机器人仿真与碰撞检测

PyBullet物理仿真、FCL碰撞检测、RRT路径规划、Domain Randomization——用仿真构建零成本实验场,用公差思维评估Sim2Real不确定性。

3-4周
8 个章节
代码示例
验收实验

本阶段目录

  1. PyBullet仿真入门
  2. RRT路径规划

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

特性PyBulletGazebo
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

四款主流仿真引擎的核心差异

特性PyBulletMuJoCoGazeboIsaac Sim
物理精度中等(凸模型)中等高(PhysX 5)
接触求解LCP近似Gauss原理多引擎可选基于GPU的PBD
GPU加速有限是(RTX)
渲染质量基础基础中等照片级(路径追踪)
ROS集成手动手动原生ROS 2 bridge
强化学习支持极好(Gymnasium原生)有限
学习曲线低-中中-高
适合场景快速原型、RLRL训练首选ROS集成、传感器视觉任务、Sim2Real

为什么RL社区偏爱MuJoCo

MuJoCo的接触模型基于凸优化而非传统LCP(线性互补问题),这使得:

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/mHertz接触刚度
$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经验知道:单元太硬→矩阵病态→不收敛。完全一样的问题。

🔧 工程连接:仿真接触刚度与FEA的罚函数法是同一原理。你理解的"刚度矩阵条件数→收敛性→精度→网格尺寸"这条链,在仿真接触参数调优中就是"接触刚度→数值稳定性→积分步长→计算精度"。

8. Domain Randomization消融实验设计与Sim2Real

系统性消融实验——找到哪个随机化真正有用

不是所有随机化都对Sim2Real有帮助。有些可能引入过多噪声反而降低性能。你需要系统地测试每种随机化:

实验组随机化内容目的
A: Baseline无随机化上界(仿真)vs下界(Sim2Real)
B: Visual Only光照/纹理/背景测试视觉鲁棒性
C: Dynamics Only质量/摩擦/阻尼测试动力学鲁棒性
D: Geometry Only连杆长度/物体尺寸测试几何鲁棒性
E: Sensor Only噪声/延迟测试感知鲁棒性
F: FullB+C+D+E全随机化
G: AblatedF逐一移除一项找到最关键的因素
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有天然优势:

验收实验

  • 在PyBullet和MuJoCo中分别搭建同一个机械臂环境,对比仿真精度
  • 实现深度相机和LiDAR的仿真传感器噪声模型
  • 运行完整的Domain Randomization消融实验(7组),找出Sim2Real最关键的因素
  • 在Gazebo中加载URDF模型,通过ROS 2控制关节运动并验证传感器输出
  • 对比无随机化 vs 全随机化策略的Sim2Real成功率gap