跳转至

🎮 仿真平台与Sim2Real

仿真平台与Sim2Real迁移图

学习时间:4小时 | 难度:⭐⭐⭐⭐ 中高级 | 前置知识:强化学习基础、PyTorch


本章目标

  • 掌握主流仿真平台(MuJoCo、Isaac Sim/Lab/Gym)的使用
  • 理解Domain Randomization的原理与实现
  • 学会Teacher-Student框架实现Sim2Real迁移
  • 了解URDF/MJCF机器人描述格式
  • 掌握仿真中的传感器模拟与数据生成

1. 仿真平台概览

1.1 主流平台对比

特性 MuJoCo Isaac Lab (Gym) PyBullet SAPIEN
物理引擎 MuJoCo PhysX 5 Bullet PhysX 5
GPU并行 单线程MJX ✓ (数千环境)
渲染质量 中等 高(RTX光追)
开源 ✓ (2022后)
适用场景 学术研究 工业级训练 教学 灵巧操作
RL训练速度 快(MJX on GPU) 最快 中等

1.2 为什么需要仿真?

Text Only
真实世界训练的问题:
  1. 数据收集慢: 机械臂1小时≈3600步, RL需要~10M步
  2. 安全风险: 探索可能损坏机器人或伤人
  3. 重置困难: 每次episode需人工重置场景
  4. 成本高: 一台Franka ~30万元

仿真的优势:
  1. 速度: Isaac Gym可同时运行4096个环境, 10分钟≈真实世界10年
  2. 安全: 随意探索不会损坏任何东西
  3. 自动重置: 一键恢复初始状态
  4. 可控: 可以精确控制物理参数、光照、物体属性

2. MuJoCo 实践

2.1 基础使用

Python
import mujoco
import mujoco.viewer
import numpy as np

# 加载机器人模型(MJCF格式)
model = mujoco.MjModel.from_xml_path("franka_emika_panda/scene.xml")
data = mujoco.MjData(model)

# 查看模型信息
print(f"广义坐标维度 (nq): {model.nq}")   # 关节位置维度
print(f"广义速度维度 (nv): {model.nv}")   # 关节速度维度
print(f"控制输入维度 (nu): {model.nu}")   # 执行器数量
print(f"刚体数量: {model.nbody}")

# 关节名称
for i in range(model.njnt):
    name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, i)
    print(f"  关节{i}: {name}, 范围: [{model.jnt_range[i, 0]:.2f}, "
          f"{model.jnt_range[i, 1]:.2f}]")

# 仿真循环
with mujoco.viewer.launch_passive(model, data) as viewer:
    for step in range(10000):
        # 设置控制输入(关节力矩)
        data.ctrl[:7] = np.zeros(7)  # 7个关节力矩
        data.ctrl[7] = 0.04          # 夹爪开度

        # 前进一个仿真步 (默认dt=0.002s)
        mujoco.mj_step(model, data)

        # 读取状态
        joint_positions = data.qpos[:7].copy()
        joint_velocities = data.qvel[:7].copy()

        # 读取末端位姿
        ee_site_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SITE, "end_effector")
        ee_pos = data.site_xpos[ee_site_id].copy()

        viewer.sync()

2.2 MuJoCo RL环境封装

Python
import gymnasium as gym
from gymnasium import spaces

class MuJoCoReachEnv(gym.Env):
    """MuJoCo机械臂到达任务(Gymnasium接口)"""

    metadata = {'render_modes': ['human', 'rgb_array']}

    def __init__(self, xml_path, render_mode=None):
        super().__init__()  # super()调用父类方法
        self.model = mujoco.MjModel.from_xml_path(xml_path)
        self.data = mujoco.MjData(self.model)
        self.render_mode = render_mode

        # 动作空间: 7个关节力矩
        self.action_space = spaces.Box(
            low=-1.0, high=1.0, shape=(7,), dtype=np.float32
        )

        # 观察空间: 关节角度(7) + 速度(7) + 末端位置(3) + 目标位置(3)
        self.observation_space = spaces.Box(
            low=-np.inf, high=np.inf, shape=(20,), dtype=np.float32
        )

        self.max_steps = 200
        self.current_step = 0
        self.target_pos = np.array([0.5, 0.0, 0.5])  # np.array创建NumPy数组

    def reset(self, seed=None, options=None):
        super().reset(seed=seed)

        mujoco.mj_resetData(self.model, self.data)

        # 随机化目标位置
        self.target_pos = np.array([
            self.np_random.uniform(0.3, 0.7),
            self.np_random.uniform(-0.3, 0.3),
            self.np_random.uniform(0.2, 0.6)
        ])

        self.current_step = 0
        return self._get_obs(), {}

    def step(self, action):
        # 缩放动作到实际力矩范围
        torque_limits = np.array([87, 87, 87, 87, 12, 12, 12])
        self.data.ctrl[:7] = action * torque_limits

        # 仿真多步(action repeat = 10, 即控制频率 = 50Hz)
        for _ in range(10):
            mujoco.mj_step(self.model, self.data)

        self.current_step += 1
        obs = self._get_obs()

        # 计算奖励
        ee_pos = self._get_ee_pos()
        dist = np.linalg.norm(ee_pos - self.target_pos)  # np.linalg线性代数运算
        reward = -dist  # 密集奖励

        if dist < 0.05:  # 到达目标
            reward += 10.0

        terminated = dist < 0.02
        truncated = self.current_step >= self.max_steps

        return obs, reward, terminated, truncated, {'distance': dist}

    def _get_obs(self):
        return np.concatenate([
            self.data.qpos[:7],          # 关节角度
            self.data.qvel[:7],          # 关节速度
            self._get_ee_pos(),          # 末端位置
            self.target_pos              # 目标位置
        ]).astype(np.float32)

    def _get_ee_pos(self):
        site_id = mujoco.mj_name2id(
            self.model, mujoco.mjtObj.mjOBJ_SITE, "end_effector"
        )
        return self.data.site_xpos[site_id].copy()

3. NVIDIA Isaac Lab

3.1 大规模并行训练

Python
# Isaac Lab 核心优势: GPU上同时跑数千个仿真环境
# pip install isaaclab

import torch
from isaaclab.envs import ManagerBasedRLEnv
from isaaclab.managers import SceneEntityCfg

class FrankaReachEnvCfg:
    """Isaac Lab配置(声明式定义)"""

    # 场景配置
    scene = {
        "robot": {
            "type": "Articulation",
            "prim_path": "/World/envs/env_.*/Robot",
            "usd_path": "franka_panda.usd",
            "actuator_groups": {
                "panda_arm": {
                    "joint_names": ["panda_joint[1-7]"],
                    "effort_limit": 87.0,
                    "stiffness": 400.0,
                    "damping": 80.0
                }
            }
        },
        "target": {
            "type": "RigidObject",
            "prim_path": "/World/envs/env_.*/Target",
            "usd_path": "target_sphere.usd"
        }
    }

    # 并行环境数量
    num_envs = 4096

    # 仿真参数
    sim = {
        "dt": 1.0 / 120,           # 仿真频率120Hz
        "decimation": 2,            # 控制频率60Hz
        "physx": {
            "gpu_found_lost_pairs_capacity": 1024 * 1024,
            "gpu_total_aggregate_pairs_capacity": 16 * 1024
        }
    }

# 训练流水线(概念代码)
# env = ManagerBasedRLEnv(cfg=FrankaReachEnvCfg())
# obs = env.reset()  # (4096, obs_dim) — 4096个环境同时初始化!
#
# for step in range(total_steps):
#     actions = policy(obs)           # (4096, 7) — 批量推理
#     obs, rewards, dones, infos = env.step(actions)  # GPU上并行执行
#     # 在4096环境上同时收集经验 → 训练速度提升1000x+

3.2 摄像头渲染

Python
# Isaac Sim/Lab 支持光线追踪渲染
# → 生成逼真的RGB/深度/分割图像 → 用于训练视觉策略

class CameraConfig:
    """仿真摄像头配置"""

    cameras = {
        "wrist_camera": {
            "prim_path": "/World/envs/env_.*/Robot/panda_hand/Camera",
            "resolution": (256, 256),
            "data_types": ["rgb", "depth", "semantic_segmentation"],
            "update_period": 0.05  # 20Hz
        },
        "overhead_camera": {
            "prim_path": "/World/envs/env_.*/OverheadCam",
            "resolution": (480, 640),
            "data_types": ["rgb", "depth"],
            "update_period": 0.1  # 10Hz
        }
    }

    # 在Isaac Sim中,可以启用RTX光追渲染
    # → 阴影、反射、透明物体都能正确渲染
    # → 对视觉Sim2Real至关重要

4. Domain Randomization

4.1 原理

Text Only
核心思想:
  如果仿真参数不够准确(sim2real gap),
  那就让模型在各种各样的随机参数下都能工作!

  训练时随机化 → 推理时对真实世界的变化具有鲁棒性

  类比: 如果你在各种极端天气都练习过开车,正常天气自然不在话下

                    仿真训练
    ┌─────────────────────────────────────┐
    │  摩擦力: 0.2~1.0                     │
    │  质量: 0.5x~2.0x                     │
    │  光照: 暗~亮                          │
    │  纹理: 随机                           │
    │  相机位姿: ±5cm, ±5°                 │
    │  ...                                 │
    └─────────────────────────────────────┘
            真实世界部署 ← 在随机化范围内

4.2 实现

Python
import numpy as np
from dataclasses import dataclass, field
from typing import Tuple

@dataclass  # @dataclass自动生成__init__等方法
class DomainRandomizationConfig:
    """Domain Randomization配置"""

    # 物理参数随机化
    friction_range: Tuple[float, float] = (0.3, 1.2)
    mass_scale_range: Tuple[float, float] = (0.5, 2.0)
    damping_scale_range: Tuple[float, float] = (0.8, 1.2)

    # 视觉随机化
    light_intensity_range: Tuple[float, float] = (0.3, 2.0)
    light_color_var: float = 0.2
    camera_pos_noise: float = 0.02      # ±2cm
    camera_rot_noise: float = 0.05      # ±2.8°
    texture_randomize: bool = True

    # 执行器随机化
    action_noise_std: float = 0.02
    action_delay_range: Tuple[int, int] = (0, 3)  # 延迟0-3步

    # 物体随机化
    object_size_scale: Tuple[float, float] = (0.8, 1.2)
    object_pos_noise: float = 0.05      # ±5cm

class DomainRandomizer:
    """Domain Randomization管理器"""

    def __init__(self, config: DomainRandomizationConfig):
        self.cfg = config

    def randomize_physics(self, model):
        """随机化物理参数"""
        # 摩擦系数
        for geom_id in range(model.ngeom):
            model.geom_friction[geom_id, 0] = np.random.uniform(
                *self.cfg.friction_range
            )

        # 物体质量
        for body_id in range(model.nbody):
            original_mass = model.body_mass[body_id]
            scale = np.random.uniform(*self.cfg.mass_scale_range)
            model.body_mass[body_id] = original_mass * scale

        # 关节阻尼
        for jnt_id in range(model.njnt):
            original_damp = model.dof_damping[jnt_id]
            scale = np.random.uniform(*self.cfg.damping_scale_range)
            model.dof_damping[jnt_id] = original_damp * scale

    def randomize_visual(self, renderer):
        """随机化视觉参数"""
        # 随机光照
        intensity = np.random.uniform(*self.cfg.light_intensity_range)
        color_noise = np.random.normal(0, self.cfg.light_color_var, 3)
        light_color = np.clip([1, 1, 1] + color_noise, 0, 1) * intensity
        renderer.set_light(color=light_color)

        # 随机相机扰动
        pos_noise = np.random.normal(0, self.cfg.camera_pos_noise, 3)
        rot_noise = np.random.normal(0, self.cfg.camera_rot_noise, 3)
        renderer.perturb_camera(pos_noise, rot_noise)

        # 随机纹理
        if self.cfg.texture_randomize:
            random_color = np.random.randint(0, 255, 3)
            renderer.set_table_color(random_color)

    def randomize_action(self, action):
        """随机化执行器输出(模拟真实执行器的不确定性)"""
        noisy_action = action + np.random.normal(
            0, self.cfg.action_noise_std, action.shape
        )
        return noisy_action

    def apply(self, env, episode_start=True):
        """在每个episode开始时应用随机化"""
        if episode_start:
            self.randomize_physics(env.model)
            self.randomize_visual(env.renderer)

            # 随机化物体初始位置
            for obj in env.objects:
                noise = np.random.uniform(
                    -self.cfg.object_pos_noise,
                    self.cfg.object_pos_noise, 3
                )
                obj.reset_position(obj.default_position + noise)

                # 随机化物体大小
                scale = np.random.uniform(*self.cfg.object_size_scale)
                obj.set_scale(scale)

5. Sim2Real迁移技术

5.1 Teacher-Student框架

Text Only
仿真中:
  Teacher策略 → 使用特权信息(精确物理状态)训练 → 表现很好
     │ 蒸馏(行为克隆)
  Student策略 → 只使用可观测信息(相机/触觉) → 学习模仿Teacher
     │ 部署
  真实世界 → Student策略直接运行

为什么有效?
  1. Teacher用完整信息训练,更容易收敛
  2. Student学习的是行为(输入→输出映射),不依赖特权信息
  3. Student只需要真实世界可获得的传感器输入
Python
import torch
import torch.nn as nn

class TeacherPolicy(nn.Module):  # 继承nn.Module定义网络层
    """Teacher策略(使用特权信息)"""

    def __init__(self, priv_obs_dim, act_dim, hidden=256):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(priv_obs_dim, hidden), nn.ELU(),
            nn.Linear(hidden, hidden), nn.ELU(),
            nn.Linear(hidden, hidden), nn.ELU(),
            nn.Linear(hidden, act_dim)
        )

    def forward(self, priv_obs):
        """
        priv_obs包含:
          - 关节角度/速度 (可观测)
          - 物体精确位姿 (特权,真实中需要视觉估计)
          - 接触力 (特权,真实中需要力矩传感器)
          - 摩擦系数 (特权,真实中不可知)
        """
        return self.net(priv_obs)

class StudentPolicy(nn.Module):
    """Student策略(只使用现实可观测信息)"""

    def __init__(self, obs_dim, act_dim, hidden=256, history_len=10):
        super().__init__()
        self.history_len = history_len

        # 观察编码器(处理历史观察)
        self.obs_encoder = nn.Sequential(
            nn.Linear(obs_dim * history_len, hidden), nn.ELU(),
            nn.Linear(hidden, hidden), nn.ELU()
        )

        # 动作输出
        self.action_head = nn.Sequential(
            nn.Linear(hidden, hidden), nn.ELU(),
            nn.Linear(hidden, act_dim)
        )

    def forward(self, obs_history):
        """
        obs_history: (B, history_len, obs_dim)
        只包含真实世界可获得的信息:
          - 本体感知 (关节编码器)
          - 历史动作
          - IMU数据
        """
        B = obs_history.shape[0]
        flat = obs_history.view(B, -1)  # 重塑张量形状
        features = self.obs_encoder(flat)
        return self.action_head(features)

class TeacherStudentTrainer:
    """Teacher-Student蒸馏训练"""

    def __init__(self, teacher, student, lr=1e-3):
        self.teacher = teacher
        self.student = student
        self.optimizer = torch.optim.Adam(student.parameters(), lr=lr)

    def distill_epoch(self, env, num_episodes=100):
        """
        1. 用Teacher在仿真中执行
        2. 收集 (student_obs, teacher_action) 数据对
        3. 训练Student模仿Teacher
        """
        dataset = []

        # 收集数据
        self.teacher.eval()
        for ep in range(num_episodes):
            priv_obs, student_obs = env.reset()
            obs_buffer = []

            for step in range(env.max_steps):
                # Teacher决策
                with torch.no_grad():  # 禁用梯度计算,节省内存
                    teacher_action = self.teacher(
                        torch.FloatTensor(priv_obs).unsqueeze(0)  # unsqueeze增加一个维度
                    ).squeeze(0).numpy()  # squeeze压缩维度

                # 记录Student视角的观察和Teacher的动作
                obs_buffer.append(student_obs)
                if len(obs_buffer) >= self.student.history_len:
                    history = np.stack(
                        obs_buffer[-self.student.history_len:]
                    )
                    dataset.append((history, teacher_action))

                # 执行动作
                priv_obs, student_obs, reward, done, info = \
                    env.step(teacher_action)

                if done:
                    break

        # 训练Student
        self.student.train()
        losses = []

        # 随机打乱
        np.random.shuffle(dataset)

        for i in range(0, len(dataset), 64):  # batch_size=64
            batch = dataset[i:i+64]
            obs_batch = torch.FloatTensor(np.stack([d[0] for d in batch]))
            act_batch = torch.FloatTensor(np.stack([d[1] for d in batch]))

            pred_actions = self.student(obs_batch)
            loss = nn.functional.mse_loss(pred_actions, act_batch)

            self.optimizer.zero_grad()  # 清零梯度
            loss.backward()  # 反向传播计算梯度
            self.optimizer.step()  # 更新参数
            losses.append(loss.item())  # 将单元素张量转为Python数值

        return np.mean(losses)

5.2 系统辨识(System Identification)

Python
# 另一种Sim2Real方法: 让仿真尽可能接近真实

class SystemIdentification:
    """通过优化使仿真参数匹配真实数据"""

    def __init__(self, sim_env, real_trajectories):
        self.sim = sim_env
        self.real_data = real_trajectories

    def optimize_params(self, param_names, bounds, n_iter=100):
        """
        贝叶斯优化: 调整仿真参数使轨迹匹配真实数据
        """
        from scipy.optimize import differential_evolution

        def objective(params):
            # 设置仿真参数
            for name, value in zip(param_names, params):  # zip按位置配对
                self.sim.set_param(name, value)

            # 回放真实动作序列在仿真中
            total_error = 0
            for traj in self.real_data:
                self.sim.reset(traj['initial_state'])
                for action, real_next_state in zip(
                    traj['actions'], traj['next_states']
                ):
                    sim_next_state = self.sim.step(action)
                    total_error += np.linalg.norm(
                        sim_next_state - real_next_state
                    ) ** 2

            return total_error

        result = differential_evolution(objective, bounds, maxiter=n_iter)
        return dict(zip(param_names, result.x))

6. URDF与机器人描述

6.1 URDF基础

XML
<?xml version="1.0"?>
<!-- 简单的2关节机械臂URDF -->
<robot name="simple_arm">

  <!-- 基座(固定) -->
  <link name="base_link">
    <visual>
      <geometry><cylinder radius="0.05" length="0.1"/></geometry>
    </visual>
    <collision>
      <geometry><cylinder radius="0.05" length="0.1"/></geometry>
    </collision>
    <inertial>
      <mass value="5.0"/>
      <inertia ixx="0.01" iyy="0.01" izz="0.01"
               ixy="0" ixz="0" iyz="0"/>
    </inertial>
  </link>

  <!-- 关节1: 旋转 -->
  <joint name="joint1" type="revolute">
    <parent link="base_link"/>
    <child link="link1"/>
    <origin xyz="0 0 0.05" rpy="0 0 0"/>
    <axis xyz="0 0 1"/>
    <limit lower="-3.14" upper="3.14" effort="87" velocity="2.17"/>
  </joint>

  <!-- 连杆1 -->
  <link name="link1">
    <visual>
      <geometry><box size="0.04 0.04 0.3"/></geometry>
      <origin xyz="0 0 0.15"/>
    </visual>
    <collision>
      <geometry><box size="0.04 0.04 0.3"/></geometry>
      <origin xyz="0 0 0.15"/>
    </collision>
    <inertial>
      <mass value="2.0"/>
      <origin xyz="0 0 0.15"/>
      <inertia ixx="0.015" iyy="0.015" izz="0.001"
               ixy="0" ixz="0" iyz="0"/>
    </inertial>
  </link>

  <!-- 关节2 -->
  <joint name="joint2" type="revolute">
    <parent link="link1"/>
    <child link="link2"/>
    <origin xyz="0 0 0.3" rpy="0 0 0"/>
    <axis xyz="0 1 0"/>
    <limit lower="-1.57" upper="1.57" effort="87" velocity="2.17"/>
  </joint>

  <!-- 连杆2 (末端执行器) -->
  <link name="link2">
    <visual>
      <geometry><box size="0.03 0.03 0.2"/></geometry>
      <origin xyz="0 0 0.1"/>
    </visual>
  </link>

</robot>

7. 练习题

代码实践

  1. 入门:用MuJoCo加载Franka Panda,实现关节位置控制(PD控制器),让机械臂到达指定位置
  2. 进阶:实现Domain Randomization(摩擦力、质量、视觉),对比有无DR的策略在扰动环境下的表现
  3. 高级:实现完整的Teacher-Student流水线——在MuJoCo中训练Teacher(PPO+特权信息),蒸馏到Student(仅本体感知)

面试题

  1. 列举5种主要的Sim2Real gap来源,各自如何缓解?
  2. Domain Randomization和System Identification分别适用什么场景?能否结合使用?
  3. 为什么Isaac Gym/Lab比MuJoCo训练快?GPU并行仿真的原理是什么?
  4. Teacher-Student框架中,Teacher为什么不能直接部署到真实世界?
  5. URDF和MJCF格式有什么区别?各有什么优缺点?
  6. 如何评估仿真的fidelity(逼真度)?有哪些metrics?

最后更新:2026年2月