ESC
输入关键词搜索文章标题和内容

为什么仿真里好好的策略真机上就跪了?sim2real迁移实战

本文由 linuxROS 整理发布,首发于 linuxros.cn,转载请注明出处。

为什么仿真里好好的策略真机上就跪了?sim2real迁移实战

导读:Isaac Gym训练→MuJoCo验证→真机部署,仿真和真机之间的"sim2real gap"是具身智能落地最大的拦路虎。下面聊聊Domain Randomization、Motor Model Fitting、网络架构优化三个维度的实战方案,附完整代码模板。


一、sim2real gap问题根源剖析

1.1 仿真真机的系统性差异

用强化学习在仿真环境里训练好一个行走策略,部署到真机上,机器人站起来抖了两下就摔倒。这不是代码有问题,而是仿真和真机之间存在系统性差异。

差异来自三个层面:

差异来源 仿真 真机 后果
物理差异 刚体假设、理想接触 柔性关节、地面摩擦不均 仿真踮脚,真机踩塌
传感器差异 完美IMU、零噪声 漂移抖动、延迟 策略"看错"状态
执行器差异 即时响应、精准力矩 力矩误差、摩擦死区 命令执行偏了

1.2 三维度方案概览

flowchart TB A["🔴 sim2real gap"] --> B["Domain Randomization<br/>让仿真够脏"] A --> C["Motor Model Fitting<br/>让电机够真"] A --> D["Network Architecture<br/>让策略够皮实"] B --> B1["物理参数随机化"] B --> B2["传感器噪声随机化"] C --> C1["力矩常数拟合"] C --> C2["摩擦模型标定"] D --> D1["观察归一化"] D --> D2["动作平滑"] B1 & B2 & C1 & C2 & D1 & D2 --> E["✅ 真机部署成功"] style A fill:#FFEBEE,stroke:#D32F2F style B fill:#E3F2FD,stroke:#1976D2 style C fill:#FFF8E1,stroke:#F57C00 style D fill:#E8F5E9,stroke:#388E3C style B1 fill:#E3F2FD,stroke:#1976D2 style B2 fill:#E3F2FD,stroke:#1976D2 style C1 fill:#FFF8E1,stroke:#F57C00 style C2 fill:#FFF8E1,stroke:#F57C00 style D1 fill:#E8F5E9,stroke:#388E3C style D2 fill:#E8F5E9,stroke:#388E3C style E fill:#E8F5E9,stroke:#388E3C

主流做法是在Isaac Gym训练,MuJoCo交叉验证,真机上完成站立、行走、室外、负重四项测试。


二、Domain Randomization让仿真杂到真机都认得

2.1 DR反直觉的核心思路

Domain Randomization(域随机化,DR)的思路反直觉:别让仿真太干净,训练时人为制造混乱,让策略学会应对各种扰动,从而在真机这个"未知域"里也能工作。

来自 linuxros.cn · linuxROS

经典DR随机化这几个维度:

随机化类型 仿真默认值 随机范围 作用
地面摩擦系数 0.7 [0.3, 1.2] 应对不同地面
电机力矩常数 标称值 ±20% 应对电机个体差异
观测量噪声 0 σ=[0, 0.05] 应对传感器误差
延迟 0 [0, 20]ms 应对通讯延迟
负载质量 0 [0, 2]kg 应对负重变化
观测范围 标称 ±10% 应对标定误差

2.2 Isaac Gym中的DR配置实战

import numpy as np
import torch

class DomainRandomizer:
    """Isaac Gym域随机化配置类"""

    def __init__(self, robot_name: str = "GR-1"):
        self.robot_name = robot_name
        self.params = self._get_default_params()

    def _get_default_params(self) -> dict:
        """默认随机化参数范围"""
        return {
            # 物理参数随机化
            "ground_friction": {"default": 0.7, "range": [0.3, 1.2]},
            "motor_torque_constant": {"default": 0.05, "range": [0.04, 0.06]},
            "motor_friction": {"default": 0.01, "range": [0.005, 0.02]},
            "motor_dead_zone": {"default": 0.0, "range": [0.0, 0.02]},
            # 观测噪声 (3σ覆盖)
            "joint_pos_noise": {"default": 0.0, "range": [0.0, 0.05]},
            "joint_vel_noise": {"default": 0.0, "range": [0.0, 0.1]},
            "base_vel_noise": {"default": 0.0, "range": [0.0, 0.15]},
            "imu_noise": {"default": 0.0, "range": [0.0, 0.05]},
            # 控制延迟 (步数)
            "action_delay": {"default": 0, "range": [0, 3]},
            "observation_delay": {"default": 0, "range": [0, 2]},
            # 附加负载
            "payload_mass": {"default": 0.0, "range": [0.0, 2.0]},
            # 重力扰动
            "gravity_perturbation": {"default": 0.0, "range": [-0.1, 0.1]},
            # 关节限位缩放
            "joint_limit_scale": {"default": 1.0, "range": [0.95, 1.05]},
        }

    def sample_randomized_params(self) -> dict:
        """采样一套随机化参数,均匀分布"""
        sampled = {}
        for name, cfg in self.params.items():
            low, high = cfg["range"]
            sampled[name] = np.random.uniform(low, high)
        return sampled

    def apply_to_sim(self, sim_env, randomized_params: dict):
        """将随机化参数应用到仿真环境"""
        if "ground_friction" in randomized_params:
            sim_env.set_ground_friction(randomized_params["ground_friction"])
        if "payload_mass" in randomized_params:
            sim_env.add_payload_mass(randomized_params["payload_mass"])
        if "motor_torque_constant" in randomized_params:
            sim_env.set_motor_torque_constant(
                randomized_params["motor_torque_constant"]
            )
        if "gravity_perturbation" in randomized_params:
            g_base = 9.81
            g_perturbed = g_base + randomized_params["gravity_perturbation"]
            sim_env.set_gravity([0, 0, -g_perturbed])
        return sim_env

# 使用示例: 每1万步重采样一次参数
randomizer = DomainRandomizer(robot_name="GR-1")
envs_per_gpu = 4096  # Isaac Gym典型并行数
reset_buf = torch.zeros(envs_per_gpu, dtype=torch.long, device="cuda:0")
reset_count = 0

for step in range(100000):
    if reset_count == 0 or step % 10000 == 0:
        randomized_params = randomizer.sample_randomized_params()
        randomizer.apply_to_sim(gym_env, randomized_params)
        reset_count = 50

    actions = policy.compute_actions(obs_dict)
    obs_dict, rewards, dones = gym_env.step(actions)
    reset_buf = torch.where(dones, torch.ones_like(reset_buf), reset_buf)
    reset_count -= 1

2.3 DR避坑不等式速查

不等式 含义 常见错误
DR ≠ 随便随机 随机范围太大会导致策略收敛不到任何有效行为 同时随机摩擦0.1~2.0和重力±1.0
DR 需要课程学习 先窄范围再宽范围,比一上来就宽范围效果好 一开始就用全范围训练
DR 依赖交叉验证 Isaac Gym训完→MuJoCo验,不要跳过验证环节 直接部署真机

三、Motor Model Fitting让电机模型够真

3.1 真实电机的非线性特征

仿真里的电机通常是理想模型(力矩等于常数乘以电流),但真机电机有摩擦死区、非线性摩擦、力矩常数漂移等问题。

真实的电机模型需要考虑以下因素:
- 力矩常数漂移:不同电机个体有差异,温度变化也会影响
- 库仑摩擦:与运动方向相反的恒定摩擦力
- 粘性摩擦:与速度成正比的摩擦力
- 静摩擦死区:速度接近零时需要额外的力才能启动

参数 含义 典型值
力矩常数 电流转换为力矩的系数 0.05 N·m/A
库仑摩擦 与运动方向相反的摩擦力 0.01 N·m
粘性摩擦 与速度成正比的摩擦系数 0.001 N·m·s/rad
静摩擦死区 启动所需的最小力矩 0.005 N·m

3.2 电机参数最小二乘标定

import numpy as np
from scipy.optimize import minimize

def motor_model(torque_cmd: np.ndarray,
                joint_vel: np.ndarray,
                k_t: float = 0.05,
                f_c: float = 0.01,
                f_v: float = 0.001) -> np.ndarray:
    """电机力矩模型: 含库仑+粘性摩擦"""
    friction = f_c * np.sign(joint_vel) + f_v * np.abs(joint_vel)
    actual_torque = k_t * torque_cmd - friction
    return actual_torque

def fit_motor_parameters(
    measured_current: np.ndarray,
    measured_torque: np.ndarray,
    measured_vel: np.ndarray
) -> dict:
    """通过实际测量数据拟合电机参数(最小二乘)"""
    def objective(x):
        k_t, f_c, f_v = x
        pred = motor_model(measured_current, measured_vel, k_t, f_c, f_v)
        return np.sum((measured_torque - pred) ** 2)

    # 初值: 标称参数
    x0 = [0.05, 0.01, 0.001]
    # 边界约束
    bounds = [
        (0.03, 0.08),   # k_t: 合理范围
        (0.001, 0.05),  # f_c
        (0.0001, 0.01)  # f_v
    ]

    result = minimize(objective, x0, method='L-BFGS-B', bounds=bounds)
    k_t, f_c, f_v = result.x

    print(f"拟合结果: k_t={k_t:.5f}, f_c={f_c:.5f}, f_v={f_v:.5f}")
    print(f"残差RMS: {np.sqrt(result.fun/len(measured_torque)):.5f} N·m")

    return {"k_t": k_t, "f_c": f_c, "f_v": f_v}

# 示例: 使用虚拟数据验证
np.random.seed(42)
I = np.random.uniform(0, 1, 1000)        # 0~1A电流
v = np.random.uniform(-3, 3, 1000)       # -3~3 rad/s速度
# 虚拟真实力矩 (带噪声的真机数据)
true_tau = motor_model(I, v, k_t=0.048, f_c=0.012, f_v=0.0015) \
           + np.random.normal(0, 0.005, 1000)

fitted = fit_motor_parameters(I, true_tau, v)
# 输出: k_t≈0.048, f_c≈0.012, f_v≈0.0015 (拟合成功)

四、网络架构优化让策略网络更皮实

4.1 鲁棒Actor-Critic网络设计

import torch
import torch.nn as nn

class RobustActorCritic(nn.Module):
    """
    鲁棒Actor-Critic网络
    改进点:
    1. LayerNorm观察归一化
    2. 动作平滑 (Exponential Moving Average)
    3. 安全动作裁剪
    4. 隐层域适应 (Domain-Adaptive Layer)
    """

    def __init__(
        self,
        num_obs: int,
        num_actions: int,
        hidden_sizes: list = [256, 256, 128],
        action_scale: float = 1.0
    ):
        super().__init__()
        self.action_scale = action_scale
        self.num_obs = num_obs
        self.num_actions = num_actions

        # 观察归一化层
        self.obs_norm = nn.LayerNorm(num_obs)

        # Actor网络 (策略)
        actor_layers = []
        in_dim = num_obs
        for h_dim in hidden_sizes:
            actor_layers.extend([
                nn.Linear(in_dim, h_dim),
                nn.LayerNorm(h_dim),
                nn.Tanh()
            ])
            in_dim = h_dim
        actor_layers.append(nn.Linear(in_dim, num_actions))
        actor_layers.append(nn.Tanh())  # 输出归一化到[-1, 1]
        self.actor = nn.Sequential(*actor_layers)

        # Critic网络 (价值)
        critic_layers = []
        in_dim = num_obs
        for h_dim in hidden_sizes:
            critic_layers.extend([
                nn.Linear(in_dim, h_dim),
                nn.LayerNorm(h_dim),
                nn.Tanh()
            ])
            in_dim = h_dim
        critic_layers.append(nn.Linear(in_dim, 1))
        self.critic = nn.Sequential(*critic_layers)

        # 动作EMA平滑器
        self.action_ema = None
        self.ema_alpha = 0.7

        self._init_weights()

    def _init_weights(self):
        for m in self.modules():
            if isinstance(m, nn.Linear):
                nn.init.orthogonal_(m.weight, gain=np.sqrt(2))
                nn.init.constant_(m.bias, 0.0)

    def forward(self, obs: torch.Tensor) -> tuple:
        """前向传播,返回(动作, 价值)"""
        obs_norm = self.obs_norm(obs)
        actions = self.actor(obs_norm)
        values = self.critic(obs_norm).squeeze(-1)
        return actions, values

    def compute_actions(self,
                        obs: torch.Tensor,
                        deterministic: bool = False) -> torch.Tensor:
        """计算动作 (含EMA平滑和安全裁剪)"""
        actions, _ = self.forward(obs)

        # EMA动作平滑
        if self.action_ema is None:
            self.action_ema = actions.detach().clone()
        else:
            self.action_ema = self.ema_alpha * self.action_ema + \
                             (1 - self.ema_alpha) * actions.detach()

        # 推理时用EMA平滑后的动作
        if deterministic:
            actions = self.action_ema

        # 安全裁剪: 限制关节角速度变化率
        clipped_actions = torch.clamp(
            actions,
            min=-self.action_scale,
            max=self.action_scale
        )
        return clipped_actions

# 使用示例 (GR-1人形机器人)
policy = RobustActorCritic(
    num_obs=48,      # base_lin_vel(3)+base_ang_vel(3)+projected_gravity(3)
                     # +commands(3)+joint_pos(19)+joint_vel(17)
    num_actions=19,  # 下肢关节数
    hidden_sizes=[256, 256, 128]
)
print(f"策略网络参数量: {sum(p.numel() for p in policy.parameters()) / 1e6:.2f}M")

五、训练到部署全链路Pipeline

5.1 八步全链路流程

flowchart TB A["🔴 MoCap动捕采集"] --> B["Isaac Gym PPO训练"] B --> C["Domain Randomization<br/>物理+视觉+传感器随机"] C --> D{"MuJoCo交叉验证<br/>通过?"} D -->|"是"| E["Motor Fitting<br/>标定电机参数"] E --> F["Policy Network优化<br/>EMA平滑+安全裁剪"] F --> G["ONNX导出<br/>推理引擎"] G --> H["✅ 真机部署测试<br/>站立行走室外负重"] D -->|"否"| I["调整DR参数<br/>回到训练"] I --> B style A fill:#FFEBEE,stroke:#D32F2F style B fill:#E3F2FD,stroke:#1976D2 style C fill:#E3F2FD,stroke:#1976D2 style D fill:#FFF8E1,stroke:#F57C00 style E fill:#FFF8E1,stroke:#F57C00 style F fill:#F3E5F5,stroke:#7B1FA2 style G fill:#F3E5F5,stroke:#7B1FA2 style H fill:#E8F5E9,stroke:#388E3C style I fill:#FFEBEE,stroke:#D32F2F

5.2 站立行走统一奖励函数

统一奖励函数框架兼容站立和行走两种模式:

import torch

class UnifiedRewardFunction:
    """
    统一奖励函数
    兼容站立+行走两种运动模式
    奖励 = w1*站立奖励 + w2*行走奖励 + w3*安全惩罚 + w4*平滑惩罚
    """

    def __init__(
        self,
        w_standing: float = 0.4,
        w_walking: float = 0.4,
        w_safety: float = 0.1,
        w_smooth: float = 0.1
    ):
        self.w_standing = w_standing
        self.w_walking = w_walking
        self.w_safety = w_safety
        self.w_smooth = w_smooth

    def compute(self,
               obs_dict: dict,
               actions: torch.Tensor,
               prev_actions: torch.Tensor = None) -> torch.Tensor:
        """计算奖励(obs_dict含base_height/gravity/vel/joint_pos等)"""
        r_total = torch.zeros_like(obs_dict["base_height"])

        # 站立基础奖励
        r_standing = self._reward_standing(
            base_height=obs_dict["base_height"],
            gravity_proj=obs_dict["projected_gravity"],
            joint_vel=obs_dict["joint_vel"]
        )
        r_total += self.w_standing * r_standing

        # 行走奖励
        r_walking = self._reward_walking(
            base_vel=obs_dict["base_lin_vel"],
            commands=obs_dict["commands"]
        )
        r_total += self.w_walking * r_walking

        # 安全惩罚
        r_safety = self._reward_safety(
            joint_pos=obs_dict["joint_pos"],
            base_height=obs_dict["base_height"],
            gravity_proj=obs_dict["projected_gravity"]
        )
        r_total += self.w_safety * r_safety

        # 动作平滑惩罚
        if prev_actions is not None:
            r_smooth = -torch.sum((actions - prev_actions) ** 2, dim=-1) * 0.5
            r_total += self.w_smooth * r_smooth

        return r_total

    def _reward_standing(self, base_height, gravity_proj, joint_vel):
        r_height = torch.exp(-10 * torch.abs(base_height - 0.85) ** 2)
        r_upright = torch.exp(-5 * torch.abs(gravity_proj[:, 2] - 1.0) ** 2)
        r_calm = torch.exp(-0.1 * torch.sum(joint_vel ** 2, dim=-1))
        return (r_height + r_upright + r_calm) / 3.0

    def _reward_walking(self, base_vel, commands):
        vx_cmd, vy_cmd, vyaw_cmd = commands[:, 0], commands[:, 1], commands[:, 2]
        r_vx = torch.exp(-5 * torch.abs(base_vel[:, 0] - vx_cmd) ** 2)
        r_vy = torch.exp(-10 * torch.abs(base_vel[:, 1] - vy_cmd) ** 2)
        r_yaw = torch.exp(-10 * torch.abs(base_vel[:, 2] / 0.3 - vyaw_cmd) ** 2)
        return (r_vx + r_vy + r_yaw) / 3.0

    def _reward_safety(self, joint_pos, base_height, gravity_proj):
        height_pen = torch.where(base_height < 0.5, -2.0, 0.0)
        fall_pen = torch.where(gravity_proj[:, 2] < 0.3, -5.0, 0.0)
        return height_pen + fall_pen

六、主流开源仓库参考

6.1 Isaac Gym生态仓库

仓库 内容
IsaacGymEnvs NVIDIA官方RL任务套件,含PPO/LAGRANGE实现
Humanoid-Gym 人形RL框架,Isaac Gym→MuJoCo验证
Isaac Lab Isaac Gym后继者,GPU加速RL/IL统一框架
Unitree RL Gym 宇树官方开源,Isaac Gym→MuJoCo→H1/G1真机
RSL RL ETH四足MPC+RL训练框架

6.2 sim2real迁移学习仓库

仓库 内容
RoboPianist UC Berkeley具身灵巧手钢琴演奏,sim2real完整基础设施
mujoco.sysid UC Berkeley MuJoCo物理参数辨识工具箱
legged_gym 四足RL训练框架,Domain Randomization参考
HumanoidVerse 多仿真器人形RL基准
stable-baselines3 PPO/SAC等RL算法PyTorch实现,策略训练通用底座
gymnasium OpenAI Gym维护版,标准RL环境API

七、sim2real核心不等式总结

不等式 含义
Domain Randomization ≠ 随便随机 DR参数范围要精心设计,建议从窄到宽做课程学习
Motor Fitting ≠ 线性模型就够了 真实电机有非线性摩擦、死区、迟滞,需要系统辨识
网络优化 ≠ 加层就有效 观察归一化+动作平滑比加网络深度更有效

三维度协同优化:
- DR扩大训练分布覆盖范围
- Motor Fitting减小均值偏移
- 网络优化提高对残留误差的鲁棒性


参考文献

序号 文献
1 Isaac Gym: High Performance GPU-Based Physics Simulation For Robot Learning
2 Humanoid-Gym: Zero-Shot Sim2Real Transfer
3 Unitree RL Gym
4 Isaac Lab
5 legged_gym
6 RoboPianist: sim2real for dexterous manipulation

版权声明

作者linuxROS
协议本作品采用 CC BY-NC-SA 4.0 许可协议:署名-非商业性使用-相同方式共享
关注欢迎关注微信公众号 linuxROS,获取更多机器人 / 嵌入式 / Linux 干货
返回首页