• 正文
  • 相关推荐
申请入驻 产业图谱

具身智能 | 无视复杂场景——基于混合专家模型的全开源四足鲁棒运动控制

3小时前
38
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

转载自公众号:敢敢AUTOHUB

1. 四足机器人为什么需要强化学习

四足机器人的运动控制是机器人学中一个经典而困难的问题。传统方法依赖精确的动力学建模和轨迹优化,需要工程师手动设计步态模式、落足点规划和全身动力学控制器。这类方法在平坦地面上表现尚可,但面对楼梯、碎石、斜坡、雪地等非结构化地形时,模型误差会急剧放大,控制器的鲁棒性难以保证。

近年来,强化学习(Reinforcement Learning, RL)为这一问题提供了一条截然不同的路径。其核心思想是:在物理仿真器中构建大规模并行环境,让机器人通过数十亿次试错自主学习运动策略,而非依赖人工设计的控制规则。Isaac Gym 等 GPU 加速仿真器的出现,使得同时运行数千个仿真环境成为可能,极大地加速了策略的收敛速度。Unitree Go2 作为当前学术界广泛使用的四足平台,其紧凑的体型、丰富的传感器接口和相对低廉的成本,使其成为验证 RL 运动策略的理想载体。

然而,仿真训练出的策略要在真实机器人上稳定运行,必须跨越一道关键障碍——Sim-to-Real 鸿沟。仿真器中的物理参数(摩擦系数、电机响应延迟、地形几何精度等)与真实世界存在系统性偏差,一个在仿真中表现完美的策略,部署到真机后可能立刻摔倒。这篇工作正是围绕这一核心挑战展开的。

2. 核心问题:Sim-to-Real 鸿沟与策略选择困境

Sim-to-Real 迁移的难点可以从两个层面理解。第一个层面是物理参数的不确定性。真实世界中,地面摩擦系数随材质和湿度变化,电机的力矩输出受温度和老化影响,机器人的质心位置因负载不同而偏移。如果策略只在单一参数设定下训练,它学到的是一个过于"窄"的控制规则,稍有偏差就会失效。

第二个层面更为棘手:如何在不频繁进行真机实验的前提下,判断一个策略是否适合部署。真机实验成本高昂——不仅耗时,还有损坏硬件的风险。研究者通常训练多个候选策略,但缺乏可靠的离线评估手段来预测哪个策略在真机上表现最好。传统做法是凭经验或在仿真中观察行为来"拍脑袋"选择,这显然不够科学。

本文提出的框架正是针对这两个层面给出了系统性的解决方案:用混合专家(Mixture of Experts, MoE)策略解决第一个问题,用 RoboGauge 评估套件解决第二个问题。两者形成闭环——MoE 策略提供多个候选方案,RoboGauge 通过 Sim-to-Sim 测试量化每个方案的迁移可靠性,从而在不接触真机的情况下完成策略筛选。

3. 整体框架概览:从训练到部署的闭环

该工作的技术框架可以拆解为三个相互衔接的子系统,分别对应三个开源代码仓库:

第一个子系统是策略训练go2_rl_gym(https://github.com/wty-yy/go2_rl_gym)。它基于 Isaac Gym 仿真器,使用 PPO(Proximal Policy Optimization)算法训练 MoE 运动策略。训练过程采用并行师生学习(Concurrent Teacher-Student, CTS)架构:教师网络可以访问地形高度图、接触力等特权信息,学生网络仅依赖本体感知(关节角度、角速度、IMU 数据),通过隐空间蒸馏将教师的地形理解能力迁移给学生。训练环境包含 8192 个并行仿真实例,覆盖斜坡、楼梯、障碍物、波浪地形等 9 种地形类型,并施加大范围的域随机化(摩擦系数、质量偏移、电机延迟等)。

第二个子系统是策略评估RoboGauge(https://github.com/wty-yy/RoboGauge)。它将 Isaac Gym 中训练好的策略迁移到 MuJoCo 仿真器中进行 Sim-to-Sim 测试。之所以选择跨仿真器评估,是因为两个仿真器的物理引擎实现不同(Isaac Gym 基于 PhysX,MuJoCo 基于自研求解器),策略在不同物理引擎间的表现差异,可以部分反映其在真实世界中的鲁棒性。RoboGauge 定义了 6 个量化指标,覆盖速度跟踪精度、关节极限违规、能耗、姿态稳定性和力矩平滑度,在 5 种地形、10 个难度等级上进行系统性测试

第三个子系统是真机部署unitree_cpp_deploy(https://github.com/wty-yy/unitree_cpp_deploy/tree/main)。它提供了一套 C++ 推理框架,将训练导出的 ONNX 模型加载到 Unitree Go2 机载的 Orin NX 计算平台上,以 50Hz 的频率执行策略推理,并通过 DDS 协议将关节位置指令下发给电机控制器。整个部署流水线包含有限状态机管理、线程安全的观测-推理-动作流水线、以及完善的安全保护机制。

三个子系统的数据流向是单向的:Isaac Gym 训练产出 ONNX 模型,RoboGauge 对模型进行离线评估并输出量化分数,得分最高的模型被部署到真机。但从方法论角度看,RoboGauge 的评估结果可以反馈指导训练超参数的调整,形成一个迭代优化的闭环。

4. MoE 策略架构:让不同专家处理不同地形

混合专家模型(Mixture of Experts)的核心直觉非常朴素:与其训练一个"万能"网络来处理所有地形,不如训练多个"专家"网络,每个专家擅长处理特定类型的地形或运动模式,再由一个门控网络根据当前状态动态分配各专家的权重。这种分而治之的策略在自然语言处理领域(如 Switch Transformer)已经被证明有效,本文将其引入四足运动控制。

在具体实现中,MoE 模块被嵌入到学生编码器中,用于从本体感知的历史观测序列中提取地形隐表示。整个网络架构可以分为四个组件:教师编码器、学生 MoE 编码器、策略网络(Actor)和价值网络(Critic)。

教师编码器是一个标准的多层感知机(MLP),输入维度为 263(包含关节状态、IMU 数据、地形高度图、接触力等特权信息),通过 [263, 512, 256, 32] 的网络结构映射到 32 维隐空间,最后经过 L2 归一化。教师编码器只在训练阶段使用,部署时不需要。

学生 MoE 编码器是本文的核心创新。它的输入是最近若干帧本体感知观测的拼接(例如 6 帧 x 45 维 = 270 维),输出同样是 32 维隐向量。其内部结构如下:

# 文件:rsl_rl/modules/utils.py

class Experts(nn.Module):
    """
    多专家网络:共享骨干 + 分组卷积实现独立专家头。
    每个专家负责从输入中提取一种特定的地形/运动模式表示,
    最终由外部的门控网络决定各专家输出的混合权重。
    """
    def __init__(self,
                 expert_num,            # 专家数量,默认8。即有8个并行的"子网络",
                                        # 每个专家可以理解为擅长处理一种地形模式
                 input_dim,             # 输入维度。对于学生编码器,等于
                                        # num_obs * history_length(如 45*6=270)
                 backbone_hidden_dims,  # 共享骨干的隐藏层维度列表,如 [512, 256]。
                                        # 所有专家共用这些层来提取通用特征
                 expert_hidden_dim,     # 每个专家独立头的隐藏维度,如 256。
                                        # 骨干输出维度 = expert_num * expert_hidden_dim
                 output_dim,            # 每个专家的输出维度,即隐空间维度,默认32
                 activation='elu',      # 激活函数类型,ELU 比 ReLU 更平滑,
                                        # 适合连续控制任务
    ):
        super().__init__()
        self.expert_num = expert_num
        self.output_dim = output_dim

        # 共享骨干网络:所有专家共用前几层特征提取。
        # 网络结构示例:[270, 512, 256, 8*256=2048]
        # 最后一层输出维度 = expert_num * expert_hidden_dim,
        # 相当于为每个专家预留了 expert_hidden_dim 维的特征空间。
        # last_activation=True 表示最后一层也加激活函数,
        # 因为后面还有 Conv1d 层继续处理。
        self.backbone = MLP(
            [input_dim, *backbone_hidden_dims, expert_num * expert_hidden_dim],
            activation, last_activation=True
        )

        # 分组卷积(Grouped Convolution):每个专家独立的输出头。
        # 这是实现"多专家独立输出"的关键技巧:
        # - in_channels = expert_num * expert_hidden_dim(如 8*256=2048)
        # - out_channels = expert_num * output_dim(如 8*32=256)
        # - groups = expert_num(如 8)
        # groups 参数将输入通道分成 expert_num 组,每组独立做卷积,
        # 等价于 8 个独立的线性层,但用一次 Conv1d 调用高效实现。
        # kernel_size=1 表示这是一个逐点卷积(等价于全连接层)。
        self.experts = nn.Conv1d(
            in_channels=expert_num * expert_hidden_dim,
            out_channels=expert_num * output_dim,
            kernel_size=1,
            groups=expert_num,
        )

    def forward(self, x):
        # x 形状:(B, input_dim),B 是 batch size(并行环境数)
        # backbone 输出:(B, expert_num * expert_hidden_dim)
        # unsqueeze(-1) 添加一个维度给 Conv1d:(B, channels, 1)
        shared_features = self.backbone(x).unsqueeze(-1)

        # Conv1d 分组卷积后:(B, expert_num * output_dim, 1)
        # squeeze(-1) 去掉最后一维:(B, expert_num * output_dim)
        expert_outs = self.experts(shared_features).squeeze(-1)

        # reshape 成 (B, expert_num, output_dim),
        # 即每个样本有 expert_num 个专家,每个专家输出 output_dim 维向量
        expert_outs = expert_outs.reshape(-1, self.expert_num, self.output_dim)
        return expert_outs

这段代码有一个值得注意的设计:专家网络并非完全独立的 N 个 MLP,而是采用"共享骨干 + 分组卷积"的结构。所有专家共享前几层的特征提取(backbone),只在最后一层通过 nn.Conv1d 的 groups 参数实现独立输出。这种设计在保持专家多样性的同时,大幅减少了参数量,避免了完全独立专家带来的训练不稳定问题。

门控网络负责根据输入动态计算每个专家的权重:

class MoE(nn.Module):
    """
    混合专家模块:专家网络 + 门控网络。
    门控网络根据输入状态动态决定每个专家的贡献权重,
    最终输出是所有专家输出的加权和。这种"软路由"机制
    允许多个专家同时参与决策,而非硬性选择单一专家。
    """
    def __init__(self, expert_num, input_dim, hidden_dims,
                 output_dim, activation='elu'):
        # expert_num: 专家数量,默认8。更多专家意味着更细粒度的
        #             地形/运动模式划分,但也增加参数量和训练难度
        # input_dim:  输入维度,与 Experts 相同(如 270)
        # hidden_dims: 隐藏层维度列表,如 [512, 256, 256]。
        #              前 N-1 层用于共享骨干和门控网络,
        #              最后一层仅用于专家独立头
        # output_dim: 输出维度,即隐空间维度(如 32)
        # activation: 激活函数,默认 'elu'
        super().__init__()
        self.experts = Experts(
            expert_num=expert_num, input_dim=input_dim,
            backbone_hidden_dims=hidden_dims[:-1],  # [512, 256],共享骨干层
            expert_hidden_dim=hidden_dims[-1],       # 256,每个专家独立头的维度
            output_dim=output_dim, activation=activation,
        )
        # 门控网络:一个轻量 MLP + Softmax,输出 expert_num 维的概率分布。
        # 网络结构:[input_dim, 512, 256, expert_num] → Softmax
        # 输出的每个值表示对应专家的"激活程度",所有值之和为 1。
        # 例如输出 [0.3, 0.1, 0.05, 0.4, 0.02, 0.03, 0.05, 0.05]
        # 表示第4个专家(权重0.4)和第1个专家(权重0.3)主导当前决策。
        self.gating_network = nn.Sequential(
            MLP([input_dim, *hidden_dims[:-1], expert_num], activation),
            nn.Softmax(dim=-1)  # 归一化为概率分布,确保权重和为 1
        )

    def forward(self, x):
        # x 形状:(B, input_dim),B 是 batch size(并行环境数,如 8192)
        weights = self.gating_network(x)       # (B, expert_num),每个样本的专家权重
        expert_outs = self.experts(x)           # (B, expert_num, output_dim),每个专家的输出
        # 加权求和:weights.unsqueeze(-1) 将 (B, expert_num) 扩展为 (B, expert_num, 1),
        # 与 expert_outs (B, expert_num, output_dim) 逐元素相乘后沿 expert 维度求和,
        # 得到最终的混合输出 (B, output_dim)。
        # 这等价于:output[b] = Σ_i weights[b,i] * expert_outs[b,i,:]
        output = torch.sum(
            weights.unsqueeze(-1) * expert_outs, dim=1
        )  # (B, output_dim)
        return output, weights  # 同时返回权重,用于计算负载均衡损失

门控网络的输出经过 Softmax 归一化,确保所有专家的权重之和为 1。最终输出是所有专家输出的加权和。在训练过程中,还会引入负载均衡损失(load balancing loss),防止门控网络退化为只激活少数几个专家的模式。

默认配置使用 8 个专家,隐藏层维度为 [512, 256, 256],隐空间维度为 32。输出的隐向量经过 L2 归一化后,与当前帧的观测拼接,送入策略网络生成 12 维的关节位置指令。

5. 并行师生学习(CTS):特权信息的高效蒸馏

仅有 MoE 结构还不够。部署时机器人只能获取本体感知信息(关节编码器和 IMU),无法直接感知脚下的地形类型和高度。但训练时仿真器可以提供完整的地形高度图、接触力分布等特权信息。如何将这些特权信息中蕴含的地形理解能力,迁移到只依赖本体感知的学生网络中?这就是并行师生学习(Concurrent Teacher-Student, CTS)要解决的问题。

CTS 的核心思想源自论文 "Concurrent Training of a Control Policy and a State Estimator for Dynamic and Robust Legged Locomotion"(arXiv:2405.10830)。与传统的两阶段蒸馏不同,CTS 让教师和学生在同一个训练循环中并行学习。具体做法是将 8192 个并行环境按比例划分:75% 的环境分配给教师(使用特权观测),25% 分配给学生(仅使用历史本体感知)。两者共享同一个策略网络(Actor)和价值网络(Critic),只是编码器不同

以下是 ActorCriticMoECTS 网络的完整前向推理逻辑:

# 文件:rsl_rl/modules/actor_critic_moe_cts.py

class ActorCriticMoECTS(nn.Module):
    """
    并行师生学习(CTS)的核心网络。
    包含四个子模块:教师编码器、学生 MoE 编码器、共享策略网络、共享价值网络。
    训练时教师和学生并行运行,部署时仅保留学生编码器 + 策略网络。
    """
    def __init__(self, num_obs,          # 单帧本体感知观测维度,默认45。
                                          # 包含:角速度(3) + 重力投影(3) + 速度指令(3)
                                          # + 关节位置(12) + 关节速度(12) + 上帧动作(12)
                 num_critic_obs,          # 特权观测维度,默认263。
                                          # 包含 num_obs 的全部内容,额外加上:
                                          # 机身线速度(3) + 地形高度图(187) + 足端接触力(12)
                                          # + 摩擦系数(12) + 预留(4)
                 num_actions,             # 动作维度,固定12。对应4条腿×3个关节
                                          # (髋关节横摆、大腿前后摆、小腿屈伸)的
                                          # 期望角度偏移量(相对于默认站立姿态)
                 num_envs,                # 并行仿真环境数量,默认8192。
                                          # 其中75%分配给教师,25%分配给学生
                 history_length,          # 历史帧数,默认6。学生编码器的输入是
                                          # 最近6帧观测的拼接,用于从时序模式中
                                          # 推断地形信息(如踩到台阶时的力矩突变)
                 expert_num=8,            # MoE 专家数量,默认8
                 latent_dim=32,           # 隐空间维度,默认32。教师和学生编码器
                                          # 都输出32维向量,蒸馏损失在此空间计算
                 ...):
        # 教师编码器:标准 MLP,将263维特权观测压缩到32维隐空间。
        # 网络结构 [263, 512, 256, 32],最后经 L2 归一化使隐向量落在单位超球面上。
        # 仅在训练阶段使用,部署时完全丢弃。
        self.teacher_encoder = nn.Sequential(
            MLP([num_critic_obs, 512, 256, latent_dim]),  # 263 -> 512 -> 256 -> 32
            L2Norm()  # L2 归一化:||latent||_2 = 1,稳定蒸馏训练
        )
        # 学生 MoE 编码器:将历史本体感知(6帧×45维=270维)映射到32维隐空间。
        # 内部包含 MoE 模块(8个专家 + 门控网络),是本文的核心创新。
        # 部署时这是唯一的编码器,必须仅从关节数据中"推断"地形信息。
        self.student_moe_encoder = StudentMoEEncoder(
            expert_num=expert_num,                       # 8个专家
            input_dim=num_obs * history_length,           # 45 × 6 = 270
            hidden_dims=[512, 256, 256],                  # 骨干 [512, 256] + 专家头 256
            output_dim=latent_dim,                        # 32维输出
        )
        # 共享策略网络(Actor):输入 = 32维隐向量 + 45维当前观测 = 77维,
        # 输出 = 12维关节角度偏移。教师和学生共享同一个 Actor,
        # 确保两者学到的隐表示语义一致。
        # 网络结构 [77, 512, 256, 128, 12]
        self.actor = MLP([latent_dim + num_obs, 512, 256, 128, num_actions])
        # 共享价值网络(Critic):输入 = 32维隐向量 + 263维特权观测 = 295维,
        # 输出 = 1维状态价值估计。Critic 始终使用特权观测,
        # 因为价值函数只在训练时使用,部署时不需要。
        # 网络结构 [295, 512, 256, 128, 1]
        self.critic = MLP([latent_dim + num_critic_obs, 512, 256, 128, 1])

    def act(self, obs, privileged_obs, history, is_teacher):
        """
        训练时的动作采样。
        参数:
          obs:            当前帧本体感知观测 (B, 45)
          privileged_obs: 特权观测 (B, 263),仅教师使用
          history:        历史观测序列 (B, history_length*num_obs),仅学生使用
          is_teacher:     布尔值,True=使用教师编码器,False=使用学生编码器
        """
        if is_teacher:
            # 教师路径:直接从特权观测(含地形高度图、接触力等)编码
            latent = self.teacher_encoder(privileged_obs)
        else:
            # 学生路径:从历史本体感知中推断地形隐表示。
            # torch.no_grad() 表示学生编码器在此处不计算梯度,
            # 其梯度通过蒸馏损失单独更新,避免 PPO 梯度干扰编码器训练。
            with torch.no_grad():
                latent, _ = self.student_moe_encoder(history)
        # 将32维隐向量与45维当前观测拼接为77维,送入共享 Actor
        x = torch.cat([latent, obs], dim=1)
        # 构建高斯分布 N(mean, std),mean 由 Actor 输出,std 是可学习参数
        self.update_distribution(x)
        # 从分布中采样动作(训练时需要随机性以探索)
        return self.distribution.sample()

    def act_inference(self, obs):
        """
        部署时的确定性推理(仅使用学生编码器)。
        与训练时的区别:
        1. 不使用教师编码器(真机无特权信息)
        2. 不采样,直接输出均值(消除随机性,保证行为稳定)
        3. 自动维护历史缓冲区(滑动窗口)
        参数:
          obs: 当前帧本体感知观测 (B, 45)
        """
        # 滑动窗口更新历史:丢弃最旧一帧,追加当前帧。
        # history 形状从 (B, 6, 45) 的窗口中去掉第0帧,
        # 在末尾追加当前 obs,保持窗口长度为 6。
        self.history = torch.cat(
            [self.history[:, 1:], obs.unsqueeze(1)], dim=1
        )
        # flatten(1) 将 (B, 6, 45) 展平为 (B, 270),送入学生 MoE 编码器
        latent, _ = self.student_moe_encoder(self.history.flatten(1))
        x = torch.cat([latent, obs], dim=1)  # (B, 77)
        return self.actor(x)  # 返回均值动作,不采样,确保部署行为确定性

训练过程中的损失函数由四部分组成。第一部分是标准的 PPO 损失(策略梯度的 clipped surrogate 目标 + 价值函数损失 + 熵正则化),这是强化学习的基础优化目标。第二部分是隐空间蒸馏损失,要求学生编码器的输出尽可能接近教师编码器的输出,通常使用 MSE 或余弦相似度。第三部分是 MoE 负载均衡损失,防止门控网络将所有权重集中在少数专家上,确保每个专家都能被充分利用。第四部分是可选的行为克隆损失,在训练早期用教师的动作分布监督学生。

这种并行训练的优势在于:教师和学生始终在同一个策略水平上学习,避免了两阶段蒸馏中"教师已经收敛但学生还在追赶"的滞后问题。同时,学生在训练过程中就直接参与环境交互,其产生的轨迹数据也被用于 PPO 更新,进一步提升了样本效率。

6. 训练环境:地形课程与域随机化

策略的泛化能力很大程度上取决于训练环境的多样性。go2_rl_gym 在 Isaac Gym 中构建了一套精心设计的地形课程系统,包含 9 种地形类型,每种地形有多个难度等级。8192 个并行环境被分配到不同地形上,机器人在训练过程中根据自身表现自动升降难度——表现好的个体被提升到更难的地形,表现差的则回退到简单地形。这种课程学习机制避免了一开始就在高难度地形上训练导致的策略崩溃。

以下是地形配置的核心参数:

# 文件:legged_gym/envs/go2/go2_config.py

# 9种地形类型及其在训练中的占比。
# 8192 个并行环境按以下比例分配到不同地形上,
# 每种地形内部还有多个难度等级(由课程学习机制自动调节)。
terrain_types = [
    'wave',            # 波浪地形:地面呈正弦波起伏,测试机器人对不规则地面的适应性
    'slope',           # 斜坡:倾斜平面,测试上下坡能力和重心调整
    'rough_slope',     # 粗糙斜坡:斜坡 + 随机凸起,比普通斜坡更具挑战性
    'stairs_up',       # 上楼梯:离散台阶向上,需要精确的抬腿高度控制
    'stairs_down',     # 下楼梯:离散台阶向下,需要稳定的着地缓冲
    'obstacles',       # 障碍物:随机分布的方块/圆柱体,需要跨越或绕行
    'stepping_stones', # 踏脚石:离散的小平台,需要精确落足(当前未启用)
    'gap',             # 间隙:地面上的沟壑,需要跨越(当前未启用)
    'flat'             # 平地:无障碍平面,作为基线和恢复训练用
]
# 各地形在训练中的占比,所有值之和为 1.0。
# 比例设计逻辑:重点训练真实部署中最常遇到的挑战性地形。
terrain_proportions = [0.05, 0.20, 0.05, 0.25, 0.10, 0.20, 0.0, 0.0, 0.15]
# 分析:楼梯(上25%+下10%=35%) + 障碍物(20%) + 斜坡(20%) 占主要比重,
#       波浪(5%) + 粗糙斜坡(5%) 作为补充,平地(15%) 保证基础稳定性,
#       踏脚石和间隙(各0%) 在当前配置中未启用。

从比例分配可以看出,训练重点放在楼梯(上下合计 35%)、障碍物(20%)和斜坡(20%)上,这些正是真实部署中最常遇到的挑战性地形。波浪地形和粗糙斜坡各占 5% 作为补充,踏脚石和间隙地形在当前配置中未启用。平地占 15%,确保策略在简单场景下也能保持稳定。

域随机化是弥合 Sim-to-Real 鸿沟的另一个关键手段。其核心思想是:如果策略在大范围参数变化下都能正常工作,那么真实世界的参数大概率落在这个范围内。go2_rl_gym 对以下物理参数施加了随机化:

# 域随机化参数范围(Domain Randomization)
# 核心思想:如果策略在大范围参数变化下都能正常工作,
# 那么真实世界的参数大概率落在这个范围内,从而实现 Sim-to-Real 迁移。
# 每个参数在每次环境重置时从指定范围内均匀采样。

# === 地面物理属性随机化 ===
friction_range = [0.0, 2.0]        # 地面摩擦系数。0.0=完全光滑(冰面),
                                    # 2.0=极高摩擦(橡胶地面)。
                                    # 真实世界中瓷砖约0.3-0.5,水泥约0.6-0.8,
                                    # 草地约1.0-1.5。范围覆盖了几乎所有常见地面。
base_mass_offset = [-1.0, 1.0]     # 机身质量偏移 (kg)。Go2 标称质量约15kg,
                                    # ±1kg 模拟负载变化(如背包、传感器安装)
link_mass_scale = [0.9, 1.1]       # 连杆质量缩放因子。0.9=轻10%,1.1=重10%,
                                    # 模拟制造公差和零件磨损
base_com_offset = [-0.03, 0.03]    # 质心位置偏移 (m),xyz 三个方向各±3cm。
                                    # 模拟不对称负载导致的质心偏移
restitution_range = [0.0, 0.5]     # 恢复系数(弹性碰撞程度)。0=完全非弹性,
                                    # 0.5=中等弹性。影响足端着地时的反弹行为

# === 电机参数随机化 ===
motor_zero_offset = [-0.035, 0.035]  # 关节零位偏移 (rad,约±2°)。
                                      # 模拟电机编码器的零位标定误差,
                                      # 真机上每个关节的"零位"都有微小偏差
motor_strength = [0.8, 1.2]          # 电机力矩缩放因子。0.8=输出力矩打8折,
                                      # 1.2=输出力矩增加20%。模拟电机老化、
                                      # 温度变化和个体差异对力矩输出的影响
action_delay = [0, 20]               # 动作执行延迟 (ms)。从策略输出指令到
                                      # 电机实际执行之间的时间差。真实电机的
                                      # 通信+响应延迟通常在5-15ms,训练时
                                      # 扩大到0-20ms以增强鲁棒性

# === 外部扰动(模拟意外推力) ===
push_interval = 4.0                  # 推力施加间隔 (s)。每隔4秒随机施加一次
                                      # 外力,模拟被人推、被物体撞击等场景
max_push_vel_xy = 0.4                # 最大推力等效线速度 (m/s)。推力大小以
                                      # 等效速度变化量表示,0.4m/s 约等于
                                      # 一个中等力度的侧推
max_push_ang_vel = 0.6               # 最大推力等效角速度 (rad/s)。模拟
                                      # 旋转方向的扰动,如被斜向推一下

其中动作延迟的随机化尤为重要。真实电机从接收指令到执行存在不可忽略的延迟,且延迟量随电机负载和温度变化。训练时引入 0-20ms 的随机延迟,迫使策略学会对延迟具有鲁棒性的控制行为,而非依赖精确的即时响应。

7. 奖励函数设计:引导策略学会"好的"行走方式

强化学习的核心驱动力是奖励函数。一个设计不当的奖励函数会导致策略学到各种"投机取巧"的行为——比如用不自然的姿态滑行来最大化速度跟踪奖励,或者通过剧烈抖动关节来获取额外的探索奖励。go2_rl_gym 的奖励函数由多个子项加权组成,每个子项对应一个期望的行为特征或需要惩罚的不良行为。

以下是各奖励子项及其权重:

# 文件:legged_gym/envs/base/legged_robot_config.py

class scales:
    """
    奖励函数各子项的权重。正值=鼓励该行为,负值=惩罚该行为。
    总奖励 = Σ (weight_i × reward_i),每个 reward_i 的计算方式不同。
    权重的绝对值反映了该行为在整体优化目标中的重要程度。
    """
    # === 正向奖励:鼓励期望行为 ===
    tracking_lin_vel = 1.0     # 线速度跟踪(主奖励,权重最高)。
                                # 计算方式:exp(-||v_actual - v_cmd||² / σ²),
                                # 其中 σ=0.25。当实际速度完美匹配指令时=1.0,
                                # 偏差越大衰减越快。这是策略优化的首要目标。
    tracking_ang_vel = 0.5     # 角速度(转向)跟踪。权重为线速度的一半,
                                # 因为转向精度的优先级低于前进速度。
                                # 计算方式同上,σ=0.25。
    feet_air_time = 1.0        # 足端腾空时间奖励。鼓励机器人抬腿迈步,
                                # 而非拖着脚滑行。每只脚离地时间越接近
                                # 目标值(约0.5秒),奖励越高。
                                # 没有这个奖励,策略会学到"拖行"步态。

    # === 负向惩罚:抑制不良行为 ===
    lin_vel_z = -2.0           # 惩罚垂直方向(z轴)速度波动。权重较大(-2.0),
                                # 因为机身上下颠簸是最明显的不稳定信号。
                                # 理想状态下机身高度应保持恒定。
    ang_vel_xy = -0.05         # 惩罚横滚(roll)和俯仰(pitch)角速度。
                                # 权重较小,允许一定程度的机身晃动,
                                # 但过度晃动会被惩罚。
    collision = -1.0           # 惩罚非足端部位的碰撞(如膝盖、机身撞地)。
                                # 权重-1.0,与主奖励同量级,表示碰撞是
                                # 严重的不良行为。
    action_rate = -0.01        # 惩罚相邻两帧动作的变化量 ||a_t - a_{t-1}||²。
                                # 权重很小,轻微约束动作平滑性,
                                # 避免关节指令剧烈抖动。
    torques = -0.00001         # 惩罚关节力矩的平方和 Στ²。权重极小,
                                # 仅作为正则化项,鼓励策略用更小的力矩
                                # 完成运动,降低能耗和电机磨损。
    dof_acc = -2.5e-7          # 惩罚关节加速度的平方和。权重极小,
                                # 抑制关节角速度的突变,使运动更平滑。
                                # 与 action_rate 互补:action_rate 约束指令层面,
                                # dof_acc 约束物理执行层面。

速度跟踪奖励是整个奖励体系的核心。它采用高斯核函数的形式,当实际速度与指令速度的误差为零时奖励最大,误差越大奖励衰减越快:

²²

其中 sigma = 0.25 控制了奖励的"宽容度"。较小的 sigma 意味着策略必须非常精确地跟踪指令速度才能获得高奖励,较大的 sigma 则允许一定的跟踪误差。0.25 是一个经验值,在跟踪精度和训练稳定性之间取得了平衡。

值得注意的是配置中启用了 only_positive_rewards = True,即所有负值奖励会被截断为零。这意味着惩罚项不会直接减少总奖励,而是通过降低正向奖励的相对占比来间接发挥作用。这种设计避免了训练早期因大量惩罚导致的策略崩溃,让机器人有更多"试错空间"。

足端腾空时间奖励(feet_air_time)是另一个关键设计。没有这个奖励项,策略倾向于学到一种"拖行"步态——脚不离地,靠滑动前进。这种步态在仿真中可能有效,但在真实世界中会因摩擦力不一致而失败。通过奖励足端的腾空时间,策略被迫学会抬腿迈步的自然步态。

奖励函数还支持课程化调整。某些奖励项的权重可以随训练进度线性变化,例如在训练初期降低对垂直速度的惩罚(让机器人先学会走路),后期再逐步加大惩罚(让步态更平稳):

# 奖励课程示例(Reward Curriculum)
# 某些奖励项的权重随训练迭代次数线性变化,
# 实现"先学会走,再学会走好"的渐进式训练策略。
curriculum_rewards = {
    'reward_name': 'lin_vel_z',  # 要调整的奖励项名称。
                                  # lin_vel_z 是垂直速度惩罚,
                                  # 用于抑制机身上下颠簸。
    'start_iter': 0,              # 课程开始的训练迭代次数。
                                  # 0 表示从训练一开始就生效。
    'end_iter': 1500,             # 课程结束的训练迭代次数。
                                  # 在第 0~1500 次迭代之间线性过渡。
    'start_value': 1.0,           # 初始缩放因子。1.0 表示惩罚权重
                                  # 保持原值(-2.0 × 1.0 = -2.0)。
                                  # 训练初期允许较大的垂直波动,
                                  # 让机器人先学会基本的前进运动。
    'end_value': 0.0              # 最终缩放因子。0.0 表示惩罚权重
                                  # 降为零(-2.0 × 0.0 = 0.0)。
                                  # 注意:这里 end_value < start_value,
                                  # 实际上是逐渐放松惩罚,而非加重。
                                  # 具体策略取决于训练需求。
}
# 线性插值公式:
# progress = clip((current_iter - start_iter) / (end_iter - start_iter), 0, 1)
# current_scale = (1 - progress) * start_value + progress * end_value
# 最终权重 = 原始权重 × current_scale

8. RoboGauge:用 Sim-to-Sim 预测 Sim-to-Real

RoboGauge 是本文的另一个核心贡献。它的设计动机很直接:既然我们无法廉价地在真机上测试策略,那能否通过在另一个仿真器中测试来间接预测真机表现?答案是肯定的,前提是两个仿真器的物理引擎足够不同。Isaac Gym 使用 NVIDIA PhysX 作为物理后端,MuJoCo 使用自研的凸优化求解器,两者在接触力计算、摩擦模型、积分方法等方面存在显著差异。如果一个策略能在两个物理引擎间保持一致的表现,说明它学到的控制行为不依赖于特定仿真器的"漏洞",更有可能在真实世界中也能工作。

RoboGauge 定义了 6 个评估指标,通过加权几何平均计算综合质量分数:

# 文件:robogauge/tasks/gauge/base_gauge_config.py

# 综合质量分数的各指标权重,用于加权几何平均。
# 几何平均公式:Q = Π(score_i ^ w_i) ^ (1/Σw_i)
# 相比算术平均,几何平均对"短板"更敏感——
# 任何一个指标极差都会显著拉低总分,避免"偏科"策略获得高分。
QUALITY_WEIGHTS = {
    'lin_vel_err': 2,            # 线速度跟踪误差(权重2,最高优先级)。
                                  # 衡量机器人实际前进/侧移速度与指令的偏差。
                                  # 权重为2表示该指标在总分中的影响力是其他指标的2倍,
                                  # 因为精确的速度跟踪是运动控制的首要目标。
    'ang_vel_err': 2,            # 角速度(转向)跟踪误差(权重2,最高优先级)。
                                  # 衡量机器人实际转向角速度与指令的偏差。
                                  # 与线速度同等重要,因为转向失控同样危险。
    'dof_limits': 1,             # 关节极限违规程度(权重1)。
                                  # 衡量关节角度是否接近或超过物理极限。
                                  # 频繁触及极限会损坏电机和结构件。
    'dof_power': 1,              # 电机能耗(权重1)。
                                  # 衡量 |力矩 × 角速度| 的均方根值,
                                  # 以 100W 为基准归一化。能耗过高意味着
                                  # 策略效率低下,电池续航会大幅缩短。
    'orientation_stability': 1,  # 姿态稳定性(权重1)。
                                  # 衡量机身横滚和俯仰角的波动幅度。
                                  # 机身晃动越小,运动越稳定。
    'torque_smoothness': 1,      # 力矩平滑度(权重1)。
                                  # 衡量相邻时刻力矩变化量的均方根值,
                                  # 以 30Nm 为基准归一化。力矩抖动会
                                  # 加速齿轮磨损并产生噪音。
}

速度跟踪误差的权重最高(各为 2),因为精确的速度跟踪是运动控制的首要目标。其余四个指标权重为 1,分别衡量策略的安全性(关节是否接近极限)、效率(能耗是否合理)、稳定性(机身是否晃动)和平滑性(力矩输出是否抖动)。

每个指标的计算方式都经过归一化处理,输出范围在 [0, 1] 之间,1 表示最优。以线速度跟踪误差为例:

# 各指标的计算方式(伪代码),所有指标输出范围 [0, 1],1=最优。

# 线速度跟踪误差指标:衡量实际速度与指令速度的偏差
# actual_lin_vel: 机器人实际线速度 (m/s),从仿真器读取
# target_lin_vel: 用户指令线速度 (m/s),由速度指令生成器提供
# norm_vel: 归一化基准速度,防止除零并统一量纲
vel_err = ||actual_lin_vel - target_lin_vel|| / norm_vel
score = 1 - vel_err  # 误差越小,分数越接近 1.0

# 关节极限违规指标:衡量关节角度是否接近物理极限
# joint_pos: 当前关节角度 (rad)
# soft_limit: 软极限 = 硬极限 × 0.9(留10%安全裕度)
# dof_range: 关节可动范围(硬极限上界 - 硬极限下界)
violation = max(0, |joint_pos| - soft_limit) / dof_range
score = 1 - sqrt(mean(violation²))  # RMS 归一化,对大违规更敏感

# 电机能耗指标:衡量运动的能量效率
# torque: 关节力矩 (Nm),velocity: 关节角速度 (rad/s)
# 功率 = |力矩 × 角速度|,物理意义是电机瞬时输出功率
# scaling_factor: 100W,作为"合理能耗"的基准值
power = |torque * velocity|
score = 1 - rms(power) / scaling_factor  # 能耗越低,分数越高

RoboGauge 的评估流水线分为四个层级,从简单到复杂依次为:单次评估(BasePipeline)、多种子并行评估(MultiPipeline)、难度等级搜索(LevelPipeline)和全面压力测试(StressPipeline)。

单次评估是最基础的单元。它在 MuJoCo 中加载指定地形和策略模型,让机器人执行速度跟踪任务,同时以 0.1 秒的间隔采集上述 6 个指标。评估过程中还会施加域随机化——机身质量在 {-1, 0, 1, 2, 3} kg 范围内随机偏移,地面摩擦系数在 0.2 到 1.0 之间随机采样,动作延迟在 0 到控制周期之间随机变化。如果机器人翻倒(重力投影角度超过 2.5 弧度)或陷入地面(穿透深度超过 3.5 厘米),评估立即终止并记录失败。

难度等级搜索是 RoboGauge 的一个巧妙设计。对于每种地形(斜坡、楼梯、障碍物、波浪),系统定义了 10 个难度等级,对应不同的地形参数(如斜坡高度从 0.588m 递增到 2.280m,楼梯高度从 1.35m 递增到 3.60m)。LevelPipeline 使用二分搜索算法,高效地找到策略能稳定通过的最高难度等级:

# 文件:robogauge/tasks/pipeline/level_pipeline.py
# 二分搜索最高可通过难度等级。
# 目标:高效找到策略能稳定通过的最高地形难度,
# 而非逐级测试(O(n) → O(log n)),10个等级只需约4次测试。

l, r = 0, 10  # l=搜索下界,r=搜索上界。难度等级范围 [0, 10],
               # 0=最简单(如斜坡高度0.588m),10=最难(如斜坡高度2.280m)
while l < r:
    level = (l + r + 1) // 2  # 取中间偏上的等级(向上取整,避免死循环)
    # test_level(level) 在该难度下运行多个随机种子的评估,
    # 返回 (all_success, results):
    #   all_success: 布尔值,True 表示成功率 >= 80%(多种子统计)
    #   results: 各种子的详细评估数据(6个指标 + 是否翻倒/陷入)
    all_success, results = self.test_level(level)
    if all_success:  # 成功率 >= 80%,说明策略能稳定应对该难度
        l = level    # 收缩下界:当前难度可以通过,尝试更高难度
    else:
        r = level - 1  # 收缩上界:当前难度太高,降低搜索范围
# 循环结束时 l == r,即为策略能稳定通过的最高等级。
# 例如 l=7 表示策略能通过等级7但无法通过等级8。

成功的判定标准是:在多个随机种子下,策略的平均成功率不低于 80%。这个阈值确保了评估结果的统计可靠性,避免因单次运气好而高估策略能力。

全面压力测试(StressPipeline)将上述所有组件串联起来:对每种地形执行难度搜索,在找到的最高难度等级上进行多种子、多域随机化参数的全面测试,最终汇总为一个综合 RoboGauge 分数。在 AMD EPYC 7763(64 核)上,63 个评估任务的完整压力测试仅需约 2 分 46 秒,这得益于 MuJoCo 的高效 CPU 仿真和多进程并行调度。

9. C++ 部署流水线:从 ONNX 模型到真机关节指令

训练和评估完成后,最终目标是将策略部署到 Unitree Go2 的机载计算平台(NVIDIA Orin NX)上。unitree_cpp_deploy 提供了一套完整的 C++ 推理框架,其设计需要满足两个硬约束:实时性(策略推理必须在控制周期内完成)和安全性(异常情况下必须立即切换到安全模式)。

整个部署系统采用双线程架构。主线程运行有限状态机(FSM),以 1kHz 的频率执行底层控制循环,负责读取传感器数据、下发电机指令和处理状态切换。策略线程以 50Hz 的频率运行神经网络推理,通过线程安全的缓冲区与主线程交换数据。两个线程的频率差异是有意为之的:底层 PD 控制器需要高频更新以保证电机跟踪精度,而神经网络策略的输出(期望关节角度)变化相对缓慢,50Hz 足以捕捉运动意图。

ONNX 模型的加载和推理通过 ONNX Runtime 实现。以下是推理引擎的核心代码:

// 文件:deploy/include/isaaclab/algorithms/algorithms.h
// ONNX Runtime 推理引擎封装。负责加载训练导出的 .onnx 模型文件,
// 并在 CPU 上执行前向推理。策略线程以 50Hz 频率调用 forward() 方法。

class OrtRunner : public Algorithms {
public:
    // 构造函数:加载 ONNX 模型并初始化推理会话。
    // model_path: ONNX 模型文件的绝对路径,如 "/policy/student_moe.onnx"
    OrtRunner(std::string model_path) {
        // 创建 ONNX Runtime 运行环境。
        // ORT_LOGGING_LEVEL_WARNING: 只输出警告级别以上的日志,
        // 避免推理时产生大量调试输出影响实时性。
        env = Ort::Env(ORT_LOGGING_LEVEL_WARNING, "onnx_model");

        // 启用扩展级别的图优化。ONNX Runtime 会自动执行:
        // - 算子融合(如 MatMul+Add → Gemm)
        // - 常量折叠(编译期计算固定值)
        // - 冗余节点消除
        // 这些优化可将推理延迟降低 20-40%。
        session_options.SetGraphOptimizationLevel(ORT_ENABLE_EXTENDED);

        // 创建推理会话,加载模型权重到内存。
        // 使用 CPU 执行推理(非 GPU),因为:
        // 1. Orin NX 的 GPU 资源有限,需留给其他任务(如视觉处理)
        // 2. 策略网络规模较小(~2M 参数),CPU 推理延迟 < 1ms,
        //    远低于 50Hz 控制周期的 20ms 预算
        session = std::make_unique<Ort::Session>(
            env, model_path.c_str(), session_options
        );

        // 自动发现模型的输入名称和张量形状。
        // 这种设计使部署代码与模型结构解耦——
        // 无论是 MoE 模型还是普通 MLP 模型,只要导出为 ONNX 格式,
        // 同一套代码都能加载和推理,无需修改任何硬编码参数。
        size_t num_inputs = session->GetInputCount();
        for (size_t i = 0; i < num_inputs; ++i) {
            input_shapes.push_back(
                session->GetInputTypeInfo(i)
                    .GetTensorTypeAndShapeInfo().GetShape()
                // 获取第 i 个输入的张量形状,如 [1, 45](单帧观测)
                // 或 [1, 270](6帧历史拼接)
            );
        }
    }

    // 前向推理:输入观测数据,输出动作指令。
    // obs: 键值对映射,键=输入名称(如 "obs"),值=浮点数组
    // 返回值: 键值对映射,键=输出名称(如 "actions"),值=12维关节角度偏移
    std::map<std::string, std::vector<float>> forward(
        std::unordered_map<std::string, std::vector<float>> obs
    ) override {
        // 将 C++ 数组包装为 ONNX Runtime 的张量格式。
        // 使用零拷贝方式(直接引用 data.data() 指针),避免内存分配开销。
        std::vector<Ort::Value> input_tensors;
        for (size_t i = 0; i < input_names.size(); ++i) {
            auto& data = obs.at(input_names_strings[i]);
            input_tensors.push_back(Ort::Value::CreateTensor<float>(
                memory_info, data.data(), input_sizes[i],
                input_shapes[i].data(), input_shapes[i].size()
            ));
        }
        // 执行一次前向推理。RunOptions{nullptr} 使用默认配置。
        // 输入:观测张量(如 45 维当前观测 + 270 维历史)
        // 输出:12 维关节角度偏移量(相对于默认站立姿态)
        auto output_tensors = session->Run(
            Ort::RunOptions{nullptr},
            input_names.data(), input_tensors.data(),
            input_tensors.size(),
            output_names.data(), output_names.size()
        );
        // 将输出张量转换为 C++ map,供后续 PD 控制器使用。
        std::map<std::string, std::vector<float>> results;
        for (size_t i = 0; i < output_tensors.size(); i++) {
            auto* arr = output_tensors[i].GetTensorMutableData<float>();
            // ... 拷贝到 results map
        }
        return results;
    }
};

这段代码有几个值得关注的设计决策。首先,模型的输入输出名称和形状是在加载时自动发现的,而非硬编码。这意味着同一套部署代码可以适配不同结构的策略模型,只要它们遵循 ONNX 格式。其次,推理使用 CPU 而非 GPU,这是因为 Orin NX 的 GPU 资源有限,且 CPU 推理对于 50Hz 的策略网络已经绰绰有余。第三,启用了 ORT_ENABLE_EXTENDED 图优化级别,ONNX Runtime 会自动进行算子融合、常量折叠等优化,进一步降低推理延迟。

观测数据的构建是部署流水线中最容易出错的环节。训练时仿真器直接提供标准化的状态向量,但部署时需要从原始传感器数据手动计算每一个观测分量。以重力投影向量为例,它需要从 IMU 的四元数姿态中计算得到:

// 文件:deploy/include/unitree_articulation.h
// 传感器数据更新函数。每个控制周期(1kHz)由主线程调用,
// 从 Unitree Go2 的底层状态消息中提取策略所需的观测数据。
// 这是 Sim-to-Real 部署中最容易出错的环节——
// 任何一个数据的单位、坐标系或顺序错误都会导致策略完全失效。

void update() override {
    // === 第1步:读取机身角速度(3维) ===
    // 从 IMU 陀螺仪读取 xyz 三轴角速度 (rad/s)。
    // 对应策略观测向量的前3维。
    // lowstate 是通过 DDS 协议从机器人底层控制板接收的状态消息。
    for (int i = 0; i < 3; i++) {
        data.root_ang_vel_b[i] =
            lowstate->msg_.imu_state().gyroscope()[i];
    }

    // === 第2步:计算重力投影向量(3维) ===
    // 从 IMU 的四元数姿态中计算重力在机体坐标系下的投影。
    // 四元数格式:[w, x, y, z],表示从世界坐标系到机体坐标系的旋转。
    // 这个向量告诉策略"机身当前倾斜了多少"——
    // 直立时为 [0, 0, -1],前倾时 z 分量减小、x 分量增大。
    data.root_quat_w = Eigen::Quaternionf(
        lowstate->msg_.imu_state().quaternion()[0],  // w 分量
        lowstate->msg_.imu_state().quaternion()[1],  // x 分量
        lowstate->msg_.imu_state().quaternion()[2],  // y 分量
        lowstate->msg_.imu_state().quaternion()[3]   // z 分量
    );
    // conjugate() 求共轭四元数(等价于旋转的逆),
    // 将世界坐标系下的重力向量 [0, 0, -9.81] 转换到机体坐标系。
    // GRAVITY_VEC_W = [0, 0, -1](归一化的重力方向)
    data.projected_gravity_b =
        data.root_quat_w.conjugate() * data.GRAVITY_VEC_W;

    // === 第3步:读取12个关节的位置和速度(24维) ===
    // joint_ids_map 是关键的映射表:策略网络索引 → 硬件电机编号。
    // 训练环境(Isaac Gym)的关节顺序是:
    //   [FL_hip, FL_thigh, FL_calf, FR_hip, FR_thigh, FR_calf,
    //    RL_hip, RL_thigh, RL_calf, RR_hip, RR_thigh, RR_calf]
    // 但 Go2 硬件的电机编号可能不同,joint_ids_map 负责对齐。
    // 如果映射错误,左前腿的指令会被发送到错误的电机!
    for (int i = 0; i < data.joint_ids_map.size(); i++) {
        int hw_id = data.joint_ids_map[i];  // 策略索引 i → 硬件编号 hw_id
        data.joint_pos[i] = lowstate->msg_.motor_state()[hw_id].q();   // 关节角度 (rad)
        data.joint_vel[i] = lowstate->msg_.motor_state()[hw_id].dq();  // 关节角速度 (rad/s)
    }
}

这里的 joint_ids_map 是一个关键的映射表,它将策略网络期望的关节顺序映射到硬件电机的物理编号。训练环境(Isaac Gym)和真机的关节编号顺序可能不同,如果映射错误,策略输出的左前腿指令可能被发送到右后腿,后果不堪设想。

有限状态机(FSM)是部署系统的安全骨架。它定义了机器人从上电到运行策略再到紧急停止的完整状态转换逻辑。操作员通过手柄按键触发状态切换,每个状态都有明确的进入条件和退出条件:

状态转换图:

  Passive(被动阻尼)
     │  [L2 + A]
     ▼
  FixStand(站立锁定)
     │  [Start + 方向键]
     ▼
  RL Policy(策略运行)──── [姿态异常] ────▶ Passive
     │  [L2 + B]
     ▼
  Passive(被动阻尼)

其中最关键的安全机制是姿态异常检测。当机器人的姿态偏离直立超过 2 弧度时(约 115 度),系统会立即切换到被动阻尼模式,所有电机进入低刚度阻尼状态,防止机器人在翻倒后继续执行策略导致电机过热或结构损坏:

// 文件:deploy/robots/go2/src/State_RLBase.cpp
// 姿态异常自动切换到安全模式。
// 这是部署系统中最关键的安全机制——当机器人翻倒或严重倾斜时,
// 立即停止策略执行,切换到被动阻尼模式,防止电机在异常姿态下
// 持续输出力矩导致过热、齿轮损坏或结构变形。

// registered_checks 是一个检查项列表,主线程每个控制周期(1kHz)
// 都会遍历所有检查项,一旦任何检查返回 true,立即触发状态切换。
this->registered_checks.emplace_back(
    std::make_pair(
        // Lambda 函数:检测姿态是否异常。
        // bad_orientation() 计算机身 z 轴与世界重力方向的夹角,
        // 当夹角超过阈值时返回 true。
        [&]()->bool {
            // 参数 2.0 是角度阈值(弧度),约 115°。
            // 含义:当机身倾斜超过 115° 时判定为"翻倒"。
            // 选择 2.0 而非 π/2(90°) 是为了留出一定容错空间——
            // 机器人在爬楼梯时可能短暂倾斜较大角度,
            // 不应误触发安全保护。但超过 115° 基本可以确认翻倒。
            return isaaclab::mdp::bad_orientation(env.get(), 2.0);
        },
        // 触发后切换到的目标状态:Passive(被动阻尼模式)。
        // 在 Passive 模式下,所有 12 个电机进入低刚度、高阻尼状态,
        // 关节可以自由活动但会被缓慢制动,类似"断电软着陆"。
        (int)FSMMode::Passive
    )
);

部署系统还内置了完善的数据记录功能,以 CSV 格式记录每个控制周期的关节位置、速度、力矩、IMU 数据、足端接触力和策略输出。这些日志数据对于调试策略行为、分析失败原因和验证 Sim-to-Real 一致性至关重要。

10. 观测空间设计:策略"看到"了什么

理解策略的观测空间,是理解整个系统的关键一环。本文的一个重要约束是"仅依赖本体感知"——机器人不携带深度相机、激光雷达或任何外部传感器,所有信息都来自关节编码器和机身 IMU。这意味着策略必须从关节运动的历史模式中"推断"出脚下地形的特征,而非直接"看到"地形。

学生网络的单帧观测为 45 维,具体构成如下:

学生观测向量(45维):
├── 机身角速度(3维):IMU 陀螺仪读数,反映机身旋转状态
├── 重力投影(3维):重力向量在机体坐标系下的投影,反映机身倾斜
├── 速度指令(3维):期望的前进速度、侧移速度和转向角速度
├── 关节位置(12维):4条腿 x 3个关节(髋关节、大腿、小腿)
├── 关节速度(12维):对应的关节角速度
└── 上一帧动作(12维):策略在上一个控制周期输出的关节指令

教师网络额外获得 218 维的特权信息,总计 263 维:

特权观测附加部分(218维):
├── 机身线速度(3维):仿真器直接提供,真机无法获取
├── 地形高度图(187维):17x11 的网格,覆盖机身周围 1.6m x 1.0m
├── 足端接触力(12维):4只脚 x 3个方向的接触力
├── 摩擦系数(12维):4只脚 x 3个方向的地面摩擦
└── 其他(4维):预留扩展

地形高度图是教师网络最重要的特权信息。它以机身为中心,在前后各 0.8m、左右各 0.5m 的范围内均匀采样 187 个高度值,相当于给教师一张低分辨率的"地形俯视图"。学生网络无法获取这张图,但通过 CTS 蒸馏,它学会了从关节运动的历史序列中推断出等价的地形表示——当脚踩到台阶边缘时关节力矩会突变,当走在斜坡上时重力投影会持续偏移,这些隐含信号足以让 MoE 编码器重建出有用的地形隐表示。

11. 快速复现指南

以下是从零开始复现本文结果的关键步骤概要,具体细节请参考各仓库的 README 文档。

训练阶段(go2_rl_gym):

# 安装依赖(需要 Isaac Gym 预览版)
cd go2_rl_gym
pip install -e .

# 启动 MoE+CTS 训练(8192 并行环境,GPU 加速)
python legged_gym/scripts/train.py 
    --task go2_moe_cts 
    --num_envs 8192 
    --max_iterations 150000 
    --headless

评估阶段(RoboGauge):

# 安装 RoboGauge
cd RoboGauge
pip install -e .

# 执行全面压力测试(自动搜索最高难度等级)
python robogauge/scripts/run.py 
    --pipeline stress 
    --policy_path /path/to/exported/policy.onnx 
    --num_processes 64

部署阶段(unitree_cpp_deploy):

# 在 Orin NX 上编译部署程序
cd unitree_cpp_deploy/deploy
mkdir build && cd build
cmake .. && make -j4

# 启动策略推理(连接真机)
./go2_deploy 
    --policy_dir /path/to/policy_dir 
    --network eth0 
    --log

 

相关推荐