机器人仿真与碰撞检测
PyBullet物理仿真、FCL碰撞检测、RRT路径规划、Domain Randomization——用仿真构建零成本实验场,用公差思维评估Sim2Real不确定性。
本阶段目录
1. PyBullet物理仿真入门
机械类比
你做过FEA分析和Adams多体动力学仿真。PyBullet就是机器人的"Adams"——模拟刚体碰撞、关节摩擦、重力,零成本零危险地测试控制算法。
import pybullet as p, pybullet_data, time
p.connect(p.GUI)
p.setAdditionalSearchPath(pybullet_data.getDataPath())
p.setGravity(0, 0, -9.81)
plane = p.loadURDF("plane.urdf")
robot = p.loadURDF("franka_panda/panda.urdf", useFixedBase=True)
for t in range(1000):
targets = [0.5 * np.sin(t * 0.02) for _ in range(7)]
for i, tgt in enumerate(targets):
p.setJointMotorControl2(robot, i, p.POSITION_CONTROL, targetPosition=tgt)
p.stepSimulation()
time.sleep(1/240)2. 碰撞检测与RRT路径规划
RRT快速扩展随机树
$\mathbf{q}_{new} = \mathbf{q}_{near} + \eta \frac{\mathbf{q}_{rand} - \mathbf{q}_{near}}{\|\mathbf{q}_{rand} - \mathbf{q}_{near}\|}$
从起点随机采样,逐步构建探索树,每次向随机方向生长,直到触及目标区域。
class RRTPlanner:
def plan(self, start, goal, max_iters=5000, step=0.1):
tree = [(start, None)]
for _ in range(max_iters):
q_rand = self.sample_free()
nearest_idx = self.nearest(tree, q_rand)
q_near = tree[nearest_idx][0]
q_new = q_near + step * (q_rand - q_near) / np.linalg.norm(q_rand - q_near)
if self.collision_free(q_near, q_new):
tree.append((q_new, nearest_idx))
if np.linalg.norm(q_new - goal) < step:
return self.extract_path(tree, len(tree)-1)
return None
验收实验
- 在PyBullet中导入6轴机械臂URDF并实现正弦轨迹控制
- 实现RRT路径规划器,在障碍物环境中找无碰撞路径
- 添加domain randomization(关节长度±2mm扰动),验证规划鲁棒性
3. Domain Randomization 域随机化
为什么需要域随机化
仿真中训练的策略放到真机上往往表现差。解决思路:在仿真中故意添加随机扰动——让策略学会适应不确定性,而不是记忆精确的仿真参数。这与你做公差分析时的"最坏情况分析"完全一样。
可随机的参数
| 类别 | 参数 | 范围示例 | 机械类比 |
|---|---|---|---|
| 视觉 | 光照/纹理/相机位置 | 亮度 ±30%/位置 ±5mm | 车间不同工位的光照差异 |
| 动力学 | 质量/摩擦/关节阻尼 | 名义值 ±15% | 零件制造公差 |
| 几何 | 连杆长度/物体尺寸 | 名义值 ±2mm | 装配公差 |
| 传感器 | 噪声/延迟/分辨率 | 高斯噪声 σ=0.01 | 传感器精度等级 |
def randomize_env(env):
"""每次episode开始时随机化环境参数"""
# 视觉域随机化
env.lighting_intensity = np.random.uniform(0.6, 1.4)
env.camera_pos += np.random.normal(0, 0.005, 3)
# 动力学域随机化
env.arm_mass = 3.5 * np.random.uniform(0.9, 1.1)
env.joint_friction = 0.01 * np.random.uniform(0.8, 1.2)
# 几何域随机化
env.link_length *= np.random.uniform(0.995, 1.005) # ±0.5% = ±2mm on 400mm link
return env
验收实验
- 分别测试无随机化/仅视觉/仅动力学/全随机化的Sim2Real成功率gap
- 找到使Sim2Real gap最小的随机化参数组合
4. Gazebo仿真集成
Gazebo vs PyBullet
| 特性 | PyBullet | Gazebo |
|---|---|---|
| ROS集成 | 需手动桥接 | 原生 ros_gz |
| 传感器仿真 | 基础 | 丰富(深度相机/LiDAR/IMU) |
| 物理引擎 | Bullet | 多引擎(DART/ODE/Bullet) |
| 学习曲线 | 低 | 中等 |
# 启动Gazebo + URDF机械臂
# ros2 launch my_robot gazebo.launch.py
from ros_gz_bridge import RosGzBridge
# ROS 2 ↔ Gazebo 话题桥接
bridge = RosGzBridge()
bridge.create_bridge('/joint_states', 'sensor_msgs/msg/JointState',
'/world/default/model/robot/joint_state',
'gz.msgs.Model')
验收实验
- 在Gazebo中加载URDF机械臂,通过ROS 2话题控制关节运动
- 添加深度相机传感器,验证点云输出
- 对比PyBullet和Gazebo的仿真精度差异
5. 仿真引擎深度对比——MuJoCo vs PyBullet vs Gazebo vs Isaac Sim
四款主流仿真引擎的核心差异
| 特性 | PyBullet | MuJoCo | Gazebo | Isaac Sim |
|---|---|---|---|---|
| 物理精度 | 中等 | 高(凸模型) | 中等 | 高(PhysX 5) |
| 接触求解 | LCP近似 | Gauss原理 | 多引擎可选 | 基于GPU的PBD |
| GPU加速 | 否 | 有限 | 否 | 是(RTX) |
| 渲染质量 | 基础 | 基础 | 中等 | 照片级(路径追踪) |
| ROS集成 | 手动 | 手动 | 原生 | ROS 2 bridge |
| 强化学习支持 | 好 | 极好(Gymnasium原生) | 有限 | 好 |
| 学习曲线 | 低 | 低-中 | 中-高 | 高 |
| 适合场景 | 快速原型、RL | RL训练首选 | ROS集成、传感器 | 视觉任务、Sim2Real |
为什么RL社区偏爱MuJoCo
MuJoCo的接触模型基于凸优化而非传统LCP(线性互补问题),这使得:
- 速度快:单个模拟步长比PyBullet快3-5倍(批量化时差距更大)
- 梯度平滑:接触力对状态的偏导数存在且连续——这对基于梯度的RL算法(PPO、SAC)至关重要
- 确定性:相同输入严格产生相同输出——可复现的实验结果
MuJoCo上手:从URDF到训练环境
import mujoco
import mujoco.viewer
import numpy as np
# 加载URDF模型(MuJoCo 3.0+原生支持URDF)
model = mujoco.MjModel.from_xml_path("robot.urdf")
data = mujoco.MjData(model)
# 仿真循环
with mujoco.viewer.launch_passive(model, data) as viewer:
for step in range(1000):
# PD控制器示例
target_pos = np.sin(step * 0.01) * 0.5
data.ctrl[:] = 100 * (target_pos - data.qpos) - 10 * data.qvel
mujoco.mj_step(model, data)
viewer.sync()
Gymnasium环境封装——标准RL接口
import gymnasium as gym
from gymnasium import spaces
class MuJoCoRobotEnv(gym.Env):
"""标准Gymnasium环境,兼容所有RL算法库(Stable-Baselines3, RLlib等)"""
def __init__(self, xml_path="robot.xml", render_mode="rgb_array"):
super().__init__()
self.model = mujoco.MjModel.from_xml_path(xml_path)
self.data = mujoco.MjData(self.model)
self.render_mode = render_mode
# 定义动作空间:关节力矩(连续)
self.action_space = spaces.Box(
low=-1.0, high=1.0,
shape=(self.model.nu,), dtype=np.float32)
# 定义观测空间:关节位置+速度+末端位姿
self.observation_space = spaces.Box(
low=-np.inf, high=np.inf,
shape=(self.model.nq + self.model.nv + 6,), dtype=np.float32)
def step(self, action):
self.data.ctrl[:] = action
mujoco.mj_step(self.model, self.data)
obs = self._get_obs()
reward = self._compute_reward()
terminated = self._is_terminated()
return obs, reward, terminated, False, {}
def _get_obs(self):
# 关节位置 + 速度 + 末端位姿
ee_pos = self.data.body("end_effector").xpos
ee_quat = self.data.body("end_effector").xquat
return np.concatenate([
self.data.qpos, self.data.qvel, ee_pos, ee_quat
]).astype(np.float32)
6. 仿真中的传感器建模
深度相机仿真
仿真中的深度相机不是简单的射线投射——必须建模传感器噪声、遮挡、材质反射率。
def simulate_depth_camera(physics_client, camera_pos, camera_orn, width=640, height=480, fov=60):
"""PyBullet中模拟RGB-D相机"""
import pybullet as p
view_matrix = p.computeViewMatrix(camera_pos, camera_orn)
proj_matrix = p.computeProjectionMatrixFOV(
fov=fov, aspect=width/height, nearVal=0.05, farVal=5.0)
# 获取RGB + 深度 + 分割
w, h, rgb, depth, seg = p.getCameraImage(
width, height, view_matrix, proj_matrix,
renderer=p.ER_BULLET_HARDWARE_OPENGL)
# 深度值从OpenGL深度缓冲转换到实际距离(米)
far, near = 5.0, 0.05
depth_meters = far * near / (far - (far - near) * depth)
# 添加仿真传感器噪声(高斯噪声)
noise_scale = 0.001 # 1mm标准差(模拟RealSense精度)
depth_meters += np.random.normal(0, noise_scale, depth_meters.shape)
# 模拟无效深度(超出量程返回0)
depth_meters[depth_meters > far] = 0.0
depth_meters[depth_meters < near] = 0.0
return rgb, depth_meters, seg
LiDAR仿真
def simulate_lidar(physics_client, robot_pos, num_rays=360, range_max=10.0):
"""模拟2D激光雷达——射线投射"""
angles = np.linspace(0, 2*np.pi, num_rays, endpoint=False)
ranges = np.zeros(num_rays)
for i, angle in enumerate(angles):
ray_from = robot_pos[:2]
ray_to = ray_from + range_max * np.array([np.cos(angle), np.sin(angle)])
results = p.rayTest(ray_from, ray_to)
if results[0][0] != -1: # 有命中
hit_fraction = results[0][2]
ranges[i] = hit_fraction * range_max
else:
ranges[i] = range_max
return angles, ranges
7. 接触动力学——仿真中最难的部分
机械类比
接触动力学就是数字版的"配合公差分析+摩擦学"——两个刚体如何接触、产生多大的摩擦力、会不会滑动。你做过的轴孔配合分析、摩擦系数查表,在接触动力学中直接适用。
接触模型的核心参数
| 参数 | 物理意义 | 典型值 | 机械类比 |
|---|---|---|---|
| $\mu_s$ (静摩擦系数) | 开始滑动所需的最大切向力/法向力 | 0.3-0.8(橡胶-金属) | 摩擦系数表 |
| $\mu_d$ (动摩擦系数) | 滑动后的切向力/法向力 | 通常为$\mu_s$的70-90% | Stribeck曲线 |
| $k_p$ (接触刚度) | 穿透深度→法向力的比例 | 10^4-10^6 N/m | Hertz接触刚度 |
| $k_d$ (接触阻尼) | 穿透速度→阻尼力的比例 | $k_p$的0.1-1% | 粘弹性阻尼 |
| $\epsilon$ (恢复系数) | 碰撞后速度/碰撞前速度 | 0-1(1=完全弹性) | 材料弹性模量 |
# PyBullet中配置接触参数
p.changeDynamics(
robot_id, link_index,
lateralFriction=0.5, # μ_d (动摩擦)
spinningFriction=0.01, # 旋转摩擦
rollingFriction=0.001, # 滚动摩擦
restitution=0.1, # ε (恢复系数)
contactStiffness=50000, # k_p (N/m)
contactDamping=1000, # k_d (Ns/m)
)
# 物体间独立的摩擦系数对
p.setCollisionFilterPair(robot_id, object_id, link_a, link_b, enableCollision=1)
# 对robot-link_a与object-link_b这对使用自定义接触参数
为什么仿真中的接触力会"爆炸"
接触刚度过高→小穿透产生大接触力→数值不稳定→物体飞出去。你的FEA经验知道:单元太硬→矩阵病态→不收敛。完全一样的问题。
- 解法1:降低接触刚度+提高仿真频率(1/Δt > 接触频率的10倍)
- 解法2:使用隐式积分器(Gazebo的DART/MuJoCo)替代显式欧拉法
- 解法3:在接触对之间添加"软约束"(弹簧-阻尼模型)
🔧 工程连接:仿真接触刚度与FEA的罚函数法是同一原理。你理解的"刚度矩阵条件数→收敛性→精度→网格尺寸"这条链,在仿真接触参数调优中就是"接触刚度→数值稳定性→积分步长→计算精度"。
8. Domain Randomization消融实验设计与Sim2Real
系统性消融实验——找到哪个随机化真正有用
不是所有随机化都对Sim2Real有帮助。有些可能引入过多噪声反而降低性能。你需要系统地测试每种随机化:
| 实验组 | 随机化内容 | 目的 |
|---|---|---|
| A: Baseline | 无随机化 | 上界(仿真)vs下界(Sim2Real) |
| B: Visual Only | 光照/纹理/背景 | 测试视觉鲁棒性 |
| C: Dynamics Only | 质量/摩擦/阻尼 | 测试动力学鲁棒性 |
| D: Geometry Only | 连杆长度/物体尺寸 | 测试几何鲁棒性 |
| E: Sensor Only | 噪声/延迟 | 测试感知鲁棒性 |
| F: Full | B+C+D+E | 全随机化 |
| G: Ablated | F逐一移除一项 | 找到最关键的因素 |
class AblationStudy:
def __init__(self, env_factory, policy, n_eval=100):
self.env_factory = env_factory
self.policy = policy
self.n_eval = n_eval
def run_ablation(self):
results = {}
# A: 无随机化(仿真上界)
env_sim = self.env_factory(randomization='none')
results['A_sim_upper_bound'] = self.eval(env_sim)
# B: 仅视觉随机化
env_visual = self.env_factory(randomization='visual')
results['B_visual_only'] = self.eval(env_visual)
# C: 仅动力学随机化
env_dynamics = self.env_factory(randomization='dynamics')
results['C_dynamics_only'] = self.eval(env_dynamics)
# ... D, E, F, G ...
return results
def eval(self, env):
successes = 0
for _ in range(self.n_eval):
obs = env.reset()
done = False
while not done:
action = self.policy(obs)
obs, reward, done, info = env.step(action)
if info.get('success'):
successes += 1
break
return successes / self.n_eval
真实世界的Sim2Real迁移——你的机械经验优势
作为机械设计工程师,你做Sim2Real有天然优势:
- 极限/间隙分析 → 仿真几何参数的扰动范围(名义值±公差)
- FEA材料参数 → 仿真中的质量/摩擦/刚度不确定性
- 振动模态分析 → 理解刚体仿真的局限性(没有柔性效应)
验收实验
- 在PyBullet和MuJoCo中分别搭建同一个机械臂环境,对比仿真精度
- 实现深度相机和LiDAR的仿真传感器噪声模型
- 运行完整的Domain Randomization消融实验(7组),找出Sim2Real最关键的因素
- 在Gazebo中加载URDF模型,通过ROS 2控制关节运动并验证传感器输出
- 对比无随机化 vs 全随机化策略的Sim2Real成功率gap