跳转到内容

Sim2Real:从 Gazebo 到真实硬件的零修改迁移

机器人学习研究中的头号痛点是 Sim2Real 鸿沟。你花数周在仿真中训练一个策略,在 MuJoCo 或 Gazebo 中调超参直到成功率看起来不错,然后部署到硬件上——眼看着机器人撞上墙壁。仿真物理与现实不匹配,摄像头图像看起来不同,电机响应存在仿真器未建模的延迟。

threewe SDK 从第一天起就通过严格的后端抽象层和量化的 Sim2Real 验证协议来最小化这一鸿沟。核心承诺:将 backend="gazebo" 改为 backend="real",你的代码无需任何其他修改即可在硬件上运行。

from threewe import Robot
# Development and training
async with Robot(backend="gazebo") as robot:
image = robot.get_camera_image()
await robot.move_to(x=2.0, y=1.5)
# Deployment -- the ONLY change
async with Robot(backend="real") as robot:
image = robot.get_camera_image()
await robot.move_to(x=2.0, y=1.5)

这不是营销话术,而是一个由自动化测试在每次提交时执行的工程契约。让我们来看看它是如何工作的,以及为什么切换后端时不会崩溃。


threewe SDK 使用三层架构:

┌──────────────────────────────────────────────────────────┐
│ User Code (Python) │
│ │
│ robot.get_image() │
│ robot.get_lidar_scan() │
│ robot.get_pose() │
│ await robot.move_to(x, y) │
│ robot.set_velocity(vx, vy, omega) │
│ robot.get_observation() │
└───────────────────────────┬──────────────────────────────┘
┌───────────────────────────▼──────────────────────────────┐
│ Backend Abstraction Layer │
│ │
│ BackendBase (abstract class) │
│ ├── connect() / disconnect() │
│ ├── get_camera_image() → (H, W, 3) uint8 │
│ ├── get_rgbd_image() → RGBDImage │
│ ├── get_lidar_scan() → LaserScan │
│ ├── get_pose() → Pose2D │
│ ├── get_velocity() → Velocity │
│ ├── get_imu() → IMUData │
│ ├── set_velocity(vx, vy, omega) │
│ ├── move_to(x, y, theta) → MoveResult │
│ ├── move_forward(distance) → MoveResult │
│ └── rotate(angle) → MoveResult │
└───────────────────────────┬──────────────────────────────┘
┌───────────────────┼───────────────────┐
│ │ │
┌───────▼──────┐ ┌────────▼────────┐ ┌───────▼──────┐
│ GazeboBackend│ │ IsaacSimBackend │ │ RealBackend │
│ │ │ │ │ │
│ Gazebo │ │ Isaac Sim │ │ ROS2 Jazzy │
│ Harmonic │ │ 4.x │ │ + micro-ROS │
│ + ros2_gz │ │ + IsaacLab │ │ + Pi5 + ESP32│
└──────────────┘ └─────────────────┘ └──────────────┘

每个后端实现相同的抽象接口。该契约不仅仅是”相同的方法名”——它规定了精确的数据格式、坐标系和单位。


这是 Sim2Real 迁移能够成功的关键。每个后端必须满足以下不变量:

属性规格
形状(H, W, 3) 默认 H=480, W=640
数据类型uint8
通道顺序BGR (OpenCV 惯例)
色彩空间sRGB
视场角匹配物理相机 (160 度鱼眼)
属性规格
形状(H, W) 与 RGB 一致
数据类型float32
单位
无效像素0.0
范围0.1m 到 10.0m
属性规格
形状(N,) 其中 N=360
数据类型float32
单位
角度采样均匀分布,逆时针方向
坐标系机体中心,X 轴朝前
范围0.1m 到 12.0m
无效读数0.0
属性规格
坐标系地图坐标系 (右手系:X 朝前, Y 朝左, Z 朝上)
位置单位
角度单位弧度
角度惯例从 X 轴逆时针为正
原点第一个 SLAM 关键帧或生成位置
属性规格
坐标系机体坐标系
线速度单位m/s
角速度单位rad/s
vx前进方向 (正值 = 前进)
vy侧向 (正值 = 左)
omega偏航角速度 (正值 = 逆时针)
属性规格
move_to(x, y, theta)使用路径规划导航到地图坐标系中的目标位姿
move_forward(d)沿当前航向直线行驶 d
rotate(a)原地旋转 a 弧度 (正值 = 逆时针)
set_velocity(vx, vy, omega)直接速度命令 (机体坐标系)
超时200ms 无命令则电机停止

该契约意味着,在 Gazebo 图像上训练的策略在硬件上运行时将接收到格式、分辨率、色彩空间和视场角完全相同的图像。LiDAR 数据具有相同的数组形状和单位系统。速度命令的含义完全一致。


以下是一个具体的导航任务,无需修改即可在所有三个后端上运行:

import asyncio
import numpy as np
from threewe import Robot, Pose2D
async def patrol(backend: str = "gazebo"):
"""Patrol a rectangle, collecting observations at each corner."""
waypoints = [
Pose2D(x=2.0, y=0.0, theta=0.0),
Pose2D(x=2.0, y=2.0, theta=1.57),
Pose2D(x=0.0, y=2.0, theta=3.14),
Pose2D(x=0.0, y=0.0, theta=-1.57),
]
async with Robot(backend=backend, scene="office_v2") as robot:
for i, wp in enumerate(waypoints):
# Navigate to waypoint
result = await robot.move_to(x=wp.x, y=wp.y, theta=wp.theta)
print(f"Waypoint {i}: success={result.success}, "
f"distance={result.distance:.2f}m, "
f"duration={result.duration:.1f}s")
# Collect observation
obs = robot.get_observation(
modalities=["image", "lidar", "pose", "velocity"]
)
print(f" Pose: ({obs['pose'][0]:.2f}, {obs['pose'][1]:.2f}, "
f"{obs['pose'][2]:.2f})")
print(f" Min LiDAR range: {obs['lidar'].min():.2f}m")
print(f" Image shape: {obs['image'].shape}")
# Run in simulation
asyncio.run(patrol("gazebo"))
# Run on real hardware -- SAME code
asyncio.run(patrol("real"))

输出格式完全相同。数值会有差异(真实世界的噪声、不同的物理特性),但数据类型、形状和语义保证一致。


我们不只是声称 Sim2Real 有效——我们对其进行测量。SDK 包含一个正式的验证协议,包含 5 个标准迁移测试:

测试指标通过条件描述
straight_walk_5m终点误差 (m)real < sim x 2.0直行 5m,测量位置误差
rotation_360角度误差 (deg)real < 1.0 (绝对值)旋转 360 度,测量最终航向误差
obstacle_avoidance_3成功率real > sim x 0.7绕过 3 个障碍物导航
pointnav_10mSPLreal > sim x 0.6导航 10m,计算最优路径比
dynamic_obstacle碰撞率real < sim x 1.5在移动障碍物中导航
Terminal window
# Sim-only validation (CI-friendly, no hardware needed)
threewe test sim2real --backend gazebo --scene office_v2
# Full sim2real comparison (requires hardware)
threewe sim2real report --backend gazebo --scene office_v2 --trials 5

示例输出:

Running sim2real transfer tests (backend=gazebo, scene=office_v2)
==================================================
Tests to run: ['straight_walk_5m', 'rotation_360', 'obstacle_avoidance_3',
'pointnav_10m', 'dynamic_obstacle']
Mode: sim-only (validates thresholds against sim baseline)
[PASS] straight_walk_5m: sim_value=0.082
[PASS] rotation_360: sim_value=0.340
[PASS] obstacle_avoidance_3: sim_value=0.867
[PASS] pointnav_10m: sim_value=0.742
[PASS] dynamic_obstacle: sim_value=0.067
Results: 5/5 passed
import asyncio
from threewe.benchmark.sim2real import Sim2RealValidator
async def validate():
validator = Sim2RealValidator()
# Full comparison: sim vs real
report = await validator.validate(
sim_backend="gazebo",
real_backend="real",
num_trials=5,
)
print(f"Overall: {'PASS' if report.overall_passed else 'FAIL'}")
print(f"Pass rate: {report.pass_rate:.0%}")
for result in report.results:
status = "PASS" if result.passed else "FAIL"
print(f" [{status}] {result.test_name}: "
f"sim={result.sim_value:.3f}, "
f"real={result.real_value:.3f}, "
f"ratio={result.transfer_ratio:.2f}")
asyncio.run(validate())

单靠后端抽象并不能保证迁移成功。以下几项工程决策使得差距可控:

Gazebo 仿真使用按照真实硬件规格配置的传感器插件:

  • 摄像头:相同分辨率 (640x480),相同镜头模型 (160 度鱼眼,含桶形畸变)
  • LiDAR:相同角分辨率 (1 度),相同噪声模型 (高斯,sigma=0.01m)
  • IMU:相同更新率 (100Hz),相同噪声特性 (BNO055 数据手册数值)

仿真电机使用从实际 JGA25-370 电机测量的响应曲线:

  • 速度爬升时间:150ms (仿真中匹配)
  • 稳态速度精度:误差在 5% 以内
  • 编码器分辨率:11 脉冲/转 (在仿真中建模)

仿真和实物均使用相同的 Nav2 配置:

  • 相同的规划器 (NavFn)
  • 相同的控制器 (DWB)
  • 相同的代价地图参数
  • 相同的恢复行为

这意味着路径规划行为完全相同。唯一的区别是真实世界的代价地图由于传感器噪声而更加嘈杂。

robot.get_observation() 方法返回一个包含固定数据类型和形状的 numpy 数组字典。无论数据来自 Gazebo 物理引擎还是真实传感器,你的模型始终接收:

obs = robot.get_observation(modalities=["image", "lidar", "pose", "velocity"])
# obs["image"]: (480, 640, 3) uint8
# obs["lidar"]: (360,) float32
# obs["pose"]: (3,) float32 [x, y, theta]
# obs["velocity"]: (3,) float32 [vx, vy, omega]

对于强化学习训练,SDK 提供内置的域随机化,使策略对 sim-real 差距更加鲁棒:

from threewe.sim.domain_randomization import DomainRandomization
# Default randomization ranges
dr = DomainRandomization()
# Or load from config file
dr = DomainRandomization.from_yaml("configs/domain_randomization.yaml")
# Sample randomized parameters for one episode
params = dr.sample()
print(params)
# {
# "mass_scale": 1.05, # 0.9-1.1x robot mass
# "friction_scale": 0.92, # 0.8-1.2x floor friction
# "motor_torque_noise": 0.02, # Gaussian noise on motor output
# "gravity_noise": -0.003, # Slight gravity variation
# "lighting_intensity": 1.2, # 0.5-1.5x scene lighting
# "color_jitter": 0.05, # Random color shift
# "lidar_noise_scale": 1.3, # 0.5-2.0x LiDAR noise
# "imu_noise_scale": 0.8, # 0.5-2.0x IMU noise
# "camera_noise_scale": 1.1, # 0.5-2.0x camera noise
# "odometry_slip_scale": 1.4, # 0.5-2.0x wheel slip
# }

物理随机化:

  • 质量缩放 (0.9x 到 1.1x) — 考虑载荷变化
  • 摩擦缩放 (0.8x 到 1.2x) — 不同地面材质
  • 电机扭矩噪声 — 执行器精度偏差
  • 重力噪声 — 传感器安装角度变化

视觉随机化:

  • 光照强度 (0.5x 到 1.5x) — 不同时段
  • 纹理随机化 — 不同的墙面/地板外观
  • 阴影随机化 — 不同的光源位置
  • 色彩抖动 — 相机白平衡变化

传感器随机化:

  • LiDAR 噪声缩放 — 考虑真实世界反射率变化
  • IMU 噪声缩放 — 温度漂移、振动效应
  • 摄像头噪声缩放 — 光照质量变化
  • 里程计滑移缩放 — 轮地接触变化
import gymnasium as gym
from threewe.sim.domain_randomization import DomainRandomization
dr = DomainRandomization(seed=42)
env = gym.make("3we/Navigation-v1")
for episode in range(1000):
params = dr.sample()
obs, info = env.reset(options={"domain_randomization": params})
done = False
while not done:
action = policy(obs)
obs, reward, terminated, truncated, info = env.step(action)
done = terminated or truncated

创建 YAML 文件来为你的特定硬件和环境自定义参数范围:

configs/domain_randomization.yaml
physics:
mass_scale_range: [0.85, 1.15]
friction_scale_range: [0.7, 1.3]
motor_torque_noise_stddev: 0.08
visual:
randomize_textures: true
randomize_lighting: true
lighting_intensity_range: [0.3, 1.8]
color_jitter: 0.15
sensor:
lidar_noise_scale_range: [0.3, 2.5]
imu_noise_scale_range: [0.5, 2.0]
camera_noise_scale_range: [0.5, 2.5]
odometry_slip_scale_range: [0.5, 2.5]

后端使用场景需要 GPU速度
Gazebo开发、CI/CD、基础 RL 训练实时
Isaac Sim大规模 RL、域随机化、照片级仿真是 (RTX)10-100x
Real最终验证、部署、数据采集实时

Gazebo 是默认的开发后端。它可以在 CPU 上无界面运行,非常适合 CI 流水线和快速迭代。启动方式:

Terminal window
threewe launch --backend gazebo --scene office_v2

Isaac Sim 是面向严肃 RL 研究的训练后端。它支持并行环境 (64+)、GPU 加速渲染和更精密的物理。启动方式:

Terminal window
threewe launch --backend isaac_sim --scene office_v2 --num-envs 64

真实硬件是验证后端。你的代码无需更改即可运行:

async with Robot(backend="real") as robot:
pass # Same API as simulation

典型的研究工作流包含四个阶段:

  1. 在 Gazebo 中开发 — 通过快速反馈迭代算法
  2. 使用域随机化训练 — 使用域随机化获得鲁棒策略
  3. 验证迁移 — 运行 threewe test sim2real 检查通过/失败
  4. 部署到硬件 — 将 backend="gazebo" 改为 backend="real"
# Phase 2: Train with domain randomization
import gymnasium as gym
from threewe.sim.domain_randomization import DomainRandomization
from stable_baselines3 import PPO
dr = DomainRandomization()
env = gym.make("3we/Navigation-v1")
model = PPO("MultiInputPolicy", env, verbose=1)
model.learn(total_timesteps=1_000_000)
model.save("nav_policy_v1")
# Phase 4: Deploy -- same observation/action interface
import asyncio
from stable_baselines3 import PPO
from threewe import Robot
model = PPO.load("nav_policy_v1")
async def deploy():
async with Robot(backend="real") as robot:
while True:
obs = robot.get_observation(
modalities=["image", "lidar", "pose", "velocity"]
)
action, _ = model.predict(obs, deterministic=True)
robot.execute_action(action)
asyncio.run(deploy())

从阶段 2 到阶段 4 的迁移仅需更改一个字符串:将 "gazebo" 改为 "real"


没有任何抽象层能完全消除 Sim2Real 差距。以下是剩余的误差来源及 SDK 的应对方式:

来源问题缓解措施
传感器噪声真实传感器比建模的更嘈杂可配置的 SensorNoiseModel,按硬件数据手册调优
电机延迟真实电机有加速限制和延迟Gazebo 中的一阶滞后滤波器匹配 JGA25-370 曲线
地面变化不同表面改变摩擦/里程计域随机化,宽摩擦范围 (0.7-1.3x)
光照真实光照变化剧烈训练时使用视觉域随机化 (0.3-1.8x 强度)

SDK 提供了一个 SensorNoiseModel,你可以针对具体硬件进行调优:

from threewe.sim.noise import SensorNoiseModel
noise = SensorNoiseModel(
lidar_gaussian_stddev=0.02, # 2cm noise on LiDAR
imu_accel_stddev=0.01, # m/s^2
imu_gyro_stddev=0.005, # rad/s
camera_gaussian_stddev=5.0, # pixel intensity noise
odometry_slip_factor=0.05, # 5% wheel slip
)

3we 平台在每个 Pull Request 上运行 Sim2Real 验证:

.github/workflows/sim2real-validation.yml
name: Sim2Real Validation
on: [pull_request]
jobs:
transfer-tests:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: pip install threewe[sim]
- name: Run transfer tests
run: threewe test sim2real --backend gazebo
- name: Generate report
run: threewe sim2real report --backend gazebo --trials 3

如果迁移测试出现退化(例如,有人修改了电机模型但没有更新仿真器),CI 将失败。这可以防止 Sim2Real 一致性的意外退化。


Terminal window
# Simulation only (no hardware needed)
pip install threewe[sim]
# With AI integration
pip install threewe[ai]
# Full installation
pip install threewe[sim,ai]
Terminal window
threewe launch --backend gazebo --scene office_v2
import asyncio
from threewe import Robot
async def main():
async with Robot(backend="gazebo") as robot:
# This exact code will work on real hardware
obs = robot.get_observation()
await robot.move_to(x=3.0, y=2.0)
await robot.move_forward(1.0)
await robot.rotate(1.57)
asyncio.run(main())
Terminal window
threewe test sim2real --backend gazebo

backend="gazebo" 改为 backend="real"。运行同一脚本。


Sim2Real 差距并未被消除——那需要在无限精度下模拟物理和渲染。但它通过以下方式被管理、测量和最小化:

  • BackendBase 抽象类在仿真和实物之间强制执行相同的 API 契约
  • 一致性契约规定精确的 numpy 数据类型、形状、坐标系和单位
  • 域随机化覆盖物理参数、视觉效果和传感器噪声
  • 自动化验证通过 5 项迁移测试协议和量化的通过/失败标准
  • CI 强制执行在每个 PR 上运行迁移测试以防止退化

对于室内导航任务,这种方法使得从 Gazebo 训练到真实硬件部署的可靠迁移成为可能。