Skip to content

Commit bcb810f

Browse files
committed
📝 docs: 完善四足步态控制章节图文
1 parent 9447b09 commit bcb810f

12 files changed

Lines changed: 318 additions & 63 deletions
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import mujoco
6+
import numpy as np
7+
from PIL import Image, ImageDraw
8+
9+
10+
ROOT = Path(__file__).resolve().parents[5]
11+
FIG_DIR = ROOT / "docs/practices/quadruped/cs123/figs"
12+
FIG_DIR.mkdir(parents=True, exist_ok=True)
13+
14+
LEGS = ("FL", "FR", "RL", "RR")
15+
LEG_Y = {"FL": 0.07, "FR": -0.07, "RL": 0.07, "RR": -0.07}
16+
LEG_X = {"FL": 0.105, "FR": 0.105, "RL": -0.105, "RR": -0.105}
17+
PHASE_OFFSETS = {"FL": 0.0, "FR": 0.5, "RL": 0.5, "RR": 0.0}
18+
19+
THIGH = 0.105
20+
CALF = 0.105
21+
T_CYCLE = 0.4
22+
STEP_HEIGHT = 0.04
23+
STAND_HEIGHT = 0.17
24+
25+
26+
def build_xml() -> str:
27+
legs = []
28+
for leg in LEGS:
29+
y = LEG_Y[leg]
30+
x = LEG_X[leg]
31+
side = 1 if y > 0 else -1
32+
rgba = "0.10 0.55 0.20 1" if leg in ("FL", "RR") else "0.10 0.32 0.75 1"
33+
legs.append(
34+
f"""
35+
<body name="{leg}_hip" pos="{x:.3f} {y:.3f} 0">
36+
<joint name="{leg}_hip" type="hinge" axis="1 0 0" range="-0.8 0.8" damping="1.0" armature="0.01"/>
37+
<geom type="sphere" size="0.017" rgba="{rgba}"/>
38+
<body name="{leg}_thigh" pos="0 {0.018 * side:.3f} 0">
39+
<joint name="{leg}_thigh" type="hinge" axis="0 1 0" range="-1.8 1.8" damping="1.0" armature="0.01"/>
40+
<geom type="capsule" fromto="0 0 0 0 0 {-THIGH:.3f}" size="0.011" rgba="0.55 0.55 0.55 1"/>
41+
<body name="{leg}_calf" pos="0 0 {-THIGH:.3f}">
42+
<joint name="{leg}_calf" type="hinge" axis="0 1 0" range="-2.4 0.2" damping="1.0" armature="0.01"/>
43+
<geom type="capsule" fromto="0 0 0 0 0 {-CALF:.3f}" size="0.009" rgba="{rgba}"/>
44+
<body name="{leg}_foot" pos="0 0 {-CALF:.3f}">
45+
<geom name="{leg}_foot_geom" type="sphere" size="0.018" friction="1.6 0.02 0.002" rgba="0.07 0.07 0.07 1"/>
46+
</body>
47+
</body>
48+
</body>
49+
</body>
50+
"""
51+
)
52+
53+
return f"""
54+
<mujoco model="cs123_gait_demo">
55+
<compiler angle="radian"/>
56+
<option timestep="0.002" gravity="0 0 -9.81"/>
57+
<visual>
58+
<global offwidth="720" offheight="540"/>
59+
<headlight diffuse="0.7 0.7 0.7" ambient="0.35 0.35 0.35"/>
60+
<quality shadowsize="2048"/>
61+
</visual>
62+
<asset>
63+
<texture name="grid" type="2d" builtin="checker" rgb1="0.92 0.94 0.92" rgb2="0.78 0.83 0.78" width="512" height="512"/>
64+
<material name="ground" texture="grid" texrepeat="4 4" reflectance="0.12"/>
65+
</asset>
66+
<worldbody>
67+
<light pos="0 -2 3" dir="0 1 -1" diffuse="0.8 0.8 0.8"/>
68+
<geom name="floor" type="plane" size="8 8 0.1" material="ground"/>
69+
<body name="torso" pos="0 0 {STAND_HEIGHT:.3f}">
70+
<freejoint/>
71+
<geom name="torso_geom" type="box" size="0.135 0.052 0.035" rgba="0.72 0.72 0.72 1" mass="2.0"/>
72+
{''.join(legs)}
73+
</body>
74+
</worldbody>
75+
<actuator>
76+
{''.join(f'<motor joint="{leg}_{joint}" gear="1"/>' for leg in LEGS for joint in ("hip", "thigh", "calf"))}
77+
</actuator>
78+
</mujoco>
79+
"""
80+
81+
82+
def leg_phase(t: float, leg: str, t_cycle: float = T_CYCLE, duty: float = 0.5) -> tuple[bool, float]:
83+
t_global = (t / t_cycle) % 1.0
84+
t_local = (t_global + PHASE_OFFSETS[leg]) % 1.0
85+
if t_local < duty:
86+
return True, t_local / duty
87+
return False, (t_local - duty) / (1.0 - duty)
88+
89+
90+
def foot_trajectory(s: float, in_stance: bool, step_length: float) -> np.ndarray:
91+
if in_stance:
92+
x = step_length * (0.5 - s)
93+
z = -STAND_HEIGHT
94+
else:
95+
x = step_length * (s - 0.5)
96+
z = -STAND_HEIGHT + STEP_HEIGHT * np.sin(np.pi * s)
97+
return np.array([x, z])
98+
99+
100+
def ik_leg_2d(x: float, z: float) -> tuple[float, float]:
101+
down = -z
102+
reach = np.hypot(x, down)
103+
reach = np.clip(reach, 0.045, THIGH + CALF - 0.004)
104+
cos_knee = (reach * reach - THIGH * THIGH - CALF * CALF) / (2 * THIGH * CALF)
105+
cos_knee = np.clip(cos_knee, -0.98, 0.98)
106+
knee = -np.arccos(cos_knee)
107+
hip = np.arctan2(x, down) - np.arctan2(CALF * np.sin(knee), THIGH + CALF * np.cos(knee))
108+
return hip, knee
109+
110+
111+
def gait_step(t: float, step_length: float) -> np.ndarray:
112+
target = np.zeros(12)
113+
for i, leg in enumerate(LEGS):
114+
in_stance, s = leg_phase(t, leg)
115+
foot_xz = foot_trajectory(s, in_stance, step_length)
116+
thigh, calf = ik_leg_2d(float(foot_xz[0]), float(foot_xz[1]))
117+
target[3 * i : 3 * i + 3] = [0.0, thigh, calf]
118+
return target
119+
120+
121+
def add_label(frame: np.ndarray, text: str) -> Image.Image:
122+
image = Image.fromarray(frame)
123+
draw = ImageDraw.Draw(image, "RGBA")
124+
draw.rounded_rectangle((18, 18, 330, 68), radius=10, fill=(255, 255, 255, 210))
125+
draw.text((34, 34), text, fill=(20, 30, 40, 255))
126+
return image
127+
128+
129+
def render_experiment(name: str, output: Path, step_length: float, base_speed: float) -> None:
130+
model = mujoco.MjModel.from_xml_string(build_xml())
131+
data = mujoco.MjData(model)
132+
renderer = mujoco.Renderer(model, height=540, width=720)
133+
134+
camera = mujoco.MjvCamera()
135+
camera.lookat[:] = [0.18, 0.0, 0.09]
136+
camera.distance = 0.78
137+
camera.azimuth = 135
138+
camera.elevation = -18
139+
140+
q0 = gait_step(0.0, step_length)
141+
data.qpos[:7] = [0.0, 0.0, STAND_HEIGHT, 1.0, 0.0, 0.0, 0.0]
142+
data.qpos[7:] = q0
143+
mujoco.mj_forward(model, data)
144+
145+
duration = 3.2
146+
fps = 24
147+
frames: list[Image.Image] = []
148+
149+
for frame_index in range(int(duration * fps)):
150+
t = frame_index / fps
151+
q_des = gait_step(t, step_length)
152+
# Keep the torso path prescribed so the GIF focuses on gait timing rather than
153+
# on model-specific balance tuning.
154+
data.qpos[:7] = [base_speed * t, 0.0, STAND_HEIGHT, 1.0, 0.0, 0.0, 0.0]
155+
data.qpos[7:] = q_des
156+
data.qvel[:] = 0.0
157+
mujoco.mj_forward(model, data)
158+
159+
camera.lookat[0] = base_speed * t + 0.05
160+
renderer.update_scene(data, camera=camera)
161+
frames.append(add_label(renderer.render(), name))
162+
163+
frames[0].save(
164+
output,
165+
save_all=True,
166+
append_images=frames[1:],
167+
duration=1000 // fps,
168+
loop=0,
169+
optimize=True,
170+
)
171+
print(f"saved {output} ({len(frames)} frames)")
172+
173+
174+
def main() -> None:
175+
render_experiment(
176+
name="Experiment 1: in-place trot",
177+
output=FIG_DIR / "lab5_inplace_trot.gif",
178+
step_length=0.0,
179+
base_speed=0.0,
180+
)
181+
render_experiment(
182+
name="Experiment 2: forward trot",
183+
output=FIG_DIR / "lab5_forward_trot.gif",
184+
step_length=0.10,
185+
base_speed=0.22,
186+
)
187+
188+
189+
if __name__ == "__main__":
190+
main()

docs/practices/quadruped/cs123/2.forward-kinematics.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ import DocTable from '@site/src/components/DocTable';
3333
- **关节**(Joint):连接相邻两个连杆,提供一个或多个**自由度**(DoF)。最常见的是**旋转关节**(revolute joint),提供一个绕固定轴的转角 $\theta_i$;另一类是**移动关节**(prismatic joint),提供一个沿固定轴的位移 $d_i$。一个 $n$-DoF 机械臂的状态由所有关节变量构成的向量 $q \in \mathbb{R}^n$ 完全决定。
3434
- **连杆**(Link):相邻两关节之间的刚体段,自身位姿仅由父关节决定,与子关节的状态无关。FK 计算中,连杆的作用是给出"父关节到子关节"的固定齐次变换,与几何外形(长度、惯量、碰撞体)无关。
3535

36-
此外,还有两个特殊的连杆:**末端执行器**(End Effector)和**基座**(Base)。末端执行器(简称末端)是机械臂的"手",在拓扑上是最后一个连杆,但功能特殊,所以单独命名;基座是机械臂的根部,通常固定在地面或工作台上,也是一个特殊的连杆,虽然它没有父关节。
36+
此外,还有两个特殊的连杆:**末端执行器**(End Effector)和**基座**(Base)。末端执行器(简称末端)是机械臂的"手",在拓扑上是最后一个连杆,但功能特殊,所以单独命名;基座是机械臂的根部,通常固定在地面或工作台上,也是一个特殊的连杆,虽然它没有父关节。三者在串联链条中的关系如 [图 1](#fig-joint-link-ee) 所示。
3737

3838
<Figure
39+
id="fig-joint-link-ee"
3940
src={require('./figs/joint-link-ee.webp').default}
4041
caption="关节、连杆与末端执行器的串联关系。"
4142
width={900}
@@ -52,7 +53,10 @@ import DocTable from '@site/src/components/DocTable';
5253
- **连杆坐标系**(link frame):每个连杆都有一个,记为 $\{L_i\}$,通常**贴在该连杆的父关节上**。它会随着前序关节转动而在世界里移动,是 FK 链式乘法的中间产物。
5354
- **末端坐标系**(end-effector frame):记为 $\{E\}$,贴在末端执行器上,是我们真正关心的输出坐标系。
5455

56+
这些坐标系会沿运动链逐级传递,整体关系如 [图 2](#fig-coordinate-frames) 所示。
57+
5558
<Figure
59+
id="fig-coordinate-frames"
5660
src={require('./figs/coordinate-frames.webp').default}
5761
caption="四种坐标系与 FK 的链式变换。"
5862
width={1100}
@@ -91,7 +95,7 @@ R & p \\
9195
\end{equation}
9296
$$
9397

94-
其中 $p$ 给出位置,$R$ 给出姿态。该矩阵的几何意义是:把末端坐标系 $\{E\}$ 中表示的点变换到参考坐标系 $\{B\}$ 中,如[图 3](#fig-pose) 所示
98+
其中 $p$ 给出位置,$R$ 给出姿态。该矩阵的几何意义如 [图 3](#fig-pose) 所示,即把末端坐标系 $\{E\}$ 中表示的点变换到参考坐标系 $\{B\}$ 中。
9599

96100

97101
<Figure
@@ -116,7 +120,7 @@ $$
116120

117121
本节我们详细展开一下齐次变换矩阵,即式 $\eqref{eq:fk_def}$ 里那个"把末端坐标系 $\{E\}$ 中表示的点变换到参考坐标系 $\{B\}$ 中"的过程。
118122

119-
上一节末尾要把相邻坐标系之间的变换写成代数。这一节先用图 4 建立直觉,再用几段代码验证:旋转做什么、平移做什么、合起来又是什么——读完会自然得到 §2.2 里那个 $T = \begin{bmatrix} R & p \\ 0 & 1 \end{bmatrix}$ 的形式。
123+
上一节末尾要把相邻坐标系之间的变换写成代数。这一节先用 [图 4](#fig-rotation-translation-composition) 建立直觉,再用几段代码验证:旋转做什么、平移做什么、合起来又是什么——读完会自然得到 §2.2 里那个 $T = \begin{bmatrix} R & p \\ 0 & 1 \end{bmatrix}$ 的形式。
120124

121125
<Figure
122126
id="fig-rotation-translation-composition"
@@ -229,7 +233,7 @@ $$
229233

230234
这里的 $T_{i-1}^{i}$ 表示从坐标系 $\{i\}$ 到坐标系 $\{i-1\}$ 的变换。需要注意:DH 有 standard DH 和 modified DH 等不同记法,矩阵相乘顺序会不同;阅读论文、教材或代码时,必须先确认作者采用的是哪一种约定。本文只用上式说明 Craig modified DH 的思路,后续代码不依赖 DH 表。
231235

232-
图 5 把这四个量拆成两组:$a_{i-1}$ 和 $\alpha_{i-1}$ 描述相邻关节轴之间的几何关系;$d_i$ 和 $\theta_i$ 描述当前关节沿自身 $z_i$ 轴的位移与转角
236+
这四个参数的几何含义如 [图 5](#fig-modified-dh-parameters) 所示
233237

234238
<Figure
235239
id="fig-modified-dh-parameters"
@@ -271,7 +275,7 @@ world → base → link1 → link2 → link3 → ... → end_effector
271275
- `origin`:在父连杆坐标系里,关节原点放在哪里、朝向如何?
272276
- `axis`:如果这是旋转关节或移动关节,它沿哪根轴运动?
273277

274-
图 6 把这四个字段放在同一张图里。关键是:`origin` 是模型加载时就确定的固定偏置;`axis` 是运行时关节变量 $\theta$ 作用的方向。
278+
URDF / MJCF 父-子链中这四个字段的关系如 [图 6](#fig-urdf-parent-child-joint) 所示。关键是:`origin` 是模型加载时就确定的固定偏置;`axis` 是运行时关节变量 $\theta$ 作用的方向。
275279

276280
<Figure
277281
id="fig-urdf-parent-child-joint"
@@ -332,7 +336,7 @@ $$
332336

333337
## 2.6 平面 3-DoF 手推
334338

335-
作为热身,先考虑最干净的平面 3-DoF 臂:三个连杆长度为 $L_1, L_2, L_3$,三个关节都绕 $z$ 轴旋转,关节角分别为 $\theta_1, \theta_2, \theta_3$。
339+
作为热身,先考虑最干净的平面 3-DoF 臂:三个连杆长度为 $L_1, L_2, L_3$,三个关节都绕 $z$ 轴旋转,关节角分别为 $\theta_1, \theta_2, \theta_3$。该几何模型如 [图 7](#fig-planar-3dof-fk) 所示。
336340

337341
<Figure
338342
id="fig-planar-3dof-fk"
@@ -591,7 +595,7 @@ plt.axis('equal'); plt.title('3-DoF planar arm workspace')
591595
plt.show()
592596
```
593597

594-
运行结果应接近下图所示的点云。由于本例的三段长度满足 $L_1 < L_2 + L_3$,机械臂可以折叠到原点附近,因此工作空间近似为一个圆盘;如果最长连杆长度大于其余连杆长度之和,中心会出现不可达区域,点云会更接近环形。
598+
运行结果应接近 [图 8](#fig-planar-workspace-scatter) 所示的点云。由于本例的三段长度满足 $L_1 < L_2 + L_3$,机械臂可以折叠到原点附近,因此工作空间近似为一个圆盘;如果最长连杆长度大于其余连杆长度之和,中心会出现不可达区域,点云会更接近环形。
595599

596600
<div style={{maxWidth: 420, margin: '0 auto'}}>
597601
<Figure
@@ -630,7 +634,7 @@ with mujoco.viewer.launch_passive(model, data) as v:
630634
v.sync()
631635
```
632636

633-
预期效果如下图所示:红色点由 Python FK 计算得到,蓝色点是 MuJoCo `mj_forward` 后的 `end_site` 位置。若 FK 链式乘法、`body pos``joint axis` 均一致,两者应重合在第三节连杆末端。
637+
预期效果如 [图 9](#fig-mujoco-fk-overlay) 所示。若 FK 链式乘法、`body pos``joint axis` 均一致,两者应重合在第三节连杆末端。
634638

635639
<div style={{maxWidth: 560, margin: '0 auto'}}>
636640
<Figure

docs/practices/quadruped/cs123/3.inverse-kinematics.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,13 @@ FK 是一个干净的前向函数,IK 则是"解一个非线性方程组",通
4444

4545
先退到最干净的情形——**3-DoF 平面臂**,三节长度 $L_1, L_2, L_3$,关节在 z 轴转。
4646

47-
常见的简化做法是把"末端朝向"也作为输入($\phi = \theta_1+\theta_2+\theta_3$),这样前两个关节退化成经典的 2-DoF 几何问题,第三个关节由朝向约束直接读出。
47+
常见的简化做法是把"末端朝向"也作为输入($\phi = \theta_1+\theta_2+\theta_3$),这样前两个关节退化成经典的 2-DoF 几何问题,第三个关节由朝向约束直接读出。这个拆解过程如 [图 1](#fig-planar-3dof-ik) 所示。
4848

4949
<Figure
50-
src={require('./figs/planar-3dof-ik.svg').default}
50+
id="fig-planar-3dof-ik"
51+
src={require('./figs/planar-3dof-ik.webp').default}
5152
caption="3-DoF 平面臂解析 IK:先从目标末端位姿沿末端朝向倒退 L3,得到 wrist 点 (x', y');再把 L1、L2 和 r 看成一个三角形,用余弦定理解出前两个关节。"
52-
width={1000}
53+
width={620}
5354
/>
5455

5556
令:
@@ -328,7 +329,7 @@ while data.time < 20.0:
328329
- 半径 $R$ 加大到工作空间边缘时,圆会突然变成一段弧——那是 IK 开始返回 `None`,末端走不出去。
329330
- 圆心 `center` 设到 $(0.8, 0)$ 这种绝对不可达的位置,可以看到 IK 直接退化成"朝目标方向伸直"。DLS 在这里是**优雅失败**,不崩不炸,而是尽力靠近。
330331

331-
`traj_log` 里的目标轨迹和实际轨迹画到同一张 matplotlib 图上,肉眼差距就是**控制带宽 + IK 残差**的总和。
332+
`traj_log` 里的目标轨迹和实际轨迹画到同一张 matplotlib 图上,肉眼差距就是**控制带宽 + IK 残差**的总和;示例结果如 [图 2](#fig-ik-circle-tracking) 所示
332333

333334
<div style={{maxWidth: 720, margin: '0 auto'}}>
334335
<Figure
@@ -379,6 +380,8 @@ while data.time < 15.0:
379380
- **拐角越急、$K_p$ 越大跟得越紧**,但太大会震荡(第 1 章那个老问题)。把 $K_p$ 从 40 调到 80,再调到 200,三种现象会很明显。
380381
- 把三角形某个顶点放到工作空间边缘外,可以看到那一段被"裁短"成一条直冲向最远点的射线——这是 DLS 的**饱和行为**
381382

383+
三角形轨迹的目标路径与实际末端路径对比如 [图 3](#fig-ik-triangle-tracking) 所示,重点看角点处的圆滑化现象。
384+
382385
<div style={{maxWidth: 720, margin: '0 auto'}}>
383386
<Figure
384387
id="fig-ik-triangle-tracking"
@@ -392,7 +395,7 @@ while data.time < 15.0:
392395

393396
## 3.9 实验:viewer 里实时看 DLS 收敛
394397

395-
光看末端轨迹不够直观——把 <strong>目标点(绿球)</strong> 和 <strong>当前末端(红球)</strong> 都叠到 MuJoCo viewer 里,DLS 的"追"才能一眼看出来。这一段是第 2 章 viewer 实验的直接延伸
398+
光看末端轨迹不够直观——把 <strong>目标点(绿球)</strong> 和 <strong>当前末端(红球)</strong> 都叠到 MuJoCo viewer 里,DLS 的"追"才能一眼看出来。这一段是第 2 章 viewer 实验的直接延伸,最终叠加效果见 [图 4](#fig-ik-viewer-debug-overlay)
396399

397400
```python title="代码 3-8 viewer 中实时可视化 DLS 收敛"
398401
import mujoco.viewer

0 commit comments

Comments
 (0)