跳到主要内容

5. 步态控制

上一章 Pupper 已经能站起来。这一章我们让它走起来,暂时不动强化学习,只用手写的步态生成器。这样我们能清楚知道机器人为什么会走成这样,也能为 §6 的 RL 策略提供一个对比基线。

本章走的是 model-based(基于模型) 路线:我们事先用运动学/动力学模型把"腿应该怎么动"显式写成公式——支撑相-摆动相节奏、足端轨迹、IK 反解关节角,全部由人手工设计,控制器内部没有任何需要训练的参数。它的对立面是 §6learning-based(基于学习) 路线,那里步态由神经网络在仿真里试错学出来。model-based 的好处是行为可解释、不需要数据、改一行公式就能预测效果;代价是地形/扰动一旦超出建模假设就容易崩,因此通常用作 baseline 与安全兜底。

本章目标

  • 理解支撑相(stance)与摆动相(swing)的划分
  • 能写一个最简单的 trot 步态发生器(四条腿两两同相)
  • 能用 IK 把"末端足端轨迹"转成"关节角序列"
  • 能让 Pupper 在仿真里走出 1 米直线

前置阅读

5.1 常见步态

四足步态可以先按速度理解:walk 最稳、trot 最常用、bound/gallop 更快但更难控。四种典型步态的着地节奏、占空比和适用场景如 图 1 所示。

四足常见步态:walk、trot、bound、gallop 的着地节奏与占空比。
四足常见步态:walk、trot、bound、gallop 的着地节奏与占空比。

本章从 trot(小跑) 入门,原因很简单:

  • 好写:对角腿同步,FL+RR 一组,FR+RL 一组;
  • 够稳:左右对称,支撑线穿过机身附近,不容易横向偏;
  • 效果明显:速度通常在 0.3–0.5 m/s,比 walk 更容易看出前进,又不需要 bound/gallop 的腾空和落地缓冲。

补充一点:pace(侧对步) 也是占空比约 0.5 的两两同步步态,但同步的是同侧腿(FL+RL、FR+RR),转弯和侧倾问题更明显,不作为本章重点。

CS123 Lab 4 slides 直接挑明:"Pupper is NOT expected to walk straight from this simple gait." 后面 §5.7 会让你亲眼看到这一点,并把它当作 §6 RL 的动机。

5.2 步态节奏图

步态节奏图(gait diagram)可以理解为一张按时间轴展开的甘特图:横轴是一轮步态周期 ,每一行对应一条腿。绿色段代表 stance(支撑相,脚在地上),橙色段代表 swing(摆动相,脚抬起向前摆动)

看 trot 时,不必记住图本身的形状,关键是把握它的对称关系:FL 和 RR 同步,FR 和 RL 同步,两组对角腿错开半个周期。前半个周期 FL+RR 着地、FR+RL 摆动;后半个周期两组腿正好对调,整体节奏如 图 2 所示。

trot 步态图:FL 与 RR 同相,FR 与 RL 同相,两组对角腿相差半个周期。
trot 步态图:FL 与 RR 同相,FR 与 RL 同相,两组对角腿相差半个周期。

把这张图转化为控制器,需要三个参数:

  • 周期 :一轮完整步态对应的真实时间,trot 通常取 0.3–0.5 s, 越小步频越高。
  • 占空比 :一条腿在一个周期内处于支撑相的比例。 表示半个周期着地、半个周期摆动。
  • 相位偏移 :第 条腿相对参考腿错开了多少。单位不是秒,而是"周期的比例"。

以 FL 为参考,图 2 中的 trot 可以写成:

phi_FL = 0.0, phi_RR = 0.0 # 对角线 1
phi_FR = 0.5, phi_RL = 0.5 # 对角线 2

这里最容易引起误解的是 0.5:它指的是半个周期,并不是 0.5 秒。换算成真实时间需要乘以 —— s 时半个周期是 0.2 s, s 时则缩短到 0.15 s。相位只描述"这一轮走到了哪里",因此在调整步频快慢时,四条腿之间的相对节奏不需要重写。

代码中通常先把当前时间归一化为全局相位 。可以把它理解为一个循环时钟:从 0 走到 1,归零后再来一轮。每条腿在这个时钟上叠加自己的相位偏移,就得到局部相位:

得到局部相位后,与占空比对比,就能判断该腿当前是着地还是抬起:

  • ,第 条腿处于 stance;
  • ,处于 swing。

举个例子:、全局相位 时,FL/RR 的局部相位为 0,处于支撑相;FR/RL 的局部相位为 0.5,处于摆动相。当全局相位走到 ,两组腿的状态立刻对调。

下面代码中 tt_global 容易混淆:t 是仿真时间(单位秒),t_global 才是上面公式中的归一化全局相位。

import numpy as np

# 四条腿的相位偏移 φ_i,单位是"周期的比例",不是秒
# 以 FL 为参考腿,相位为 0;RR 与 FL 同相,构成 trot 的对角线 1
# FR、RL 相位为 0.5,即与 FL 错开半个周期,构成对角线 2
PHASE_OFFSETS = {'FL': 0.0, 'FR': 0.5, 'RL': 0.5, 'RR': 0.0}

# 占空比 β:一个周期内支撑相所占的比例
# 0.5 表示半个周期着地、半个周期摆动,是 trot 的常用设置
DUTY = 0.5


def leg_phase(t, leg, T_cycle):
"""根据全局时间 t,计算指定腿当前的相位状态。

参数:
t 当前仿真时间,单位:秒
leg 腿的名称,取 'FL' / 'FR' / 'RL' / 'RR'
T_cycle 一轮步态周期对应的真实时长,单位:秒

返回:
(in_stance, s)
in_stance — bool,True 表示该腿处于支撑相,False 表示摆动相
s — 该腿在当前 stance 或 swing 段内的归一化进度,∈ [0, 1)
"""
# 把仿真时间归一化为全局相位 t_global ∈ [0, 1)
# 等价于一个周期为 T_cycle 的循环时钟,从 0 走到 1 后归零,再重新计数
t_global = (t / T_cycle) % 1.0

# 在全局时钟基础上叠加该腿自身的相位偏移,得到这条腿的局部相位
# 例如 t_global = 0.2、FR 偏移 0.5 时,FR 的局部相位为 0.7
t_local = (t_global + PHASE_OFFSETS[leg]) % 1.0

if t_local < DUTY:
# 局部相位落在 [0, β) → 处于支撑相
# 把段内位置除以 β,归一化为段内进度,使 stance 起点 s=0、终点 s→1
return True, t_local / DUTY
else:
# 局部相位落在 [β, 1) → 处于摆动相
# 同理把段内位置除以 (1 - β),swing 起点 s=0、终点 s→1
return False, (t_local - DUTY) / (1 - DUTY)

这段代码本质上是把 图 2 拆成三个可调量:T_cycle 决定时钟运行的快慢,PHASE_OFFSETS 决定四条腿如何错开,DUTY 决定每条腿的着地时长。后续的足端轨迹生成只需要 in_stance 和段内进度 s,不再处理四条腿之间的节奏关系。

5.3 足端轨迹

§5.2 解决的是节奏问题——每条腿什么时候踩地、什么时候抬腿。但腿在空间中沿什么样的曲线运动,还没有说清楚。

先建立一个基本画面:脚在身体坐标系下应该如何运动?所谓身体坐标系,就是把原点固定在机身上、跟着机身一起平移。从机身的视角看脚,运动可以拆成两段:

  • 支撑相:脚踩在地上保持不动,而机身在向前移动。从机身视角看,脚就在向后划——类似站在传送带上的感觉,脚相对地面没动,但相对身体在向后退。
  • 摆动相:脚已经抬离地面,需要在空中向前移动一段,落到机身前方,为下一次着地做准备。

把这两段衔接起来,单条腿一个周期内的足端轨迹就是一条上下拼接的闭合回路:下半段贴地运动(stance,前→后),上半段在空中划弧(swing,后→前)。这条轨迹上的四个关键点,如 图 3 所示。

单腿足端轨迹的四个关键相位:touch down、standing、lift off 与 mid swing。
单腿足端轨迹的四个关键相位:touch down、standing、lift off 与 mid swing。
相位时机足端位置(身体系下)物理意义
Touch downswing → stance 切换机身前方 + 高度 0脚刚落地的瞬间
Standingstance 中段机身正下方 + 高度 0脚扫到机身正下方,推进力最大
Lift offstance → swing 切换机身后方 + 高度 0脚抬离地面的瞬间
Mid swingswing 中段机身正下方 + 最高点在空中越过中点,不擦地面

值得留意的一点:Standing 和 Mid swing 的 x 坐标同样都位于"机身正下方",一个是在地面扫过中点,另一个是在空中越过中点——x 相同,差别只体现在 z 上。

把这条轨迹写成代码。先约定几个量:step_length 是一步的前后摆幅 step_height 是抬脚的最高高度,stand_height 是机身离地高度;x 沿前进方向、z 向上,每条腿的足端位置都是相对该腿髋关节零位测量的(每条腿有自己的局部原点,但坐标轴方向与身体系一致)。

def foot_trajectory(s, in_stance, step_length, step_height, stand_height):
"""根据相位状态,生成单条腿足端在身体系下的目标位置。

参数:
s 当前 stance 或 swing 段内的归一化进度,∈ [0, 1)
由 5.2 节的 leg_phase 给出
in_stance bool,True 表示支撑相,False 表示摆动相
step_length 一步的前后摆幅 L,单位:米
step_height 摆动相抬脚的最高高度,单位:米
stand_height 机身离地高度,单位:米

返回:
np.ndarray([x, z]),身体系下足端目标位置
x 沿前进方向,z 向上;原点为该腿髋关节零位
"""
if in_stance:
# 支撑相:脚踩在地面上不动,机身在向前移动
# 从机身视角看,脚相对身体在 x 方向上从 +L/2(前方)线性移到 -L/2(后方)
# s=0 对应 touch down 瞬间,s=1 对应 lift off 瞬间
x = step_length * (0.5 - s)
# z 取 -stand_height 并保持恒定,脚紧贴地面
# 此处不能加入任何上下波动,否则机身会随之颠簸,看起来像在原地蹦
z = -stand_height
else:
# 摆动相:脚已离地,需要在空中向前移动一段,落到机身前方
# x 方向与 stance 相反,从 -L/2(后方)走回 +L/2(前方),为下一次着地做准备
x = step_length * (s - 0.5)
# z 在 -stand_height 基础上叠加 sin(πs) 弧线
# sin(πs) 在 s=0 和 s=1 时为 0、在 s=0.5 时达到峰值 1
# 保证起脚、落脚瞬间高度恰好为 0(避免硬着陆),mid swing 抬至 step_height
z = -stand_height + step_height * np.sin(np.pi * s)
return np.array([x, z])

四个表达式都不复杂,逐行拆开看就清楚:

  • stance 的 x——step_length * (0.5 - s)s 从 0 走到 1 时,x 从 +L/2(机身前方)线性变化到 -L/2(机身后方),正好对应前面"脚定住、机身前进 → 脚相对向后划"的描述。
  • stance 的 z——取 -stand_height,是一个常量。脚踩在地面上,整个 stance 期间不应有任何垂向波动;若在此处额外加入下压或抬升,机身会随之上下震荡,更像是在原地颠簸,而不是前进。
  • swing 的 x——step_length * (s - 0.5)。方向与 stance 相反:从 -L/2 走到 +L/2,把脚从机身后方移回前方。
  • swing 的 z——-stand_height + step_height * sin(πs)sin(πs) 处取 0、在 处取 1,恰好形成一道两端落地、中间最高的弧线;起脚和落脚瞬间高度都回到 0,不会出现硬着陆。

最后再强调一下方向:脚在身体系中向后划,是因为机身在向前移动,而不是脚本身在向后蹬。这两者很容易被混淆——一旦弄反,写出来的轨迹方向整体颠倒,机器人会原地后退。

5.4 轨迹到关节角

§5.3 生成的是身体坐标系下的足端目标位置,而底层执行器接收的是关节空间中的控制输入。因此,需要先通过逆运动学(IK)将足端目标转换为目标关节角,再由 PD 控制器根据当前关节状态计算力矩或控制量,并送入 MuJoCo 仿真。这条执行管线如 图 4 所示。

从足端轨迹到关节控制的执行管线:足端目标经 IK 得到目标关节角,再由 PD 控制器输出控制量。
从足端轨迹到关节控制的执行管线:足端目标经 IK 得到目标关节角,再由 PD 控制器输出控制量。

§3 已经介绍了单腿 IK 的 DLS 求解思路。到了 §5,为了让代码重点放在步态节奏和足端轨迹上,练习代码直接复用 exercises/shared/kinematics/leg_kinematics.py 里的四腿封装 ik_pupper_leg。这个 helper 内部处理了 FL/FR/RL/RR 的髋关节偏置、左右腿 HAA 镜像和关节限位。

如果在 lab_* 目录中运行代码,需要像各个 starter.py 一样先把 codes/practices/quadruped/cs123/exercises 加入 sys.path。随后即可导入:

import numpy as np

from shared.kinematics.leg_kinematics import HIP_OFFSETS, LEG_ORDER, ik_pupper_leg


def gait_step(t, T_cycle, step_length, step_height, stand_height):
"""对四条腿各调用一次 IK,组装出整机 12 维目标关节角。

参数:
t 当前仿真时间,单位:秒
T_cycle 一轮步态周期的时长,单位:秒
step_length 一步的前后摆幅,单位:米
step_height 摆动相抬脚的最高高度,单位:米
stand_height 机身离地高度,单位:米

返回:
12 维 ndarray,顺序为 [FL, FR, RL, RR] × [HAA, HFE, KFE]
"""
# 12 = 4 条腿 × 每条腿 3 个关节(HAA / HFE / KFE)
target_q = np.zeros(12)
for k, leg in enumerate(LEG_ORDER):
# 第 1 步:节奏 — 查询这条腿当前在 stance 还是 swing、以及段内进度 s
in_stance, s = leg_phase(t, leg, T_cycle)

# 第 2 步:轨迹 — 根据 (in_stance, s) 算出身体系下足端 (x, z) 目标
foot_xz = foot_trajectory(s, in_stance,
step_length, step_height, stand_height)

# 第 3 步:补齐 y 维度 — foot_trajectory 只输出矢状面 (x, z) 轨迹
# HIP_OFFSETS[leg] 给出该腿髋关节在 base 坐标系下的位置
hip_local = np.array([foot_xz[0], 0.0, foot_xz[1]])
foot_xyz = HIP_OFFSETS[leg] + hip_local

# 第 4 步:IK — 把足端目标解算成 3 个关节角,写入对应腿的位置
# 第 k 条腿占据 target_q 的 [3k, 3k+3) 区间
target_q[3*k : 3*k+3] = ik_pupper_leg(foot_xyz, leg=leg)
return target_q

接入 §4 的 PD 回路后,target_q 作为期望关节角,与仿真中读取到的当前关节角 q 和关节速度 dq 一起用于计算控制输入。

频率分层:步态生成器和 IK 通常可以以 50–100 Hz 更新;PD 控制器则跟随 MuJoCo 仿真步长运行,常见频率为 500 Hz–2 kHz。这与 §3 IK + PD 的结构一致,只是足端参考轨迹从单腿测试轨迹扩展为四条腿同步的步态轨迹。

5.5 CPG 简述

§5.2 使用的模 1 相位计数器可以理解为一种全局时钟:控制器维护一个统一的全局相位 ,所有腿根据预先设定的相位偏移 从同一时钟中读取自己的局部相位。与之相对,CPG(Central Pattern Generator,中心模式发生器) 通常为每条腿配置一个振荡器,并通过振荡器之间的相位耦合维持整体节奏。两种节奏生成方式的差异如 图 5 所示。

全局时钟与 CPG 振荡器网络的对比:前者由统一相位驱动,后者依靠耦合振荡器维持节奏。
全局时钟与 CPG 振荡器网络的对比:前者由统一相位驱动,后者依靠耦合振荡器维持节奏。

全局时钟的优点是实现简单、参数直观,并且可以直接与 §5.2 的步态表和相位偏移定义对应起来。本章的手写 trot 控制器采用这种方式,便于调试和理解。

CPG 的主要优势在于节奏具有一定的柔性:当机器人受到外界扰动、局部接触状态发生变化时,耦合振荡器有机会通过相位调整逐步恢复协调节奏。代价是实现复杂度更高,参数也更难调试。

在现代四足机器人中,纯 CPG 控制并不是唯一主流方案;工程上常见做法包括基于全局相位的手写步态、模型预测控制,以及 §6 将介绍的强化学习步态策略。不过,CPG 强调的相位表示仍然非常重要:许多学习型策略会将全局相位或腿部相位作为额外观测输入,用来帮助策略学习周期性运动结构。

如果希望进一步实现 Hopf 振荡器形式的 trot,可以参考 Margolis 2022 的 "Walk These Ways" 第 4 节。

5.6 原地踏步实验

最稳的"步态生成器有没有写对"自检——不让它前进

from pathlib import Path

import mujoco
import numpy as np

# 教学版 MJCF 模型:延续第 4 章的 base / FL,FR,RL,RR / HAA,HFE,KFE / 12 actuator 命名,
# 但用 capsule + sphere 几何直接显示腿链和足端,避免 mesh 与简化 IK 模型错位
MODEL_PATH = Path(
"codes/practices/quadruped/cs123/5.gait-control/pupper_gait_demo.xml"
)

# 加载 MuJoCo 模型(静态结构)和对应的仿真状态数据(随时间变化)
model = mujoco.MjModel.from_xml_path(str(MODEL_PATH))
data = mujoco.MjData(model)

# 步态参数
T_cycle = 0.4 # 一轮 trot 周期 0.4 s,对应步频 2.5 Hz
step_length = 0.0 # 关键:摆幅置 0,脚抬起后原地落回,机器人不前进
step_height = 0.04 # 摆动相抬脚最高 4 cm,留出足够离地余量
stand_height = 0.18 # 机身离地 18 cm,姿态偏低更稳定,适合调试

# 12 个关节统一使用同一组 PD 增益
# Kp 决定位置跟踪刚度,Kd 提供阻尼;数值偏中等,适合教学调试
Kp = np.full(12, 30.0); Kd = np.full(12, 1.0)

# 主仿真循环:连续推进 10 s
while data.time < 10.0:
# 1) 步态生成:根据当前仿真时间算出 12 维目标关节角
target_q = gait_step(data.time, T_cycle,
step_length, step_height, stand_height)

# 2) 读取当前状态
# qpos[0:7] = 浮动基底(3 平移 + 4 四元数),关节角从索引 7 开始
# qvel[0:6] = 浮动基底的 6 维速度,关节速度从索引 6 开始
q, dq = data.qpos[7:], data.qvel[6:]

# 3) PD 控制律:τ = Kp·(q_target - q) - Kd·dq,直接写入仿真控制输入
data.ctrl[:] = Kp * (target_q - q) - Kd * dq

# 4) 推进仿真一个步长,时长由 MJCF 中 <option timestep=.../> 决定
mujoco.mj_step(model, data)

上述参数下的原地 trot 踏步效果如 图 6 所示。该实验主要用于检查对角腿同步关系、抬腿高度和机身垂向稳定性。

图 6 由 codes/practices/quadruped/cs123/5.gait-control/render_gait_experiment_gifs.py 在 conda 的 mujoco 环境中生成,加载的是本章目录下的 pupper_gait_demo.xml。这个 XML 延续 §4baseFL/FR/RL/RRHAA/HFE/KFE 和 12 actuator 命名,但用 capsule / sphere 几何直接显示腿链和足端,避免 mesh 与教学 IK 简化模型错位。

原地踏步实验:step_length = 0 时,Pupper 执行原地 trot 踏步。
原地踏步实验:step_length = 0 时,Pupper 执行原地 trot 踏步。

观察重点:

  • 对角腿是不是同步:FL + RR 一起抬、FR + RL 一起抬。不同步说明 PHASE_OFFSETS 写反了。
  • 抬腿高度对不对:viewer 里目测约 4 cm,相机俯视看不到地面闪光就是没抬起来。
  • base.z 抖动幅度:原地踏步时身体应该上下小幅起伏 < 1 cm;抖得很厉害多半是 step_height 太大或 stand_height 太低。

5.7 前进实验

step_length 改成"周期内身体应该走的距离":

接触系数经验上取 1.0~1.2(补偿 stance 末期略微滑动)。脚本几乎和原地踏步实验一样,只改一行:

注意:图 7 仍然使用同一个 pupper_gait_demo.xml 和同一条 gait/IK/PD 管线。为了让读者看清 step_length > 0 后足端相对身体的前后摆动,渲染脚本会让 XML 内置的 base weld 沿 x 方向缓慢移动;如果完全关闭这个约束,开环 trot 很快会暴露 §5.8 里的走偏和翻车问题。

v_cmd = 0.3 # 期望前进速度 0.3 m/s,trot 的常见档位
# 一步应该走的距离 = v_cmd × T_cycle × 接触系数
# 接触系数 1.1 用来补偿 stance 末期足端略微滑动造成的速度损失
step_length = v_cmd * T_cycle * 1.1 # ≈ 0.13 m

# 记录前进速度,便于事后绘图分析开环 trot 的速度跟踪情况
vx_log = []

# 主仿真循环:步态生成、状态读取、PD 控制、推进仿真等环节与 5.6 节完全一致
# 唯一区别是上面 step_length 不再是 0,机器人会真正向前移动
while data.time < 10.0:
target_q = gait_step(data.time, T_cycle,
step_length, step_height, stand_height)
q, dq = data.qpos[7:], data.qvel[6:]
data.ctrl[:] = Kp * (target_q - q) - Kd * dq
mujoco.mj_step(model, data)

# qvel[0] 是浮动基底在世界系下沿 x 方向(前进方向)的线速度
vx_log.append((data.time, data.qvel[0]))

给定前进速度后的开环 trot 效果如 图 7 所示。该实验用于观察手写开环步态在前进任务中的速度误差和走偏现象。

前进实验:给定前进速度后,Pupper 使用开环 trot 向前移动。
前进实验:给定前进速度后,Pupper 使用开环 trot 向前移动。

10 秒下来期望走出约 3 m(命令 0.3 m/s × 10 s)。但你大概率会看到三种"问题"现象——这些就是 CS123 Lab 4 slides 那句"NOT expected to walk straight"的具体表现:

  1. 走偏 1–3 度:开环控制对左右惯量不对称、四条腿摩擦不一致非常敏感。
  2. 实际速度比命令低 10–20%:触地瞬间足端有微滑,能量被摩擦带走。
  3. 跑得越快越歪v_cmd > 0.6 m/s 时 stance 阶段太短,足端来不及"压实"地面就被抬起来。

(time, vx_actual) 画成曲线和 v_cmd 横线对比,就是后面和 RL 步态对比时最直接的观察素材。这条曲线就是 §6 RL 替代手写 trot 的最直接动机

5.8 常见失败模式

按"症状 → 原因 → 怎么调"列:

症状多半的原因怎么修
滑步:触地瞬间足端横向窜出swing 末速度不为 0(写成抛物线了)改成 sin(π s) 让端点速度归零
踩空:摆动腿擦着地走step_height 太小,或 stand_height 没对齐 §4 站姿step_height 调到 4–6 cm,stand_height§4.6 那个 0.18 m 一致
对角腿不同步:四条腿乱晃PHASE_OFFSETS 写错或 % 1.0 漏写单步打印 (t_global, t_local for each leg) 一目了然
走偏:直线命令但越走越斜开环控制本身的痼疾模型驱动方法里加 yaw 反馈 PI(base 朝向 → 前后腿步长差)
翻车:高速下身体前栽T_cycle 太长或 stance 太短提高步频(T_cycle 0.4 → 0.3)让四足"踏踏地"更频繁
NaN 飞天step_length > 工作空间,IK 返回 NaNIK 调用前 clip 一下 foot 目标到工作空间内

调参顺序(推荐):原地踏步实验先把 4 条腿对齐 → 前进实验用 v_cmd = 0.2 跑出"看着像走"的视频 → 再逐渐加速看哪条症状先出来 → 针对症状调一项参数,每次只调一项。

项目拆解

§5.6 让 trot 原地踏步,§5.7 把它推上 ground 看到了走偏。但 §5.1 里还提到了 pace、bound:它们和 trot 到底差在哪?这里直接看一条完整工程链路:把 leg_phase()foot_trajectory()gait_step() 抽成一个 gait factory,让 trot / pace / bound 共用同一套轨迹、IK 和 PD,只改 offsets / duty

从哪个文件开始

这个 Lab 的代码在:

codes/practices/quadruped/cs123/exercises/lab_5_gait_zoo/

先认清几个文件:

文件作用
starter.py本节主脚本:完整实现 + 关键注释,直接从这里学习步态生成链路
make_artifacts.py生成教程中的观察图:三联 GIF、Gantt 图、base z FFT
models/pupper_zoo.xml三只 Pupper 并排展示的 MuJoCo 场景
tests.py可选数值检查:想深入时再看,不作为本节学习要求

阅读时建议把 starter.py 折叠到这几个部分:GAITSleg_phase()foot_trajectory()gait_step()render_panel_gif_frames()。其余加载模型、渲染、画图代码先当成基础设施。

代码主线

整个 Lab 可以按这条流水线理解。注意:这里每一步都已经在 starter.py 里写好,学习重点是看懂"为什么三种步态只差几个相位数字"。

GAITS
定义 trot / pace / bound 的 offsets 和 duty

leg_phase()
全局时间 -> 某条腿处于 stance 还是 swing

foot_trajectory()
局部进度 -> hip-local 足端目标

gait_step()
四条腿足端目标 -> IK -> 12 维关节角

make_artifacts.py
画出三联 GIF、Gantt 图和 base z FFT

改动 1:用 GAITS 描述三种步态

starter.py 里先把三种步态写成同一种数据结构。真正决定"哪几条腿同相"的是 offsets

GAITS = {
"trot": {
"offsets": {"FL": 0.0, "FR": 0.5, "RL": 0.5, "RR": 0.0},
"duty": 0.5,
"T_cycle": 1.2,
"step_length": 0.07,
"step_height": 0.05,
"stand_height": 0.14,
},
"pace": {
"offsets": {"FL": 0.0, "FR": 0.5, "RL": 0.0, "RR": 0.5},
"duty": 0.5,
...
},
"bound": {
"offsets": {"FL": 0.0, "FR": 0.0, "RL": 0.5, "RR": 0.5},
"duty": 0.4,
...
},
}

读这段时只抓一件事:trot 是对角腿同相,pace 是同侧腿同相,bound 是前腿同相、后腿同相。duty 是支撑相占整个周期的比例,0.5 表示一半时间踩地、一半时间摆腿。

改动 2:全局时钟转单腿相位

入口是:

def leg_phase(t: float, leg: str, *, offsets: dict[str, float], duty: float, T_cycle: float) -> tuple[bool, float]:
...

核心逻辑是先把时间折回一个周期,再加上这条腿自己的相位偏移:

t_global = (t / T_cycle) % 1.0
t_local = (t_global + float(offsets[leg])) % 1.0

if t_local < duty:
return True, t_local / duty
return False, (t_local - duty) / (1.0 - duty)

返回值里的第一个量表示这条腿是否在支撑相;第二个量 s 是局部进度,始终归一化到 [0, 1]。这样后面的足端轨迹函数不需要关心全局时间,也不需要知道当前是 trot 还是 pace。

改动 3:相位转足端轨迹

入口是:

def foot_trajectory(
s: float,
in_stance: bool,
*,
step_length: float,
step_height: float,
stand_height: float,
) -> np.ndarray:
...

支撑相和摆动相各做一件简单的事:

if in_stance:
# 脚踩住地面,身体向前经过它,所以足端在 hip-local 坐标里向后扫。
x = step_length * (0.5 - s)
z = -stand_height
else:
# 脚离地向前摆。sin(pi*s) 让起点/终点都回到站立高度,中间最高。
x = step_length * (s - 0.5)
z = -stand_height + step_height * np.sin(np.pi * s)

这里用 sin(pi*s) 而不是普通抛物线,是为了让摆动相起点和终点都平滑回到地面高度,减少 §5.8 里的"滑步"。

改动 4:四条腿拼成 12 维目标角

入口是:

def gait_step(t: float, ctx: GaitContext) -> np.ndarray:
...

它只是把前面两步对四条腿循环一遍,然后调用 Lab 3 已经准备好的 IK:

for k, leg in enumerate(LEG_ORDER):
in_stance, s = leg_phase(...)
hip_local = foot_trajectory(...)
foot_xyz = HIP_OFFSETS[leg] + hip_local
q_leg = ik_pupper_leg(foot_xyz, leg=leg, q_seed=ctx.q_seed[leg])
target_q[3 * k : 3 * k + 3] = q_leg

HIP_OFFSETS[leg] + hip_local 这一步很关键:foot_trajectory() 生成的是髋关节局部坐标下的足端目标,而 IK 需要机器人坐标系下的位置。

怎么运行

命令从 codes/practices/quadruped/cs123/exercises/ 目录运行:

cd codes/practices/quadruped/cs123/exercises

# 可选:先确认 shared 里的四腿 IK 能正常工作
uv run python shared/kinematics/test_leg_kinematics.py

# 直接运行完整 starter:打印三种步态的 base z 抖动和 roll 激励指标
uv run python lab_5_gait_zoo/starter.py

# 生成下方观察图,图片会写到 lab_5_gait_zoo/portfolio/
uv run python lab_5_gait_zoo/make_artifacts.py

正常输出大致是:

trot: base z std=0.000 mm, roll excitation std=0.000
pace: base z std=0.000 mm, roll excitation std=0.998
bound: base z std=0.000 mm, roll excitation std=0.000

这里的 roll excitation 只是一个简化指标:同侧腿一起支撑时,左右受力更不均匀,所以 pace 的侧滚激励明显大于 trot。

运行后看什么

第一张图是三种步态的并排动图。trot 对角腿成对抬落,pace 同侧腿一起抬落,bound 前腿和后腿分组抬落。

trot、pace、bound 在相同模型、相同 PD 参数和相同足端轨迹幅值下的并排对照。
trot、pace、bound 在相同模型、相同 PD 参数和相同足端轨迹幅值下的并排对照。

第二张图是 stance/swing Gantt 图。深色表示支撑相,白色表示摆动相。它把"对角同相 / 同侧同相 / 前后同相"从视频现象落到相位数据上。

三种步态的 stance/swing Gantt 图:trot 对角同相,pace 同侧同相,bound 前后成组。
三种步态的 stance/swing Gantt 图:trot 对角同相,pace 同侧同相,bound 前后成组。

第三张图是 welded 场景下的 base z FFT。因为 base 被焊住,这里的 base z 抖动非常小;这张图更适合用来理解"同一套步态生成器可以被数据化观察",而不是用来比较真实地面稳定性。

三种步态在 welded 场景下的 base z 频谱。
三种步态在 welded 场景下的 base z 频谱。

为什么这里先把 Pupper 焊在空中?因为这个阶段只想看 phase pattern。pace / bound 一接到地面上,很快会侧翻或前栽,注意力会从"哪几条腿同相"转移到"怎么摔倒"。把 base 解开、接触地面、让策略自己学会稳定,是 §6 要处理的问题。

完整脚本见 exercises/lab_5_gait_zoo/

参考资料