人形机器人步态控制从ZMP到MPC实战解析
导读:人形机器人这两年火得不行,但站稳走稳始终是核心难题。本文顺着ZMP判据、LIPM建模、MPC优化、鲁棒控制、闭环仿真、ROS2部署这条链路,每段贴核心代码、标测试要点、说踩过的坑。
总体技术链路七阶段贯通
人形机器人步态控制的核心链路贯通七个阶段,先拿ZMP判据判断会不会摔,再用LIPM把复杂动力学简化为4维状态空间,接着用贝塞尔曲线规划摆动腿轨迹、用分段常数生成ZMP参考序列,然后靠MPC滚动优化未来N步的地面反力并通过QP求解,同时叠加PD+滑模修正项对抗外力扰动,最后在2D/3D闭环仿真里验证长时间稳定性和抗扰动能力,最终通过ROS2节点以100Hz频率部署到真机。下面这张图把这七个阶段串成一条完整的链路。
步态控制核心矛盾高重心与强耦合
人形机器人有两个天然缺陷:
| 缺陷 | 表现 | 后果 |
|---|---|---|
| 高重心窄支撑 | 重心高、足底面积小 | 重心投影偏离足底就倾倒 |
| 多自由度强耦合 | 下肢6-DOF×2=12关节 | 抬一只脚整条链都在抖 |
说白了,步态控制就两件事,不摔和走得快。这俩天然矛盾,越想跑跳越容易翻车。
主流解决路线:
| 路线 | 代表 | 优点 | 缺点 |
|---|---|---|---|
| 纯模型驱动 | ZMP经典路线 / IIT ergoCub | 可证明稳定、可解释 | 建模困难 |
| 数据驱动 | DRL端到端 / GR-1 | 泛化性强 | sim2real gap大 |
| 模型+约束 | CBF-QP | 兼顾安全与灵活 | 约束设计复杂 |
下面重点聊路线1。
ZMP零力矩点稳定性判据
物理原理
一句话,ZMP落在支撑多边形内,机器人就不会翻跟头。
支撑多边形就是双脚(或单脚站立时那只脚)与地面的接触面积。ZMP是地面反力合力矩为零的点,水平方向上恰好抵消,机器人处于力矩平衡状态。
ZMP怎么算
ZMP位置 = 质心位置 - (质心高度 ÷ 重力加速度) × 质心水平加速度
- 静止时,ZMP在质心正下方
- 加速前进时,ZMP向前偏移,加速度越大偏移越多
- 重心越高,相同加速度下偏移越明显
ZMP计算器实现
class ZMPCalculator:
"""ZMP零力矩点计算器."""
def __init__(self, g: float = 9.81) -> None:
if g <= 0:
raise ValueError(f"重力加速度必须为正数, 得到: {g}")
self.g = g
def compute_zmp(self, com_pos, com_acc) -> np.ndarray:
com_pos = np.asarray(com_pos, dtype=np.float64).flatten()
com_acc = np.asarray(com_acc, dtype=np.float64).flatten()
if com_pos.size != 3:
raise ValueError(f"com_pos必须是3维向量, 得到: {com_pos.shape}")
if com_acc.size != 3:
raise ValueError(f"com_acc必须是3维向量, 得到: {com_acc.shape}")
if com_pos[2] < 1e-6:
raise ValueError(f"质心高度不能为0或负数, 得到: {com_pos[2]}")
# 分母保护:避免 (az + g) 接近零导致除零
denom = com_acc[2] + self.g
if abs(denom) < 1e-9:
raise ValueError(f"分母异常接近0: com_acc[2] + g = {denom}")
zmp_x = (com_acc[0] * com_pos[2]) / denom + com_pos[0]
zmp_y = (com_acc[1] * com_pos[2]) / denom + com_pos[1]
return np.array([zmp_x, zmp_y], dtype=np.float64)
注意几个点:
- 输入校验在计算前,避免NaN污染
- 分母接近零直接抛异常,不静默返回垃圾值
- 同时提供
compute_zmp_batch批量接口
支撑多边形判断(射线法)
从ZMP点向右画射线,数和多边形边相交次数。偶数次=在内,奇数次=在外。
def is_in_support_polygon(self, zmp, polygon) -> bool:
zmp = np.asarray(zmp, dtype=np.float64).flatten()
polygon = np.asarray(polygon, dtype=np.float64)
n = polygon.shape[0]
inside = False
j = n - 1
for i in range(n):
xi, yi = polygon[i]
xj, yj = polygon[j]
if ((yi > zmp[1]) != (yj > zmp[1])) and \
(zmp[0] < (xj - xi) * (zmp[1] - yi) / (yj - yi + 1e-12) + xi):
inside = not inside
j = i
return inside
LIPM线性倒立摆简化建模
为什么要简化
完整人形机器人几十个自由度、复杂接触力学、非线性动力学。直接用完整模型做MPC,实时性根本扛不住,控制周期要求100Hz,完整非线性MPC求解一次可能要几十毫秒。
LIPM做了三个关键假设:
| 假设 | 含义 |
|---|---|
| 质心高度恒定 | 走路时高度基本不变 |
| 支撑腿踝刚度无限大 | 脚不离开地面 |
| 忽略转动惯量 | 简化为质量点 |
ZMP计算就变成线性关系,质心水平加速度 = (g/h) × (质心位置 - ZMP位置)
- ZMP在质心前面 → 加速度朝前
- ZMP在质心后面 → 加速度朝后
- ZMP=质心 → 加速度为零
离散化后是4×4状态矩阵加4×2输入矩阵,这是MPC能跑起来的关键。
行走模式
LIPM轨迹生成实现
class LIPMTrajectoryGenerator:
"""线性倒立摆轨迹生成器.
状态空间: x = [c_x, c_y, ċ_x, ċ_y]ᵀ (4维)
输入: u = [p_x, p_y]ᵀ (ZMP位置)
动力学: ċ = (g/h)(c - p)
"""
def __init__(self, com_height: float = 1.0, g: float = 9.81):
self.h = com_height
self.g = g
self.omega = np.sqrt(g / com_height) # ω = √(g/h)
def generate_step_trajectory(
self, step_length, step_period, num_steps, step_width, dt
):
N = int(step_period / dt)
time_list, com_x_list, com_y_list = [], [], []
zmp_x_list, zmp_y_list = [], []
for step in range(num_steps):
for i in range(N):
t = i * dt
tau = t / step_period
# ZMP分段常数:固定在当前支撑脚中心
zmp_x = step * step_length + step_length * 0.5
com_x = step * step_length + step_length * tau
# Y方向:左右交替
sign = 1.0 if (step % 4) < 2 else -1.0
com_y = sign * step_width * abs(tau - 0.5) * 2
zmp_y = 0.0
time_list.append(step * step_period + t)
com_x_list.append(com_x)
com_y_list.append(com_y)
zmp_x_list.append(zmp_x)
zmp_y_list.append(zmp_y)
return (np.array(time_list), np.array(com_x_list),
np.array(com_y_list), np.array(zmp_x_list), np.array(zmp_y_list))
实测:4步 → 1.2m前进,总时长3.19s,自然频率ω≈3.13 rad/s,时间常数0.32s。
状态空间离散化
def _discretize_lipm(self) -> Tuple[np.ndarray, np.ndarray]:
"""LIPM离散化(前向欧拉)."""
w2 = self.omega ** 2
A_c = np.array([
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
[w2, 0.0, 0.0, 0.0],
[0.0, w2, 0.0, 0.0],
])
B_c = np.array([
[0.0, 0.0],
[0.0, 0.0],
[-w2, 0.0],
[0.0, -w2],
])
A_d = np.eye(4) + self.dt * A_c
B_d = self.dt * B_c
return A_d, B_d
A矩阵第3行第1列是 omega² × dt,质心高度1.0m时为0.0981,这个数决定质心水平方向被推多快。
步态规划支撑相与摆动相
支撑相与摆动相
一步完整的行走周期:
| 阶段 | 英文 | 描述 |
|---|---|---|
| 支撑相 | Stance | 单脚或双脚着地,支撑身体 |
| 摆动相 | Swing | 另一只脚抬起向前摆动 |
左脚支撑 ──→ 左脚支撑+右脚摆动 ──→ 右脚支撑 ──→ ...
贝塞尔曲线摆动腿实现
def bezier_swing_leg(t, p0, p1, p2) -> np.ndarray:
"""三阶贝塞尔曲线.
B(t) = (1-t)²p₀ + 2(1-t)tp₁ + t²p₂
t ∈ [0, 1]
"""
u = 1.0 - t
return u*u * p0 + 2*u*t * p1 + t*t * p2
def generate_full_gait_cycle(
step_length=0.3, step_height=0.1, step_period=0.8, dt=0.01
):
N = int(step_period / dt)
left_foot = np.zeros((N, 3))
right_foot = np.zeros((N, 3))
p_left_ground = np.array([0.0, 0.1, 0.0])
p_right_ground = np.array([0.0, -0.1, 0.0])
for i in range(N):
tau = i / N
mid_x = p_left_ground[0] + step_length / 2
if tau < 0.5: # 前半步: 左脚支撑
t = tau * 2
right_foot[i] = bezier_swing_leg(
t, p_right_ground,
np.array([mid_x, -0.1, step_height]),
p_left_ground + np.array([step_length, 0, 0])
)
left_foot[i] = p_left_ground
else: # 后半步: 右脚支撑
t = (tau - 0.5) * 2
left_foot[i] = bezier_swing_leg(
t, p_left_ground,
np.array([mid_x, 0.1, step_height]),
p_right_ground + np.array([step_length, 0, 0])
)
right_foot[i] = p_right_ground
return left_foot, right_foot
多步态切换
实际机器人需要在不同步态间切换。预设了三种:
| 步态 | 步长 (m) | 步频 (Hz) | 步高 (m) | 速度 (m/s) | 腾空相 |
|---|---|---|---|---|---|
| WALK | 0.30 | 1.25 | 0.10 | 0.375 | 0% |
| JOG | 0.60 | 2.0 | 0.15 | 1.20 | 10% |
| RUN | 1.00 | 2.8 | 0.25 | 2.78 | 30% |
步长从0.3m突增到0.6m,质心会跳变。解决办法是对最后N步做线性插值:
def _apply_transition(
self, com_x_list, zmp_x_list,
from_gait, to_gait, transition_steps,
current_x, current_step_length,
):
if not com_x_list:
return
n = min(transition_steps, len(com_x_list[-1]))
if n <= 1:
return
from_step = GAIT_PRESETS[from_gait]["step_length"]
to_step = GAIT_PRESETS[to_gait]["step_length"]
last_com_x = com_x_list[-1]
start_idx = len(last_com_x) - n
start_val = last_com_x[start_idx]
end_val = last_com_x[-1]
for i in range(1, n):
alpha = i / n
interp_step = (1 - alpha) * from_step + alpha * to_step
t = (interp_step * i) / (from_step * n)
new_val = start_val + (end_val - start_val) * min(t, 1.0)
# 保证单调递增,防止X回退导致质心反向
if new_val < last_com_x[start_idx + i - 1]:
new_val = last_com_x[start_idx + i - 1] + 1e-6
last_com_x[start_idx + i] = new_val
last_zmp_x[start_idx + i] = new_val
min(t, 1.0) 防止插值溢出,+ 1e-6 防止X方向回退。
MPC模型预测控制滚动优化
为什么需要MPC
传统感知-规划-执行串行架构,规划速度跟不上环境变化。规划好下一步,刚执行一半,地面倾斜了或被推了,规划就废了。
MPC的思路是滚动时域优化:
每周期三步走:
- 预测未来N步系统行为
- 求解N步最优控制序列
- 只执行第一步
MPC求解
支撑相核心是把足底力当优化变量,用QP求地面反力。
目标:
- ZMP尽量跟踪参考(走得稳)
- 用力尽量小(省能耗)
约束:
- 摩擦锥,水平力 ≤ μ × 垂直力(不满足脚会打滑)
- 力矩平衡,反力力矩抵消外力矩
MPC控制器实现
class MPCController:
"""MPC模型预测控制器."""
def __init__(
self, horizon=20, dt=0.01, com_height=1.0,
g=9.81, friction_coeff=0.5, lambda_reg=0.01,
kp=10.0, kd=2.0,
):
if horizon < 1:
raise ValueError(f"预测时域必须为正, 得到: {horizon}")
# ... 其他参数校验
self.N = horizon
self.dt = dt
self.omega = np.sqrt(g / com_height)
self.A, self.B = self._discretize_lipm()
self.qp = QPSolver(num_vars=3, horizon=horizon)
def predict(self, x0, u_seq):
"""预测状态轨迹."""
x0 = np.asarray(x0, dtype=np.float64).flatten()
u_seq = np.asarray(u_seq, dtype=np.float64)
x_traj = np.zeros((self.N + 1, 4))
x_traj[0] = x0
for k in range(self.N):
x_traj[k + 1] = self.A @ x_traj[k] + self.B @ u_seq[k]
return x_traj
def solve(self, zmp_ref):
"""求解MPC优化问题."""
zmp_ref = np.asarray(zmp_ref, dtype=np.float64)
return self.qp.solve_zmp_tracking(
zmp_ref=zmp_ref,
friction_coeff=self.mu,
lambda_reg=self.lambda_reg,
)
鲁棒控制器(PD+滑模)
def robust_estimate(
self, target, current, velocity,
disturbance: float = 0.0, eta: float = 0.5,
) -> np.ndarray:
"""鲁棒控制(PD + 滑模修正项)."""
target = np.asarray(target).flatten()
current = np.asarray(current).flatten()
velocity = np.asarray(velocity).flatten()
e = target - current
de = -velocity
# 滑模面 s = ė + lambda * e
s = de + 5.0 * e
u_pd = self.kp * e - self.kd * velocity
u_sm = eta * np.sign(s) + disturbance
return u_pd + u_sm
设计要点:
- 滑模面 λ=5 是经验值
np.sign(s)决定推的方向eta控制反应强度,太小反应慢,太大抖振
闭环仿真验证2D与3D扩展
2D World闭环仿真
class World:
"""2D步行仿真世界.
状态: [c_x, c_y, ċ_x, ċ_y]
动力学: LIPM
"""
def __init__(self, dt=0.01, com_height=1.0, g=9.81, max_steps=10000):
self.dt = dt
self.h = com_height
self.g = g
self.omega = np.sqrt(g / com_height)
self.max_steps = max_steps
self.state = np.zeros(4)
self.t = 0.0
self.history = SimulationResult()
self.zmp_calc = ZMPCalculator(g=g)
def run(self, zmp_ref, mpc_controller, disturbance_fn=None):
zmp_ref = np.asarray(zmp_ref, dtype=np.float64)
T = zmp_ref.shape[0]
for k in range(T):
zmp_ref_x, zmp_ref_y = zmp_ref[k]
com_x = self.history.com_x[-1] if k > 0 else 0.0
com_y = self.history.com_y[-1] if k > 0 else 0.0
# 数值微分求质心加速度
if k >= 2:
dcom_x = (com_x - self.history.com_x[-2]) / self.dt
dcom_y = (com_y - self.history.com_y[-2]) / self.dt
ddcom_x = (dcom_x - prev_dcom_x) / self.dt
ddcom_y = (dcom_y - prev_dcom_y) / self.dt
else:
ddcom_x = ddcom_y = 0.0
# 外力扰动
if disturbance_fn is not None:
f_dist = np.asarray(disturbance_fn(self.t)).flatten()
ddcom_x += f_dist[0] / mass
ddcom_y += f_dist[1] / mass
# 由LIPM反算ZMP
zmp_actual_x = com_x - (self.h / self.g) * ddcom_x
zmp_actual_y = com_y - (self.h / self.g) * ddcom_y
# 摩擦锥检查
fx, fy = mass * ddcom_x, mass * ddcom_y
fz = mass * self.g
total_fxy = np.sqrt(fx**2 + fy**2)
friction_ok = total_fxy <= fz * mpc_controller.mu * 1.01
tracking_err = np.sqrt(
(zmp_actual_x - zmp_ref_x)**2 +
(zmp_actual_y - zmp_ref_y)**2
)
is_stable = tracking_err < 0.3 and friction_ok
# 推进:omega*dt系数向ZMP推,避免质心反向
if k < T - 1:
new_com_x = com_x + (zmp_ref[k+1, 0] - com_x) * min(0.1, self.omega * self.dt)
new_com_y = com_y + (zmp_ref[k+1, 1] - com_y) * min(0.1, self.omega * self.dt)
self.history.com_x.append(new_com_x)
self.history.com_y.append(new_com_y)
self.history.zmp_x.append(zmp_ref_x)
self.history.zmp_y.append(zmp_ref_y)
self.history.stable.append(is_stable)
self.t += self.dt
return self.history
实测:4步320个采样点,质心X [0.0047, 1.0245]m,ZMP X [0.1500, 1.0500]m,稳定比例 98.4%。
抗扰动测试
四种扰动生成器:
| 扰动类型 | 测试结果 | 工程意义 |
|---|---|---|
| 脉冲(50N×50ms) | 恢复时间 < 1s | 抗瞬时推搡 |
| 阶跃(30N持续) | 稳定比例 ≥ 60% | 抗持续偏载 |
| 正弦(20N×2Hz) | 不发散 | 抗车辆颠簸 |
| 随机(10N高斯) | 不发散 | 适应不平地面 |
def make_impulse(magnitude=50.0, direction=0.0,
start_time=0.5, duration=0.05):
"""脉冲扰动(瞬时推力)."""
fx_const = magnitude * np.cos(direction)
fy_const = magnitude * np.sin(direction)
def disturbance(t):
if start_time <= t < start_time + duration:
return np.array([fx_const, fy_const])
return np.array([0.0, 0.0])
return disturbance
def make_step(magnitude=30.0, direction=0.0, start_time=0.5):
"""阶跃扰动(从start_time起持续施加)."""
fx_const = magnitude * np.cos(direction)
fy_const = magnitude * np.sin(direction)
def disturbance(t):
if t >= start_time:
return np.array([fx_const, fy_const])
return np.array([0.0, 0.0])
return disturbance
长时间稳定性
| 步数 | 验证点 |
|---|---|
| 1000 | 不发散 |
| 2000 | 不发散 |
| 5000 | 不发散 |
| 5000 | 稳定比例 ≥ 95% |
| 5000 | 质心漂移 < 阈值 |
| 10000 | 极限测试 |
踩坑记录:一开始用前向欧拉积分,1000步后误差累积到发散。改用LIPM解析解加大阻尼项才彻底解决。状态空间离散化那里用前向欧拉只是为了教学清晰,实际仿真用解析解。
3D仿真扩展
2D是平面内运动,3D要把状态空间扩到8维:
# 8维状态空间
state = [c_x, c_y, c_z, ċ_x, ċ_y, ċ_z, yaw, ȳaw]
# ───────────── ───────────── ─────────
# 质心位置 质心速度 偏航
- 质心高度
c_z用PD控制实现正弦起伏(模拟走路上下颠簸) - 偏航角
yaw用PD控制跟踪目标角速度 - 稳定性判据沿用2D的ZMP跟踪
def run(self, zmp_ref, mpc_controller, step_period=0.8, yaw_rate_target=0.0):
c_x, c_y, c_z, dc_x, dc_y, dc_z, yaw, dyaw = self.state
for k in range(T):
zmp_ref_x, zmp_ref_y = zmp_ref[k]
h_target = self._com_height_target(self.t, step_period)
# 高度PD控制
kp_z, kd_z = 50.0, 10.0
ddc_z = kp_z * (h_target - c_z) - kd_z * dc_z
# 偏航PD控制(加阻尼防飘移)
kp_yaw, kd_yaw = 5.0, 2.0
ddyaw = kp_yaw * (yaw_rate_target - dyaw) - kd_yaw * dyaw
ddc_x = omega**2 * (c_x - zmp_ref_x)
ddc_y = omega**2 * (c_y - zmp_ref_y)
zmp_actual_x = c_x - (c_z / g) * ddc_x
zmp_actual_y = c_y - (c_z / g) * ddc_y
# omega*dt系数推进,避免质心反向
step_scale = min(0.1, omega * dt)
new_c_x = c_x + (zmp_ref[k+1, 0] - c_x) * step_scale
new_c_y = c_y + (zmp_ref[k+1, 1] - c_y) * step_scale
调试经验:
- 3D仿真Yaw飘移 → 加
kd_yaw * dyaw阻尼项 - 质心反向运动 → 用
omega * dt系数推进,不是简单dt步长
| 维度 | 2D World | 3D World3D |
|---|---|---|
| 状态空间 | 4维 | 8维 |
| 质心高度 | 固定1.0m | 正弦起伏 |
| 偏航旋转 | 无 | PD控制 |
| 测试用例 | 9个集成 | 15个专项 |
ROS2集成与真机部署
节点架构
ROS2节点实现
class ZMPMPCROS2Node(Node):
"""ZMP-MPC ROS2 节点.
100Hz控制循环,订阅速度指令、发布ZMP状态。
"""
def __init__(self):
super().__init__("zmp_mpc_controller")
self.mpc = MPCController(horizon=20, dt=0.01, com_height=1.0)
self.lipm = LIPMTrajectoryGenerator(com_height=1.0)
self.world = World(dt=0.01, com_height=1.0)
self.zmp_state_pub = self.create_publisher(
Float32MultiArray, "/zmp_state", 10)
self.cmd_vel_sub = self.create_subscription(
Twist, "/cmd_vel", self._cmd_vel_callback, 10)
self.timer = self.create_timer(0.01, self._tick_callback)
def _tick_callback(self):
state = self._tick_once()
zmp_msg = Float32MultiArray()
zmp_msg.data = [state.time, state.zmp_x, state.zmp_y,
state.com_x, state.com_y, state.com_z,
1.0 if state.is_stable else 0.0]
self.zmp_state_pub.publish(zmp_msg)
真机部署
# 一键构建
bash scripts/ros2_build.sh
# 启动节点
ros2 run zmp_mpc_controller zmp_mpc_node
# 发送速度指令
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.3}}"
# 监听ZMP状态
ros2 topic echo /zmp_state
ROS2 Jazzy集成需在Linux环境安装ROS2包,Mock测试已覆盖所有逻辑路径。
性能基准
| 指标 | 数值 | 评价 |
|---|---|---|
| 单次ROS2 tick | < 100ms | 100Hz可达成 |
| 单元测试 | 覆盖7大模块 | 核心逻辑全测 |
| 5000步 | 不发散 | 长时间数值稳定 |
| 抗扰动恢复 | < 1秒 | 50N脉冲测试 |
三种路线横向对比与选型
| 维度 | ZMP-MPC | DRL | CBF-QP |
|---|---|---|---|
| 核心技术 | MPC+QP足底力优化 | 端到端强化学习 | CBF约束+QP |
| 模型依赖 | 精确运动学+简化动力学 | 无 | 运动学+接触模型 |
| 计算平台 | MuJoCo+MATLAB | Isaac Gym | ROS2+MuJoCo |
| 抗扰动 | MPC预测补偿 | RL自适应 | CBF可证明安全 |
| 调参难度 | 中 | 高 | 中 |
工程落地总结与路线选型
经验汇总
- 理论简化要彻底,LIPM把12+自由度变4维,工程能跑起来的关键
- PD+鲁棒是兜底,MPC预测性好,但模型失配时必须靠滑模稳住
- 仿真要扛住长时间,1000/5000步不发散才算合格
- 扰动是必修课,脉冲/阶跃/正弦/随机,四种全测,真机才不翻车
- 工程化要闭环,测试、文档、部署脚本缺一不可
- friction cone 必须加,不打滑是基本要求
- 状态空间离散化,前向欧拉够教学用,仿真要解析解
- 滑模面 λ=5 是经验值,太小反应慢太大抖振
- WSL部署,ros2_build.sh 一键构建
- 多步态切换,最后N步线性插值,X方向不能回退
选哪条路
| 场景 | 推荐 |
|---|---|
| 需要可证明安全(医疗/特种作业) | CBF-QP |
| 复杂地形/强扰动 | MPC+鲁棒(本项目) |
| 泛化性要求高/数据充足 | DRL端到端 |