Skip to main content

2. 运动学核心概念与坐标变换

上一讲直接给出了 FK 公式,但没有解释为什么第二项是 cos(θ1 + θ2) 而不是 cos(θ2)。这一讲从这个问题出发,推导坐标系变换,最终让 FK 公式从矩阵连乘里自然推出来。


前言:lesson_01 留下的一个问题

平面两连杆机械臂示意

仔细看上面这张图:θ1 是连杆 L1 相对全局坐标系 F0(x 轴)的角度,θ2 是连杆 L2 相对连杆 L1 延长线的角度——注意图中虚线,θ2 的参考方向不是 x 轴,而是 L1 的方向。

在上一讲,我们直接给出了两连杆机械臂的正运动学公式:

x = L1 * cos(θ1) + L2 * cos(θ1 + θ2)
y = L1 * sin(θ1) + L2 * sin(θ1 + θ2)

你可能当时就有一个疑问:

为什么第二项是 cos(θ1 + θ2),而不是 cos(θ2)

答案就藏在图里:θ2 是相对 L1 量的,不是相对全局 x 轴量的。要算末端在全局坐标系中的位置,就必须把这个"局部角度"转换到全局参考系——这个转换过程,就是本讲的核心。

理解了这一点,你就理解了:

  • 为什么机器人需要多个坐标系
  • 齐次变换矩阵是什么,为什么要用它
  • FK 公式不是"背下来的",而是从坐标系变换一步步推出来的

2.1 为什么 θ2 不是全局角度?

2.1.1 用手臂做类比

伸出你的右臂,想象:

  • 你的肩膀是关节 1,大臂是连杆 1
  • 你的肘部是关节 2,小臂是连杆 2

现在做一个动作:

  1. 大臂向前抬起 45°(θ1 = 45°,相对地面)
  2. 小臂相对大臂再弯曲 30°(θ2 = 30°,相对大臂)

问:小臂在全局空间里的方向是多少度?

答案是 75°,也就是 θ1 + θ2 = 45° + 30°

╱ 小臂(全局方向 75°)

* 肘部(关节 2) ← θ2 = 30° 是相对大臂量的

╱ 大臂

────*──────── 地面
肩部 ↑ θ1 = 45° 是相对地面量的
(关节 1)

这就是为什么公式里是 θ1 + θ2θ2 是相对大臂的角度,不是相对地面的角度

2.1.2 这就是"坐标系"问题的本质

θ2 是在大臂坐标系里定义的,不是在全局坐标系里定义的。

机器人的每个关节都只知道"自己相对上一级转了多少",要算末端在全局的位置,就必须把这些局部角度一层层传递、累加。这棵层级结构,就是下一节要讲的坐标系树


2.2 为什么机器人需要多个坐标系

2.2.1 生活类比:同一个地方,不同的描述

假设你在北京,朋友在上海。你们同时描述天安门广场的位置:

  • 你说:"在我北边 2 公里"
  • 朋友说:"在我北边 1200 公里"

两个描述都是正确的,只是参考点不同

机器人里也一样:

  • 相机说:"杯子在我前方 0.3 米"(相机坐标系)
  • 机械臂说:"杯子在我右边 0.5 米、前方 0.2 米"(机械臂基座坐标系)

同一个杯子,在不同坐标系下,数字完全不同。

2.2.2 机械臂的坐标系树

一个两连杆机械臂有这样的坐标系结构:

world(世界坐标系)
└── base(机械臂基座坐标系)
└── link1(第一根连杆坐标系)
└── link2(第二根连杆坐标系)
└── end_effector(末端执行器坐标系)

每个坐标系都"附着"在对应的物理部件上,随着关节转动而转动。

为什么要这样设计?

因为每个关节只知道"自己相对于上一级转了多少角度",不知道全局位置。把这些局部信息一层层传递,最终才能算出末端在全局的位置。

这棵坐标系树,在 ROS2 里由 tf2 负责管理。第 8 讲会详细讲 tf2,现在只需要知道"有这么一棵树"就够了。

2.2.3 坐标系之间的关系

相邻两个坐标系之间的关系,可以用两个量描述:

  • 平移:子坐标系的原点,相对父坐标系原点偏移了多少
  • 旋转:子坐标系的轴方向,相对父坐标系旋转了多少角度
父坐标系

│ 平移 (dx, dy)
│ 旋转 θ

子坐标系

用数学表达旋转:


2.3 旋转矩阵——从直觉到数值

2.3.1 一个具体问题

有一个点 P,坐标是 (1, 0)。

现在把整个坐标系绕原点旋转 30°(或者等价地,把点 P 反向旋转 30°)。

旋转后,P 的新坐标是多少?

y


│ · P'(0.866, 0.500)
│ ·
│ ·
│ ·
│ ·
│ · ⌒30°
O──────────────────────── x
P(1,0)

用三角函数可以算出:

P'_x = cos(30°) = 0.866
P'_y = sin(30°) = 0.500

2.3.2 推广到任意点

如果点 P 的坐标是 (x, y),绕原点旋转 θ 角后,新坐标是:

x' = x * cos(θ) - y * sin(θ)
y' = x * sin(θ) + y * cos(θ)

这个公式怎么来的?

把点 P 用极坐标表示:P = (r*cos(α), r*sin(α)),旋转 θ 后变成 (r*cos(α+θ), r*sin(α+θ)),展开用和角公式化简,就得到上面的公式。

2.3.3 写成矩阵形式

上面的公式可以写成矩阵乘法:

2D 旋转矩阵可视化
[x'] [cos(θ) -sin(θ)] [x]
[y'] = [sin(θ) cos(θ)] [y]

右边那个 2×2 矩阵,就叫做 2D 旋转矩阵,记作 R(θ):

R(θ) = [cos(θ) -sin(θ)]
[sin(θ) cos(θ)]

2.3.4 数值验证

用 Python 验证一下:点 (1, 0) 旋转 30° 后的坐标。

import numpy as np

def rotation_matrix_2d(theta_deg):
"""
生成 2D 旋转矩阵
参数:theta_deg 旋转角度(度)
返回:2x2 旋转矩阵
"""
theta = np.radians(theta_deg)
R = np.array([
[np.cos(theta), -np.sin(theta)],
[np.sin(theta), np.cos(theta)]
])
return R

# 点 P = (1, 0),旋转 30°
R = rotation_matrix_2d(30)
P = np.array([1, 0])
P_new = R @ P # @ 是矩阵乘法运算符

print(f"旋转矩阵 R(30°):")
print(R)
print(f"\n旋转后的点: ({P_new[0]:.4f}, {P_new[1]:.4f})")
# 预期输出: (0.8660, 0.5000)

运行结果:

旋转矩阵 R(30°):
[[ 0.866 -0.5 ]
[ 0.5 0.866]]

旋转后的点: (0.8660, 0.5000)

和我们手算的结果完全一致。

2.3.5 旋转矩阵的直觉理解

旋转矩阵的两列,其实就是旋转后的坐标轴方向

R(θ) = [cos(θ) -sin(θ)]
[sin(θ) cos(θ)]
↑列1 ↑列2
旋转后的x轴 旋转后的y轴
  • 第 1 列 [cos(θ), sin(θ)]:原来的 x 轴,旋转 θ 后指向哪里
  • 第 2 列 [-sin(θ), cos(θ)]:原来的 y 轴,旋转 θ 后指向哪里

这个理解方式在后面看 tf2 的输出时会很有用。


2.4 平移 + 旋转分开写的麻烦

2.4.1 只有旋转还不够

机器人的每个关节,不只是旋转,还有平移(连杆有长度)。

比如第一根连杆:

  • 关节 1 旋转了 θ1
  • 连杆 1 的末端(也就是关节 2 的位置)相对关节 1 平移了 (L1, 0)

如果分开写,一次变换需要两步:

# 第一步:旋转
P_rotated = R @ P

# 第二步:平移
P_final = P_rotated + translation

2.4.2 连续变换时的麻烦

如果有两段连杆,就需要做两次这样的操作:

# 第一段连杆的变换
P1 = R1 @ P0 + t1

# 第二段连杆的变换
P2 = R2 @ P1 + t2

# 展开后:
P2 = R2 @ (R1 @ P0 + t1) + t2
= R2 @ R1 @ P0 + R2 @ t1 + t2

三段连杆就更复杂了。而且旋转和平移混在一起,很容易出错。

有没有办法把旋转和平移合并成一个操作?

有,这就是齐次变换矩阵


2.5 齐次变换矩阵——统一旋转和平移

2.5.1 核心思想:升维

齐次变换矩阵结构

齐次变换矩阵的思路是:给坐标加一个维度

2D 的点 (x, y) 变成 (x, y, 1),这个多出来的 1 是固定的,叫做齐次坐标

然后用一个 3×3 矩阵,同时完成旋转和平移:

[x'] [cos(θ) -sin(θ) tx] [x]
[y'] = [sin(θ) cos(θ) ty] [y]
[1 ] [0 0 1] [1]

这个 3×3 矩阵就是 2D 齐次变换矩阵,记作 T:

T = [R t] = [cos(θ) -sin(θ) tx]
[0 1] [sin(θ) cos(θ) ty]
[0 0 1]

其中:

  • R 是 2×2 旋转矩阵
  • t = (tx, ty) 是平移向量
  • 最后一行 [0, 0, 1] 是固定的

2.5.2 为什么这样设计能合并旋转和平移?

展开矩阵乘法验证一下:

[cos(θ) -sin(θ) tx] [x] [x*cos(θ) - y*sin(θ) + tx]
[sin(θ) cos(θ) ty] [y] = [x*sin(θ) + y*cos(θ) + ty]
[0 0 1] [1] [1 ]

结果的前两行正好是:先旋转,再平移。完美。

2.5.3 完整数值例子

问题:点 P = (2, 0),先绕原点旋转 45°,再沿 x 方向平移 1,沿 y 方向平移 0.5。求变换后的坐标。

Python 验证

import numpy as np

def homogeneous_transform_2d(theta_deg, tx, ty):
"""
生成 2D 齐次变换矩阵
参数:
theta_deg: 旋转角度(度)
tx, ty: 平移量
返回:3x3 齐次变换矩阵
"""
theta = np.radians(theta_deg)
T = np.array([
[np.cos(theta), -np.sin(theta), tx],
[np.sin(theta), np.cos(theta), ty],
[0, 0, 1]
])
return T

# 点 P = (2, 0),旋转 45°,平移 (1, 0.5)
T = homogeneous_transform_2d(45, 1, 0.5)
P = np.array([2, 0, 1]) # 注意:齐次坐标,最后加 1

P_new = T @ P

print(f"变换矩阵 T:")
print(np.round(T, 4))
print(f"\n变换后的点: ({P_new[0]:.4f}, {P_new[1]:.4f})")
# 预期输出: (2.4142, 1.9142)

运行结果:

变换矩阵 T:
[[ 0.7071 -0.7071 1. ]
[ 0.7071 0.7071 0.5 ]
[ 0. 0. 1. ]]

变换后的点: (2.4142, 1.9142)

和手算结果一致。


2.6 矩阵连乘 = 坐标系链(核心)

2.6.1 连续变换变成矩阵连乘

有了齐次变换矩阵,连续变换就变成了矩阵连乘:

T_total = T1 * T2 * T3 * ...

这比之前的"旋转 + 平移分开写"简洁多了,而且不容易出错。

2.6.2 两连杆机械臂的变换链

对于两连杆机械臂,坐标系链是:

base → link1 → end_effector

对应的变换矩阵:

  • T_b_1:link1 坐标系相对 base 坐标系的变换(由 θ1 和 L1 决定)
  • T_1_e:end_effector 坐标系相对 link1 坐标系的变换(由 θ2 和 L2 决定)

末端相对 base 坐标系的齐次变换

T_b_e = T_b_1 * T_1_e

T_b_e 是一个 3×3 齐次变换矩阵,包含旋转和平移两部分信息。末端位置从其第三列提取:x = T_b_e[0,2]y = T_b_e[1,2]

2.6.3 写出每个变换矩阵

T_b_1(base → link1):

关节 1 旋转了 θ1,连杆 1 长度为 L1,所以 link1 的末端(关节 2 的位置)相对 base 的变换是:

T_b_1 = [cos(θ1) -sin(θ1) L1*cos(θ1)]
[sin(θ1) cos(θ1) L1*sin(θ1)]
[0 0 1 ]

注意:平移量 (L1*cos(θ1), L1*sin(θ1)) 就是关节 2 在 base 坐标系中的位置。

T_1_e(link1 → end_effector):

关节 2 相对 link1 旋转了 θ2,连杆 2 长度为 L2:

T_1_e = [cos(θ2) -sin(θ2) L2*cos(θ2)]
[sin(θ2) cos(θ2) L2*sin(θ2)]
[0 0 1 ]

2.6.4 矩阵连乘推导 FK 公式

矩阵连乘展开过程:

T_b_e = T_b_1 · T_1_e

[cos θ1 -sin θ1 L1·cos θ1] [cos θ2 -sin θ2 L2·cos θ2]
= [sin θ1 cos θ1 L1·sin θ1] · [sin θ2 cos θ2 L2·sin θ2]
[0 0 1 ] [0 0 1 ]

展开第三列(平移部分,即末端位置):

x = cos θ1 · L2·cos θ2 + (-sin θ1) · L2·sin θ2 + L1·cos θ1
= L1·cos θ1 + L2·(cos θ1·cos θ2 - sin θ1·sin θ2)
= L1·cos θ1 + L2·cos(θ1 + θ2) ← 和差化积

y = sin θ1 · L2·cos θ2 + cos θ1 · L2·sin θ2 + L1·sin θ1
= L1·sin θ1 + L2·(sin θ1·cos θ2 + cos θ1·sin θ2)
= L1·sin θ1 + L2·sin(θ1 + θ2) ← 和差化积

这就是 FK 公式的来源——矩阵连乘第三列展开后,用三角和差化积化简,直接得到我们在第 1 讲里给出的公式。

import numpy as np

def fk_via_matrix(L1, L2, theta1_deg, theta2_deg):
"""
用矩阵连乘推导两连杆机械臂正运动学
"""
theta1 = np.radians(theta1_deg)
theta2 = np.radians(theta2_deg)

# base → link1 的变换矩阵
T_b_1 = np.array([
[np.cos(theta1), -np.sin(theta1), L1 * np.cos(theta1)],
[np.sin(theta1), np.cos(theta1), L1 * np.sin(theta1)],
[0, 0, 1 ]
])

# link1 → end_effector 的变换矩阵
T_1_e = np.array([
[np.cos(theta2), -np.sin(theta2), L2 * np.cos(theta2)],
[np.sin(theta2), np.cos(theta2), L2 * np.sin(theta2)],
[0, 0, 1 ]
])

# 连乘得到 base → end_effector 的变换
T_b_e = T_b_1 @ T_1_e

# 末端位置就是变换矩阵最后一列的前两个元素
x = T_b_e[0, 2]
y = T_b_e[1, 2]

return x, y

# 测试:L1=1, L2=0.8, θ1=30°, θ2=45°
x, y = fk_via_matrix(1.0, 0.8, 30, 45)
print(f"矩阵连乘结果: x = {x:.4f}, y = {y:.4f}")

# 用直接公式验证
theta1 = np.radians(30)
theta2 = np.radians(45)
L1, L2 = 1.0, 0.8
x2 = L1 * np.cos(theta1) + L2 * np.cos(theta1 + theta2)
y2 = L1 * np.sin(theta1) + L2 * np.sin(theta1 + theta2)
print(f"直接公式结果: x = {x2:.4f}, y = {y2:.4f}")
print(f"两种方法结果一致: {np.isclose(x, x2) and np.isclose(y, y2)}")

运行结果:

矩阵连乘结果: x = 1.3428, y = 1.2071
直接公式结果: x = 1.3428, y = 1.2071
两种方法结果一致: True

这就是 FK 公式的来源:它不是凭空背下来的,而是矩阵连乘展开后的结果。

2.6.5 用 ASCII 图理解每一步

步骤 1:base 坐标系
y


└──── x
O(base)

步骤 2:关节 1 旋转 θ1,连杆 1 延伸 L1
y
│ * ← 关节2的位置 (L1*cos(θ1), L1*sin(θ1))
│ ╱
│ ╱ L1
│ ╱ θ1
└──── x
O(base)

步骤 3:在关节2处建立 link1 坐标系,关节 2 再旋转 θ2,连杆 2 延伸 L2
y
│ * ← 关节2
│ ╱ ╲
│ ╱ ╲ L2
│ ╱ θ1 ╲ (θ1+θ2 方向)
└──── x * ← 末端位置
O(base)

末端位置 = 关节2位置 + 连杆2在全局方向上的投影:

x = L1*cos(θ1) + L2*cos(θ1+θ2)
y = L1*sin(θ1) + L2*sin(θ1+θ2)

现在你知道这个公式为什么是这样了。


2.7 常见误区

2.7.1 误区一:以为 θ2 是全局角度

错误理解:θ2 = 30° 意味着第二根连杆在全局坐标系里指向 30°。

正确理解:θ2 = 30° 意味着第二根连杆相对第一根连杆旋转了 30°。第二根连杆在全局坐标系里的方向是 θ1 + θ2

怎么避免:每次看到关节角,都问自己"这个角度是相对谁的?"


2.7.2 误区二:以为矩阵乘法顺序可以随意交换

矩阵乘法不满足交换律T1 * T2 ≠ T2 * T1

import numpy as np

# 先旋转 45°,再平移 (1, 0)
T_rotate_then_translate = (
homogeneous_transform_2d(0, 1, 0) @ # 平移
homogeneous_transform_2d(45, 0, 0) # 旋转
)

# 先平移 (1, 0),再旋转 45°
T_translate_then_rotate = (
homogeneous_transform_2d(45, 0, 0) @ # 旋转
homogeneous_transform_2d(0, 1, 0) # 平移
)

P = np.array([0, 0, 1]) # 原点
print("先旋转再平移:", (T_rotate_then_translate @ P)[:2].round(4))
print("先平移再旋转:", (T_translate_then_rotate @ P)[:2].round(4))
# 两个结果不同!

运行结果:

先旋转再平移: [1. 0.]
先平移再旋转: [0.7071 0.7071]

结论:变换的顺序很重要,矩阵乘法不满足交换律。

这里有两种常见的写法,规则不同,注意区分:

  • 对点施加连续变换T_final · P = T2 · T1 · P,从右往左读,T1 先执行
  • 坐标系链T_b_e = T_b_1 · T_1_e,从左往右读,和坐标系链方向一致(base → link1 → end_effector)

本讲 2.6.2 节用的是坐标系链写法,两者本质等价,只是视角不同。


2.7.3 误区三:以为齐次变换矩阵只是"数学技巧"

齐次变换矩阵不只是数学上的便利,它在工程里直接对应实际系统:

  • tf2 内部就是用变换矩阵(实际上是四元数 + 平移向量,等价于齐次变换矩阵)来存储坐标系关系的
  • URDF 里每个 <joint> 标签定义的就是相邻坐标系之间的变换
  • MoveIt 2 的 IK 求解器,本质上就是在求"给定末端变换矩阵,反推各关节角"

理解了齐次变换矩阵,你看 tf2 的输出、URDF 的结构、MoveIt 2 的 API 都会更清晰。


2.8 本讲完整代码汇总

把本讲所有函数整理到一个文件,可以直接运行:

# lesson_02_transforms.py
# 运行方式:python3 lesson_02_transforms.py

import numpy as np


def rotation_matrix_2d(theta_deg):
"""生成 2D 旋转矩阵"""
theta = np.radians(theta_deg)
return np.array([
[np.cos(theta), -np.sin(theta)],
[np.sin(theta), np.cos(theta)]
])


def homogeneous_transform_2d(theta_deg, tx, ty):
"""生成 2D 齐次变换矩阵"""
theta = np.radians(theta_deg)
return np.array([
[np.cos(theta), -np.sin(theta), tx],
[np.sin(theta), np.cos(theta), ty],
[0, 0, 1]
])


def fk_via_matrix(L1, L2, theta1_deg, theta2_deg):
"""用矩阵连乘计算两连杆机械臂正运动学"""
theta1 = np.radians(theta1_deg)
theta2 = np.radians(theta2_deg)

T_b_1 = np.array([
[np.cos(theta1), -np.sin(theta1), L1 * np.cos(theta1)],
[np.sin(theta1), np.cos(theta1), L1 * np.sin(theta1)],
[0, 0, 1]
])

T_1_e = np.array([
[np.cos(theta2), -np.sin(theta2), L2 * np.cos(theta2)],
[np.sin(theta2), np.cos(theta2), L2 * np.sin(theta2)],
[0, 0, 1]
])

T_b_e = T_b_1 @ T_1_e
return T_b_e[0, 2], T_b_e[1, 2]


if __name__ == "__main__":
print("=== 旋转矩阵演示 ===")
R = rotation_matrix_2d(30)
P = np.array([1, 0])
print(f"点 (1,0) 旋转 30° 后: {(R @ P).round(4)}")

print("\n=== 齐次变换矩阵演示 ===")
T = homogeneous_transform_2d(45, 1, 0.5)
P_h = np.array([2, 0, 1])
result = T @ P_h
print(f"点 (2,0) 旋转 45° 再平移 (1,0.5) 后: ({result[0]:.4f}, {result[1]:.4f})")

print("\n=== 矩阵连乘 FK 演示 ===")
configs = [
(1.0, 0.8, 0, 0, "两杆伸直"),
(1.0, 0.8, 0, 90, "第二杆向上弯 90°"),
(1.0, 0.8, 30, 45, "θ1=30°, θ2=45°"),
(1.0, 0.8, 90, -90, "θ1=90°, θ2=-90°"),
]
for L1, L2, t1, t2, desc in configs:
x, y = fk_via_matrix(L1, L2, t1, t2)
print(f" {desc}: 末端 = ({x:.4f}, {y:.4f})")

运行结果:

=== 旋转矩阵演示 ===
点 (1,0) 旋转 30° 后: [0.866 0.5 ]

=== 齐次变换矩阵演示 ===
点 (2,0) 旋转 45° 再平移 (1,0.5) 后: (2.4142, 1.9142)

=== 矩阵连乘 FK 演示 ===
两杆伸直: 末端 = (1.8000, 0.0000)
第二杆向上弯 90°: 末端 = (1.0000, 0.8000)
θ1=30°, θ2=45°: 末端 = (1.3428, 1.2071)
θ1=90°, θ2=-90°: 末端 = (0.8000, 1.0000)

试着改变 θ1 和 θ2 的值,观察末端位置如何变化,建立直觉。


本讲核心总结

概念一句话理解
θ2 是相对角度θ2 是相对上一段连杆的角度,全局方向是 θ1 + θ2
坐标系树机器人每个部件有自己的坐标系,形成父子树结构
旋转矩阵2×2 矩阵,描述坐标系旋转,两列是旋转后的坐标轴方向
齐次变换矩阵3×3 矩阵,把旋转和平移合并成一个操作
矩阵连乘多段变换串联,顺序不能交换,坐标系链从左往右读
FK 公式的来源矩阵连乘展开后的结果,不是凭空背下来的

参考代码

本讲对应的完整参考代码位于:

ros_ws/scripts/lesson_02_transforms.py

文件内容:旋转矩阵、齐次变换矩阵、矩阵连乘 FK,含详细原理注释。

直接运行(无需 ROS2 环境):

python3 $ROS_WS/scripts/lesson_02_transforms.py

代码注释中标注了 [原理][注意][对比] 等标记,与本讲教程内容互补。


下一讲预告

3. 正运动学与逆运动学实现

现在你已经理解了 FK 公式从哪里来。下一讲我们会:

  • 完整推导两连杆 IK 的解析解(用余弦定理)
  • 处理双解(肘上 / 肘下)
  • 判断目标点是否可达
  • 写出完整的 FK + IK Python 实现
  • 用 FK 验证 IK 的正确性(round-trip 测试)

这些函数后面会直接接入 ROS2 节点,成为整个系统的算法核心。