Skip to content

Ch18 · 视觉感知运动控制

定位:Part IV 第四章。从"盲"的本体感知控制跨越到视觉闭环控制——机器人如何用"眼睛"感知地形并做出精准的运动决策。 关键文献:extreme-parkour — Extreme Parkour with Legged Robots(ICRA'24,三阶段视觉管线标杆) 关键框架:mjlab MuJoCo Warp Batch Renderer · Isaac Lab TiledCamera / TiledCameraCfg RTX 加速渲染 累积项目:E(视觉地形感知模块——从 height scan teacher 到 depth student 的完整蒸馏管线) 前置要求:Ch05(observation 设计)、Ch09(teacher-student 蒸馏)、Ch13(四足 Locomotion)、Ch17(视觉操作基础) 本章定位:视觉与运动控制的融合入口。Ch17 从操作角度引入了视觉管线("方块在哪里"),本章从 locomotion 角度展开("脚下的地形是什么样"),两者共享 CNN + teacher-student + visual DR 的工程核心,但在感知目标、传感器配置和部署约束上有根本差异。 阅读时间估计:精读约 5-7 小时(含动手实验),快速浏览约 2 小时。§18.7 的 extreme-parkour 案例需要 IsaacGym 或 Isaac Lab 环境。


前置自测

📋 答不出 ≥ 3 题 → 先回前置章节复习

  1. [Ch09] Teacher-student 蒸馏中,teacher 使用什么类型的 privileged 信息?student 的输入被限制为什么?为什么这种分离有助于 sim-to-real?
  2. [Ch13] 四足 locomotion 中 RaycastSensor(height scan)返回的数据格式是什么?典型的采样点数和空间分布是怎样的?
  3. [Ch17] CNN + SpatialSoftmax 的输出维度如何计算?SpatialSoftmax 相比 Global Average Pooling 保留了什么信息?
  4. [图像基础] 深度图 distance_to_image_planedistance_to_camera 有什么区别?对 CNN 的输入预处理有何影响?
  5. [物理] 为什么前视深度相机无法直接感知机器人后脚下方的地形?这对自我中心视觉策略意味着什么?
  6. [工程] Isaac Lab 的 --enable_cameras 标志是做什么的?如果不加这个标志而任务配置了相机,会发生什么?
  7. [RL 基础] DAgger(Dataset Aggregation)与 offline behavior cloning 的核心区别是什么?为什么视觉蒸馏通常更偏好 DAgger?

本章目标

学完本章后,你应该能够:

  1. 解释为什么"盲"策略(仅使用本体感知)在平坦地面上有效但在复杂地形上失败,以及视觉如何填补这个感知缺口
  2. 对比 height scan(特权地形扫描)和 egocentric depth camera(自我中心深度相机)两种地形感知范式的工程优劣
  3. 配置 Isaac Lab 的 TiledCameraCfg 和 mjlab 的 MuJoCo 相机,理解分辨率-吞吐-精度的三角权衡
  4. 设计 CNN 视觉编码器(SpatialSoftmax vs Global Pooling vs Flatten),知道不同池化策略在 locomotion 中的适用场景
  5. 实现 state teacher → depth student 的完整蒸馏管线,包括 DAgger 数据采集、MTS(Mixture of Teacher and Student)策略、loss 设计
  6. 配置视觉 Domain Randomization 的阶段化策略,理解视觉 DR 与物理 DR 的关键区别
  7. 精读 extreme-parkour 的三阶段代码结构(blind oracle → depth teacher → depth student),定位每个阶段的关键配置和训练参数
  8. 诊断视觉 locomotion 策略的典型故障——从 CNN 特征不收敛到 depth 噪声导致的步态退化

本章知识全景图

视觉感知运动控制知识树
├── 【本章核心】视觉 + Locomotion
│   ├── 地形感知范式
│   │   ├── Height Scan(特权,teacher 用)
│   │   │   └── RaycastSensor / BVH raycast
│   │   └── Egocentric Depth(可部署,student 用)
│   │       └── 前视深度相机 + CNN encoder
│   ├── CNN 编码器架构(§18.4)
│   │   ├── SpatialSoftmax(位置提取)
│   │   ├── ConvNet + GRU(时序感知)
│   │   └── Foundation Model encoder(前沿)
│   ├── Teacher-Student 三阶段管线(§18.5)
│   │   ├── Stage 1:State Teacher(privileged height scan)
│   │   ├── Stage 2:Depth Student(DAgger 蒸馏)
│   │   └── Stage 3:RGB Student(可选,加视觉 DR)
│   └── 视觉 Domain Randomization(§18.6)
│       ├── 深度噪声(高斯 + dropout)
│       ├── 相机外参扰动
│       └── 光照 / 材质(仅 RGB)
├── 【回顾桥】Ch17 视觉操作("方块在哪"→"地形如何")
├── 【前瞻】Ch19 Loco-Manipulation 视觉(操作 + 地形)
└── 代表工作
    ├── extreme-parkour(ICRA'24,§18.7 精读)
    ├── Agarwal et al. 2022(CoRL 2022, Egocentric Vision Locomotion)
    ├── Lee et al. 2020(ANYmal 两阶段 teacher-student)
    └── VIRAL(NVIDIA 2025,大规模视觉 sim-to-real)

18.1 为什么"盲"策略不够:从平地到崎岖地形 ⭐⭐

这一节解决什么问题:建立"本体感知有边界"的认知。Ch04-Ch14 的所有策略都是"盲"的——它们不用视觉就能工作。本节解释这种盲策略在什么条件下会失败,以及视觉如何填补感知缺口。

动机:Ch04-Ch14 的策略为什么不需要视觉

回顾前面章节的 locomotion 策略:Go1 的 velocity tracking(Ch13)、G1 的 humanoid walking(Ch14)、ANYmal 的四足进阶(Ch13)——它们的 actor observation 只包含本体感知信息:关节位置、关节速度、base 角速度、projected gravity、commands。没有任何视觉输入。

这些策略在平坦地面和简单崎岖地面上表现良好。原因是本体感知已经提供了足够的信息:关节角度告诉策略"腿在哪里",角速度告诉策略"身体是否稳定",projected gravity 告诉策略"哪个方向是下"。在平地上,"脚下是什么"不需要额外感知——因为脚下永远是平面。即使在 Ch13 的 rough terrain 中,策略通过 Ch09 的 teacher-student 架构获得了隐式的地形适应能力——teacher 用 height scan 看到地形,student 通过本体感知历史"猜测"地形特征。

盲策略的失败边界

但盲策略有一个物理性的感知边界:它只能"反应",不能"预测"

考虑以下场景:

场景 盲策略表现 失败原因
平坦地面 ✅ 正常行走 无需预测
随机粗糙地面 ✅ 自适应步态 本体感知历史够用(Ch09 adaptation)
已知的均匀台阶 ⚠️ 勉强可行 需要足够长的历史才能"猜到"台阶高度
不规则障碍物 ❌ 频繁绊倒 无法预测前方障碍的高度和位置
大缺口(gap jump) ❌ 直接掉入 必须提前看到缺口才能跳跃
高障碍(2× 体高) ❌ 撞上障碍 必须提前看到障碍才能调整步态

失败的根本原因在于信息延迟:盲策略只有在脚碰到障碍物后才能"感知"到它——此时已经太晚了。视觉提供了"预测性感知"——在障碍物还在远处时就知道它的形状和位置,让策略有时间调整步态和轨迹。

用一个人类的类比:闭着眼睛走平路完全没问题。闭着眼睛走楼梯——如果你知道是楼梯,可以通过脚的触觉反馈逐步适应。但闭着眼睛跑酷绝对不行——你必须看到前方的障碍才能决定是跳过还是绕开。机器人 locomotion 面临完全相同的信息瓶颈。

这个类比还可以进一步延伸:即使闭着眼睛走楼梯,你的表现也远不如睁眼——你会更慢、更谨慎、更容易绊倒。这正是"盲 student"(Ch09 的纯本体感知策略)和"视觉 student"(本章的 depth student)的区别——前者可以适应简单地形但速度慢且保守,后者可以像睁眼跑步一样自信地通过复杂地形。从工程数据看:extreme-parkour 的 depth student 可以在 Unitree A1 上实现 2× 体高的跳跃,而同等条件下的盲策略甚至无法可靠地通过 0.5× 体高的台阶。

视觉填补了什么信息缺口

视觉为 locomotion 策略提供了三类关键信息,每类信息对应不同的控制决策:

第一类:前方地形几何。 "前面 1-2 米的地面是平的还是有台阶?有多高?有多宽?"这类信息决定了步态的选择——平地用正常步态,上台阶用抬腿步态,大缺口用跳跃步态。height scan(特权)和 depth camera(可部署)都提供这类信息,但分辨率和噪声特性不同。

第二类:脚下地形细节。 "当前脚底接触的地面坡度是多少?摩擦力大概多大?"这类信息影响单步内的力控制。前视深度相机看不到脚正下方的地形(视野盲区),必须依赖本体感知历史或腹部/下方相机。这是自我中心视觉的一个根本限制。

第三类:障碍物的语义类型。 "前面的东西是可以踩的台阶还是不能碰的玻璃门?"RGB 相机可以提供这类语义信息,但 depth 相机只看到几何形状。对于 parkour 等纯几何任务,depth 已经足够;对于需要语义理解的导航任务(如区分可通行和不可通行区域),RGB 或语义分割是必要的。

三种视觉模态的工程对比

模态 提供的信息 不提供的信息 sim-to-real 难度 计算成本
Depth 几何形状、距离 颜色、材质、语义 低(不受光照影响)
RGB 颜色、纹理、语义 精确距离 高(光照/材质敏感)
Segmentation 语义标签 形状细节 不可部署(仿真特有)
Height Scan 精确地形高度 视觉外观 不可部署(需要特权) 极低

本质洞察:对 locomotion 视觉控制,depth 是性价比最高的模态——它提供了 locomotion 最需要的几何信息(地形高度、障碍物距离),同时 sim-to-real gap 最小(深度不受光照和材质影响)。这就是为什么 extreme-parkour、ANYmal perceptive locomotion 等工作都以 depth camera 作为主要外感受传感器。RGB 只在需要语义理解时才引入。(注意 RMA 属于另一类工作——它的核心是基于本体感知历史在线适应的 adaptation module,部署侧主要依赖 proprioception,并非以 depth camera 为主的视觉 locomotion,不应归到 depth-vision 代表工作里。)

视觉 locomotion 的三代演进

第一代(2016-2019):elevation mapping + foothold planning。 用 LiDAR 或 stereo camera 构建高程地图(elevation map),然后用规划算法计算落脚点。代表工作:ANYmal with elevation mapping (Fankhauser et al., 2018)。优势:几何精确、可解释。局限:构建高程地图计算量大、对传感器噪声敏感、无法处理动态环境。

第二代(2020-2022):RL + height scan teacher → proprioceptive student。 用 RL 训练一个使用 height scan(特权地形信息)的 teacher 策略,再蒸馏到只用本体感知历史的 student。代表工作:Lee et al. 2020 (Science Robotics)、RMA (Kumar et al., RSS 2021)。优势:不需要视觉传感器就能部署、对传感器噪声免疫。局限:student 的"盲"特性让它无法处理需要预测性感知的复杂地形。

第三代(2023-至今):RL + height scan teacher → depth student。 仍然用 height scan teacher,但 student 使用 depth camera 而非纯本体感知。depth student 既有预测性感知(看到前方地形)又有部署可行性(depth camera 便宜且对光照鲁棒)。代表工作:Agarwal et al. 2022 (CoRL)、extreme-parkour (Cheng et al., ICRA 2024)。优势:能处理 gap jump、high obstacle 等需要预测性感知的场景。局限:前视相机有视野盲区(看不到脚下),depth sensor 有噪声和遮挡问题。

本章聚焦于第三代方法——这也是当前工业界和学术界的主流范式。

视觉 locomotion 关键时间节点:

2018 — Fankhauser et al.: ANYmal elevation mapping(第一代标杆)
2020 — Lee et al.: 两阶段 teacher-student(Science Robotics,第二代标杆)
       → 提出 height scan teacher + proprioceptive student 的经典范式
2021 — Kumar et al.: RMA(RSS)
       → 在线 adaptation module 替代离线 teacher-student
2022 — Agarwal & Kumar: Egocentric Vision Locomotion
       → 首次用单个前视 depth camera 替代 LiDAR
2023 — Cheng et al.: extreme-parkour(CoRL Workshop Oral)
       → 2× 体高跳跃、缺口穿越,只用 depth camera
2024 — extreme-parkour(ICRA 正会)
       → 代码开源,成为视觉 parkour 的标准参考
2024 — PIE(Luo et al.)、Agile-but-Safe(Hoeller et al.)
       → 更安全的视觉 locomotion,加入安全约束
2025 — VIRAL(NVIDIA)
       → 大规模视觉 sim-to-real(64 GPUs,humanoid)
2025 — Multi-View Depth(arXiv 2511.22744)
       → 多视角 depth 解决单视角盲区问题
2026 — TTT-Parkour
       → 用真实场景 3D 重建做 test-time training
2025 — LocoMamba(arXiv 2025-08;期刊卷期 2026)
       → Mamba 替代 CNN+GRU,序列建模新范式

这条时间线清晰展示了"感知能力"如何逐步提升:从 LiDAR 高程地图(2018)→ 特权 height scan + 盲 student(2020)→ depth camera student(2022)→ depth parkour(2023)→ 大规模视觉 sim-to-real(2025)→ Foundation Model + test-time adaptation(2026)。每一步都在"降低对特权信息的依赖"同时"提高可部署的感知能力"。

第一代到第三代的核心转变量化:

维度 第一代(Elevation Map) 第二代(Blind Student) 第三代(Depth Student)
传感器 LiDAR($5000+) 无($0) Depth camera($50-200)
计算 CPU 密集(SLAM) 轻量(MLP only) 中等(CNN + MLP)
地形能力 平坦+台阶 平坦+粗糙 平坦+台阶+缺口+高障碍
sim-to-real 困难(建图精度) 容易(无视觉) 中等(depth DR)
代表硬件 ANYmal ($50k+) Go1 ($2700) A1/Go1 ($2700) + 廉价相机

⚠️ 常见陷阱

⚠️ 编程陷阱:把 height scan 的训练结果当成视觉策略的性能上限

Height scan teacher 使用完美的地形信息(无噪声、无遮挡、全视野),其性能是一个理论上限。但 depth student 面对的是有噪声、有遮挡、有限视野的真实图像——性能必然低于 teacher。如果你的 depth student 和 height scan teacher 性能一样好,应该怀疑 student 是否泄漏了特权信息(而非视觉真的学得完美)。

💡 概念误区:认为"视觉 = RGB"

在 locomotion 领域,depth 才是主力模态。RGB 引入了大量与 locomotion 无关的信息(颜色、纹理、光照),增加了 sim-to-real 的难度但几乎不增加有用信息(地形几何完全由 depth 决定)。只有在需要语义区分(如区分可通行/不可通行区域)时才需要 RGB。先做好 depth,确认 sim-to-real 可行,再按需考虑 RGB。

🧠 思维陷阱:认为"视觉策略一定比盲策略好"

在简单地形(平地、均匀粗糙面)上,视觉策略可能反而不如盲策略——因为视觉引入了额外的噪声源(相机抖动、depth artifact),而这些场景本体感知已经足够。视觉的价值在于它扩展了策略能处理的地形复杂度上限。不要为了"看起来更先进"而在不需要视觉的场景中强加视觉。

练习

  1. [分析题] 列出 3 个盲策略可以胜任的 locomotion 场景和 3 个必须使用视觉的场景。对每个场景,说明关键的信息缺口是什么。
  2. [设计题] 如果你要为一个四足机器人设计视觉系统来通过一个室内走廊(有台阶、有门槛、有地面障碍物),你会选择什么模态?相机安装在哪里?分辨率设为多少?给出工程理由。
  3. [估算题] 一个 depth camera 的帧率为 30 Hz,机器人移动速度为 1 m/s。在两帧之间机器人移动了多远?这对 CNN 的时序感知有什么影响?

18.2 两种地形感知范式:Height Scan vs Egocentric Depth ⭐⭐

这一节解决什么问题:Teacher 用 height scan,student 用 depth camera——这两种地形感知方式在工程上有什么本质区别?如何从一种过渡到另一种?

Height Scan:Teacher 的特权感知

Height scan(也称 scandots、terrain scan、elevation probe)是一种只在仿真中可用的特权地形感知方式。它通过从机器人底部向下发射一组"射线"(raycast),测量每条射线与地面的交点高度,得到一个以机器人为中心的地形高度图。

mjlab 中的 height scan 实现:mjlab 使用 MuJoCo Warp 的 BVH(Bounding Volume Hierarchy)raycast。配置方式:

# mjlab: height scan 配置(在 observation manager 中)
height_scan = RaycastSensorCfg(
    prim_path="/World/Robot/base_link",
    offset=RaycastSensorCfg.OffsetCfg(pos=(0.0, 0.0, 0.0)),
    attach_yaw_only=True,      # 只跟随 yaw,不跟随 pitch/roll
    pattern_cfg=GridPatternCfg(
        resolution=0.1,         # 网格分辨率 10cm
        size=(1.6, 1.0),       # 前方 1.6m × 左右 1.0m
    ),
    max_distance=100.0,         # 最大探测距离
)

这个配置在机器人前方 1.6m × 左右 1.0m 的区域内生成一个规则网格,典型的采样点数为 \(16 \times 10 = 160\)\(21 \times 11 = 231\) 个点。每个点返回一个高度值(相对于机器人 base 的海拔差),整个 height scan 的 observation 维度等于采样点数。

Isaac Lab 中的 height scan 实现:Isaac Lab 使用 RayCasterCfg(不是 RaycastSensorCfg),其 line tracing 直接在 Warp 中对指定的静态 mesh 完成(需指定 mesh_prim_paths),不是 PhysX raycast。当前推荐 ray_alignment="yaw"(旧的 attach_yaw_only 已弃用但仍兼容)。配置语法与 mjlab 的 RayCastSensorCfg 类似但命名略有不同。

# Isaac Lab: height scan 配置
from isaaclab.sensors import RayCasterCfg, patterns

height_scanner = RayCasterCfg(
    prim_path="{ENV_REGEX_NS}/Robot/base",
    offset=RayCasterCfg.OffsetCfg(pos=(0.0, 0.0, 20.0)),  # 从高处向下
    attach_yaw_only=True,
    pattern_cfg=patterns.GridPatternCfg(
        resolution=0.1,
        size=[1.6, 1.0],
    ),
    debug_vis=False,
    mesh_prim_paths=["/World/ground"],  # 指定 raycast 的目标 mesh
)

注意 Isaac Lab 的 offset.pos 中 z=20.0——这是射线的起始高度,不是相机高度。射线从 20m 高处向下发射,确保能命中任何地形。返回值是射线与地形的交点高度(世界坐标系),需要减去机器人 base 高度才能得到"相对于 base 的地形高度差"。

Height scan 数据的预处理

def preprocess_height_scan(raw_heights, base_height):
    """从原始 raycast 高度计算 obs 格式的地形高度差。

    Args:
        raw_heights: [B, num_points] 世界坐标系的地形高度
        base_height: [B, 1] 机器人 base 的当前高度
    Returns:
        height_scan_obs: [B, num_points] 相对于 base 的高度差
    """
    # 相对高度 = 地形高度 - base 高度
    # 正值 = 地形高于 base(台阶向上)
    # 负值 = 地形低于 base(缺口/台阶向下)
    # 零值 = 地形与 base 齐平(平地)
    height_scan_obs = raw_heights - base_height

    # 裁剪到合理范围(避免极值影响 MLP)
    height_scan_obs = height_scan_obs.clamp(-1.0, 1.0)

    return height_scan_obs

Height scan 的计算成本:Height scan 只是 raycast——在 GPU 上,4096 个环境 × 200 个射线 = 819,200 次射线查询。MuJoCo Warp 的 BVH raycast 可以在 < 1ms 内完成这些查询。相比之下,渲染 4096 个 64×64 的 depth 图需要 10-50ms。这就是为什么 Phase 1(使用 height scan)的训练吞吐远高于 Phase 2(使用 depth 渲染)——physics + raycast 远快于 physics + rendering。

Height scan 的采样模式:除了规则网格(GridPatternCfg),还可以使用以下模式:

模式 描述 适用场景
规则网格 均匀分布的矩形网格 通用默认(extreme-parkour 使用)
极坐标网格 以机器人为中心的放射状分布 近处密集、远处稀疏
脚底局部 只在 4 个脚掌下方密集采样 需要精确落脚点信息
混合模式 前方网格 + 脚底局部 兼顾远处地形和脚下细节

极坐标网格的优势在于"近处信息更重要"——机器人即将踩到的地面比 2m 外的地面对当前步态决策更关键。近处密集采样提供了更好的空间分辨率,而远处稀疏采样减少了总采样点数。但规则网格更简单也更常用。

Height scan 的关键特性

特性 描述 工程意义
完美信息 无噪声、无遮挡、精确到 mm teacher 的性能上限就是任务的理论上限
全局可见 前后左右脚下都能看到 不存在视野盲区
低维 160-231 个标量 直接拼入 obs 向量,无需 CNN
仿真专有 真机上不存在这种"上帝视角" 只能用于 teacher,不能部署
坐标系跟随 attach_yaw_only=True 让网格只跟随 yaw 避免机器人 pitch/roll 时扫描区域偏移

Egocentric Depth Camera:Student 的可部署感知

Egocentric depth camera 是部署时可用的感知方式——一个安装在机器人前方的深度相机,输出一张深度图。

用一个日常类比来理解 height scan 和 egocentric depth 的区别:height scan 就像在房间里开灯——你瞬间看到整个房间的所有角落。egocentric depth 就像在黑暗中用手电筒——你只能看到手电筒照到的方向,身后和脚下都是黑暗的。用手电筒走路需要不断转头(或者记住之前照过的地方),而开灯走路不需要这些额外的认知负担。机器人的 height scan teacher 享受的是"开灯"的待遇,而 depth student 必须学会用"手电筒"高效导航。

与 height scan 的对比:

维度 Height Scan Egocentric Depth
数据维度 ~200 个标量 H×W 个像素(如 64×64=4096)
信息类型 地形高度(相对于 base) 像素到相机平面的距离
视野 全方向(可配置) 前方锥形视野(FOV 依赖)
脚下信息 ✅ 直接可得 ❌ 前视相机看不到脚下
噪声 有(传感器噪声、边缘 artifact)
遮挡 有(自己的身体遮挡脚下区域)
计算成本 极低(raycast) 高(渲染 + CNN 推理)
可部署

自我中心深度的根本挑战:脚下盲区。 前视深度相机只能看到前方的地形。当机器人走过一个台阶时,前脚看到台阶边缘(在视野内),但后脚踩到台阶时台阶已经在视野之外了。这意味着 depth student 必须"记住"之前看到的地形——要么通过 CNN 的时序记忆(如 GRU/LSTM),要么通过帧堆叠(stack 最近几帧 depth),要么通过本体感知历史(关节角度的时序变化隐含了已走过的地形信息)。

extreme-parkour 的解决方案是 ConvNet + GRU:CNN 编码当前帧的空间特征,GRU 维护时序记忆。这比简单的帧堆叠更参数高效——帧堆叠把 4 帧 depth 堆成 4-channel 输入,CNN 需要学习跨帧的对应关系;GRU 则显式地用隐状态编码了"我之前看到过什么"。

从 Height Scan Teacher 到 Depth Student 的信息传递

Teacher-student 蒸馏的核心在于"信息传递":teacher 看到了什么,student 如何从视觉中恢复同样的信息?

Teacher 的信息:
  height_scan[i] = terrain_height(robot_pos + scan_offset[i]) - base_height
  → 直接知道"前方 1m 处地面高出 base 多少"

Student 的信息:
  depth_image[u, v] = distance_to_plane(camera, pixel(u,v))
  → 只知道"图像 (u,v) 处的像素对应多远的东西"
  → 需要 CNN 学会:(1) 从 depth 中提取地形轮廓
  →                (2) 将图像坐标转换为 base 坐标系
  →                (3) 估计遮挡区域的地形(靠记忆)

这三步信息转换是 CNN 需要隐式学习的——蒸馏 loss 不显式要求 CNN 完成这些转换,但 teacher 的动作只有在 student 成功完成这些转换后才可能被模仿。

本质洞察:Teacher-student 视觉蒸馏的本质不是"学会模仿 teacher 的动作"——而是"学会从 depth 图像中提取与 height scan 等价的地形信息"。动作模仿只是一个代理任务,真正的学习目标是感知能力的迁移。如果 CNN 成功学会了提取地形信息,它的动作自然就会接近 teacher。

用一个教育类比来理解:想象一个开卷考试(teacher,所有答案都在参考书里)和一个闭卷考试(student,只能靠记忆)。开卷考试容易得高分——但你不一定真正理解了内容。闭卷考试要求你把参考书的知识内化——这正是 depth student 需要做的事情:把 height scan 的"精确地形数据"内化为"从 depth 图像中提取地形特征的能力"。蒸馏的 MSE loss 相当于"对照答案改卷"——给 student 一个明确的学习目标,比让 student 自己摸索(end-to-end RL)高效得多。

另一个有用的类比是"翻译":height scan 是一种"语言"(200 维的高度向量),depth 图像是另一种"语言"(64×64 的像素矩阵),两者描述的是同一个"现实"(前方的地形)。CNN 扮演的角色是"翻译员"——它需要学会把 depth "语言"翻译成策略网络能理解的"语言"(与 height scan 等价的特征向量)。蒸馏提供了"平行语料"(同一场景的 depth 图和 height scan teacher 的动作),让翻译员通过大量对照例子学会翻译。

⚠️ 常见陷阱

⚠️ 编程陷阱:Height scan 的 attach_yaw_only 设置错误

如果设为 False,height scan 网格会随机器人的 pitch 和 roll 一起旋转——当机器人在斜面上倾斜时,扫描区域不再水平,返回的高度值包含了机器人倾斜的偏差。Teacher 学到的策略会依赖这个偏差(把身体倾斜当成"地形变化"的信号),导致蒸馏到 depth student 后行为异常。正确设置为 True——网格只跟随 yaw,始终保持水平。

💡 概念误区:认为"depth student 的性能应该接近 height scan teacher"

Height scan teacher 有完美信息(无噪声、全视野),depth student 有信息损失(噪声、遮挡、有限视野)。在复杂地形上,student 的成功率低于 teacher 20-40% 是正常的。如果差距更大(>50%),问题在蒸馏管线(CNN 没学会、DAgger 没做好)。如果差距几乎为零,问题在 privileged 泄漏。

⚠️ 编程陷阱:Depth 相机的近裁剪面太大

如果 depth camera 的 clipping_range = (0.3, 10.0),那么距相机 30cm 以内的物体返回 depth=0(或 NaN)。当机器人接近障碍物时,障碍物进入近裁剪区域"消失"——CNN 会误以为前方没有障碍。正确做法:将近裁剪面设为尽可能小(如 0.01m),或在 depth 预处理中将近裁剪区域的像素标记为"近距离障碍"。

练习

  1. [计算题] 一个 height scan 配置为 size=(2.0, 1.0), resolution=0.1。计算总采样点数。如果 observation 中还包含 48 维 proprioception,总 obs 维度是多少?
  2. [设计题] 你的机器人有两个深度相机:一个前视(看前方地形),一个下视(看脚下地形)。设计一个 observation group 配置,把两个相机的 depth 图和 proprioception 融合在一起。画出数据流图。
  3. [分析题] 为什么 extreme-parkour 选择了 ConvNet + GRU 而非简单的 4-frame 帧堆叠?从参数效率和时序建模能力两个角度分析。

Depth 图的预处理工程:从原始像素到 CNN 输入

Depth 图的预处理看起来简单("就是归一化嘛"),但工程细节直接影响 CNN 的学习效果。以下是从原始 depth 到 CNN 输入的完整处理流程:

Step 1:格式转换。 仿真器输出的 depth 数据可能是 float32(单位:m)或 uint16(单位:mm,需要除以 1000.0)。在 mjlab 中,CameraObsCfgdata_type="depth" 输出 float32 米制数据。在 Isaac Lab 中,TiledCamera.data.output["distance_to_image_plane"] 输出 float32 米制数据。

Step 2:裁剪(Clipping)。 将 depth 值限制到有效范围 \([d_{\text{near}}, d_{\text{far}}]\)。近裁剪面以内的值(如手爪挡住相机)设为 \(d_{\text{near}}\),远裁剪面以外的值(如天空)设为 \(d_{\text{far}}\)。对 locomotion,典型范围是 \([0.01, 3.0]\) m——3m 以外的地形对即时步态决策不重要。

Step 3:归一化。 线性映射到 \([0, 1]\)

\[d_{\text{norm}} = \frac{d - d_{\text{near}}}{d_{\text{far}} - d_{\text{near}}}\]

归一化后,\(d_{\text{norm}} = 0\) 表示"最近的可见距离",\(d_{\text{norm}} = 1\) 表示"最远的可见距离/无障碍"。这个值域与 CNN 的典型输入范围兼容。

如果不做归一化会怎样?原始 depth 值域 0.01-3.0 m,与 proprioception 值域(关节角度 -2~+2 rad,角速度 -10~+10 rad/s)差异不大——但 CNN 的权重初始化假设输入均值 ~0、方差 ~1。如果 depth 值域偏移严重,CNN 的初始 feature map 可能饱和或接近零,导致梯度消失。

Step 4:无效像素处理。 真实 depth sensor 的某些像素没有有效深度值(反射面、边缘空洞)。仿真中这些像素可能返回 0、NaN 或 \(d_{\text{far}}\)。统一处理策略:将所有无效像素替换为 \(d_{\text{far}}\)(视为"无障碍"),然后归一化。

def preprocess_depth(depth_raw, near_clip=0.01, far_clip=3.0):
    """Depth 预处理管线:从原始渲染输出到 CNN 输入。

    Args:
        depth_raw: [B, H, W, 1] float32, 单位 m
    Returns:
        depth_processed: [B, 1, H, W] float32, 范围 [0, 1]
    """
    # HWC → CHW
    depth = depth_raw.permute(0, 3, 1, 2)  # [B, 1, H, W]

    # 替换无效值(NaN, Inf, 负数)
    invalid_mask = torch.isnan(depth) | torch.isinf(depth) | (depth < 0)
    depth = depth.clone()
    depth[invalid_mask] = far_clip

    # 裁剪
    depth = depth.clamp(near_clip, far_clip)

    # 归一化到 [0, 1]
    depth = (depth - near_clip) / (far_clip - near_clip)

    return depth

Step 5:降采样(可选)。 如果原始分辨率(如 640×480)高于训练分辨率(如 64×64),用 F.interpolate 降采样。对 depth 图,使用 mode="bilinear"mode="nearest"——bilinear 更平滑但可能在物体边缘产生"中间深度"的伪影(本来是台阶边缘的尖锐跳变,降采样后变成了渐变),nearest 保留跳变但引入锯齿。对 locomotion,bilinear 通常足够——台阶边缘的精确像素位置不如 "台阶大概在多远" 的全局信息重要。

多相机策略:解决自我中心视觉的盲区

前视深度相机的最大局限是脚下盲区——机器人看不到正在踩的地面。几种解决方案:

方案一:纯前视 + 本体感知历史。 extreme-parkour 的方案——只用前视相机,通过 GRU 的时序记忆和本体感知历史(关节角度变化隐含了已走过地形的信息)来补偿盲区。优点:只需一个相机,硬件简单。缺点:在快速移动时 GRU 可能"忘记"之前看到的地形。

方案二:前视 + 下视双相机。 在机器人腹部加一个朝下的深度相机,直接看脚下地形。优点:消除盲区,不需要依赖记忆。缺点:多一个相机意味着多一倍的渲染成本,且两个相机的特征需要融合。

方案三:前视 + 后视。 在机器人背部加一个朝后的深度相机。对 parkour 不太需要(不需要看后面),但对避障导航有用(避免后退时碰撞)。

方案四:全景深度。 使用 360° 深度传感器(如旋转 LiDAR 的低精度版本)。提供完整的环境感知,但计算成本极高。

多相机融合的工程实现:

# 双相机(前视 + 下视)的 observation 配置
class DualCameraObsCfg:
    front_camera = CameraObsCfg(
        camera_name="front_depth",
        data_type="depth",
        height=64, width=64,
    )
    belly_camera = CameraObsCfg(
        camera_name="belly_depth",
        data_type="depth",
        height=32, width=32,  # 下视可以更低分辨率
    )

# 双相机 CNN 融合
class DualCameraEncoder(nn.Module):
    def __init__(self):
        super().__init__()
        self.front_cnn = SmallCNN(in_channels=1, out_dim=64)
        self.belly_cnn = SmallCNN(in_channels=1, out_dim=32)
        # 两个 CNN 的特征拼接后送入 MLP

    def forward(self, front_depth, belly_depth):
        front_feat = self.front_cnn(front_depth)  # [B, 64]
        belly_feat = self.belly_cnn(belly_depth)   # [B, 32]
        return torch.cat([front_feat, belly_feat], dim=-1)  # [B, 96]

最近的 "Beyond Egocentric Limits" (arXiv 2511.22744) 工作证明,多视角 depth 可以显著提高四足机器人在复杂地形上的鲁棒性——特别是在侧向障碍物和后方威胁的场景中。

视觉输入的时序处理策略

单帧 depth 只有空间信息,没有时序信息。三种添加时序信息的方法:

方法 实现 参数效率 时序建模能力 推理延迟
帧堆叠 stack 最近 N 帧为 N-channel 输入 低(CNN 输入通道 ×N) 有限(只有 N 帧窗口)
帧差分 当前帧 - 前一帧 高(只增加 1 channel) 只有一阶速度信息 极低
RNN(GRU/LSTM) CNN 特征经 RNN 处理 高(RNN 参数与历史长度无关) 强(理论上无限历史)

帧堆叠的工程实现

# 帧堆叠:维护一个 FIFO 缓冲区
class FrameStack:
    def __init__(self, num_frames=4, shape=(1, 64, 64)):
        self.buffer = torch.zeros(num_frames, *shape)
        self.num_frames = num_frames

    def push(self, frame):
        """添加新帧,丢弃最旧的帧"""
        self.buffer = torch.cat([self.buffer[1:], frame.unsqueeze(0)])

    def get(self):
        """返回堆叠的帧 [num_frames, H, W]"""
        return self.buffer  # CNN 输入 channels = num_frames

帧差分的物理意义:对 locomotion,帧差分编码了"场景在如何变化"——如果机器人在向前走,帧差分图中近处物体向下移动(在图像中"变大")、远处物体几乎不动。CNN 可以从帧差分中隐式估计机器人的前进速度和地形的接近速率。对比操作任务(Ch17 讨论的帧差分用于检测"方块是否在移动"),locomotion 的帧差分更关注"整个场景的光流"。

GRU vs LSTM 的工程选择:GRU 参数量约为 LSTM 的 2/3(GRU 没有 cell state 和 forget gate),推理速度更快。对 locomotion 视觉编码,GRU 通常足够——极少有需要 LSTM 的长期记忆能力的场景。extreme-parkour 使用 GRU。


18.3 双框架视觉配置:相机传感器与渲染管线 ⭐⭐

这一节解决什么问题:如何在 mjlab 和 Isaac Lab 中配置深度相机?两个框架的渲染管线有什么工程差异?分辨率和环境数如何权衡?

Isaac Lab 的 TiledCamera:RTX 加速渲染

Isaac Lab 的视觉能力建立在 NVIDIA Omniverse 的 RTX 渲染器之上。其核心组件是 TiledCamera——它将多个环境的相机输出"拼接"(tile)到一张大的 GPU framebuffer 中,避免了逐个环境渲染的开销。

TiledCameraCfg 的完整配置

# Isaac Lab: 前视深度相机配置
from isaaclab.sensors import TiledCameraCfg
from isaaclab.sensors.camera import CameraCfg

front_depth_camera = TiledCameraCfg(
    prim_path="{ENV_REGEX_NS}/Robot/base_link/front_camera",
    offset=CameraCfg.OffsetCfg(
        pos=(0.3, 0.0, 0.05),        # 前方 30cm,高度 5cm
        rot=(0.5, -0.5, 0.5, -0.5),  # 朝前方看(ROS 约定)
        convention="ros",              # 坐标系约定
    ),
    spawn=PinholeCameraCfg(
        focal_length=1.93,             # 焦距 mm
        horizontal_aperture=3.6,       # 水平孔径
        clipping_range=(0.01, 5.0),   # 近/远裁剪面 (m)
    ),
    width=64,                          # 图像宽度
    height=64,                         # 图像高度
    data_types=["distance_to_image_plane"],  # 深度图类型
    update_period=0.02,                # 更新周期 50Hz
)

关键参数解释

prim_path 使用 {ENV_REGEX_NS} 占位符——Isaac Lab 在创建多个环境时自动将其替换为每个环境的 prim 路径前缀。这是 tiled rendering 的基础:所有环境的相机共享同一个 render_product,通过 tile 索引区分。

data_types 支持多种模态——"rgb"(uint8, B×H×W×3)、"distance_to_image_plane"(float32, B×H×W×1,Z 轴距离)、"distance_to_camera"(float32, B×H×W×1,欧几里德距离)、"normals"(float32, B×H×W×3)、"instance_segmentation_fast"(uint32)。对 locomotion,通常只需要 "distance_to_image_plane"

"distance_to_image_plane" vs "distance_to_camera" 的区别:前者是像素到相机平面的 Z 轴投影距离(平面平行于相机传感器),后者是像素到相机原点的欧几里德距离。对 CNN 来说区别不大(CNN 可以学会处理任一表示),但前者更常用——因为它与真实 depth sensor(如 RealSense)的输出格式一致。

Isaac Lab 渲染模式与性能

渲染模式 DLSS 抗锯齿 适用场景 吞吐(512 cams, RTX 4090)
performance ✅ 开启 DLSS 训练(最快) ~20k-60k FPS
balanced ✅ 开启 DLSS 中等质量 ~10k-30k FPS
quality ✅ 开启 DLAA + DL denoiser 评估/视频 ~5k-15k FPS

Isaac Lab 渲染的工程约束: - 必须加 --enable_cameras 才能激活渲染管线 - RTX 4090 上推荐最多 512 个相机环境(内存瓶颈) - 每个 tile 分辨率 ≥ 100×100 时 DLSS 才有效;< 100×100 建议用 DLAA - 渲染开销约为纯物理仿真的 5-20 倍(取决于分辨率和环境数)

相机坐标系约定(OffsetCfg.convention)。 Isaac Lab 支持三种坐标系约定:

约定 X 轴 Y 轴 Z 轴 典型使用场景
"ros" 机器人(RealSense 默认)
"opengl" 图形学(Blender、MuJoCo)
"world" 世界 X 世界 Y 世界 Z 全局坐标

对 locomotion 视觉,推荐使用 "ros" 约定——它与真实 depth sensor(RealSense D435)的坐标系一致,减少部署时的坐标转换工作。如果使用 "opengl" 约定(MuJoCo 默认),部署时需要额外的坐标翻转(Y 轴取反、Z 轴取反)。

OffsetCfg 的 rot 参数是四元数 (w, x, y, z) 格式——不是 (x, y, z, w)。这是一个极易出错的地方。如果你用 scipy 的 Rotation.as_quat() 计算旋转,scipy 默认输出 (x, y, z, w),需要手动转换为 Isaac Lab 的 (w, x, y, z) 格式。

# 正确的相机朝前旋转(ros 约定)
# 相机光轴指向机器人前方(+X 方向)
from scipy.spatial.transform import Rotation as R
rot = R.from_euler("XYZ", [0, -90, 0], degrees=True)  # 绕 Y 轴旋转 -90°
quat_xyzw = rot.as_quat()  # scipy: [x, y, z, w]
quat_wxyz = [quat_xyzw[3], quat_xyzw[0], quat_xyzw[1], quat_xyzw[2]]
# Isaac Lab OffsetCfg 需要 (w, x, y, z) 格式

如果相机朝向配置错误(如朝上而非朝前),depth 图中看到的是天花板而非地形——策略会在完全无用的视觉输入上训练。这个 bug 非常隐蔽:网络不会报错,depth 值看起来也"合理"(天花板的 depth 在 2-3m 范围内),但策略完全学不到有用信息。自检方法:保存一帧 depth 图,确认图像中能看到地面地形而非天花板/墙壁。

Isaac Lab 的渲染输出数据流

环境创建时:
  TiledCameraCfg → Isaac Lab 自动创建 N 个相机 prim
  → Omniverse RTX 渲染器初始化 → 分配 GPU framebuffer

每个仿真步:
  PhysX 物理更新 → 刚体位姿更新
  → RTX 渲染所有相机到 tiled framebuffer
  → TiledCamera.data.output["distance_to_image_plane"]
  → [num_envs, H, W, 1] float32 tensor(已在 GPU 上)
  → 直接送入 CNN(无需 CPU-GPU 数据传输)

这个"GPU 上的全链路"是 Isaac Lab tiled rendering 的关键性能优势——depth 图从渲染到 CNN 输入全程在 GPU 上,没有 CPU-GPU 数据传输的开销。相比之下,如果使用非 tiled 的 Camera class,每个环境的图像需要单独从 GPU 拷贝到 CPU 再拷回 GPU,这在 512 个环境时会成为严重的瓶颈。

mjlab 的 MuJoCo Warp Batch Renderer

mjlab 的视觉能力基于 MuJoCo Warp 的批量渲染器。相机在 MJCF 中声明,通过 observation manager 暴露:

<!-- MJCF: 在机器人 base_link 上挂载前视深度相机 -->
<camera name="front_depth"
        pos="0.3 0.0 0.05"
        xyaxes="0 -1 0 0.5 0 0.866"
        fovy="60"
        mode="fixed"/>

MJCF 相机的 xyaxes 属性定义了相机的朝向——前三个数字是相机 X 轴(右方向),后三个数字是相机 Y 轴(上方向),Z 轴(光轴方向)由右手定则自动确定。mode="fixed" 表示相机固定在 body 上随之运动。如果设为 mode="targetbody",相机会自动追踪目标 body——这对第三人称视角(如调试可视化)有用,但对部署时的前视相机应该用 "fixed"

fovy="60" 定义了垂直方向的视野角。水平 FOV 由 fovy 和图像宽高比自动计算:\(\text{fovx} = 2 \cdot \arctan(\text{aspect\_ratio} \cdot \tan(\text{fovy}/2))\)。对 64×64 的正方形图像,fovx = fovy = 60°。

# mjlab: 在 env_cfg.py 中配置相机 observation
camera_obs = CameraObsCfg(
    camera_name="front_depth",
    data_type="depth",           # "depth", "rgb", "segmentation"
    height=64,
    width=64,
    normalize=True,              # 归一化到 [0, 1]
    clip_range=(0.01, 5.0),     # 裁剪范围
)

mjlab vs Isaac Lab 视觉能力对比

维度 mjlab (MuJoCo Warp) Isaac Lab (RTX)
渲染后端 MuJoCo Warp GPU rasterizer NVIDIA RTX ray-tracer
RGB 质量 中等(光栅化,无全局光照) 高(光线追踪,PBR 材质)
Depth 质量 高(几何精确) 高(几何精确)
吞吐 高(轻量 rasterizer) 中等(RTX 更重但有 DLSS)
视觉 DR 基础(颜色/纹理手动替换) 丰富(Replicator API + MDL 材质)
推荐场景 Depth-only locomotion RGB sim-to-real、需要 PBR 材质

工程建议:对 depth-only 的 locomotion 视觉任务(本章的核心场景),mjlab 和 Isaac Lab 的 depth 质量差异很小——depth 图是纯几何信息,不受渲染质量影响。但如果需要 RGB 视觉或丰富的视觉 DR(纹理/光照/材质随机化),Isaac Lab 的 RTX 渲染器和 Replicator API 明显更强。先在 mjlab 中用 depth 验证管线正确性(更快迭代),再在 Isaac Lab 中做 RGB 版本和视觉 DR。

分辨率-吞吐-精度的三角权衡

视觉 RL 训练中,分辨率的选择是一个工程权衡:

分辨率 地形细节 计算成本 典型 num_envs 收敛速度
32×32 粗糙(1 像素 ≈ 几 cm) 2048-4096
64×64 中等(适合大多数任务) 512-2048
128×128 精细 128-512
256×256 极精细 极高 64-128 极慢

工程经验法则:对 locomotion parkour,64×64 的 depth 图足以捕获台阶边缘、缺口宽度、障碍物轮廓等关键几何特征。extreme-parkour 使用 \(58 \times 87\) 的分辨率(因为 Unitree A1 相机的原始比例)。如果你的任务不需要精细的纹理区分(如区分草地 vs 砾石),32×32 甚至更低的分辨率就够了。

一个反直觉的事实:降低分辨率通常不会显著降低 locomotion 视觉策略的性能,但会显著提高训练吞吐——因为渲染成本随像素数线性增长,而 locomotion 需要的地形信息在低分辨率下就已经足够。但如果分辨率低到 16×16,台阶边缘可能只有 1-2 个像素,CNN 难以可靠地检测。

视觉 Observation 与 Proprioception 的融合

视觉策略的 observation 不只有图像——还包含 proprioception(关节状态、base 角速度等)。两者需要融合后才能送入策略网络。融合方式有三种:

Early fusion(特征级拼接)。 CNN 编码 depth → feature vector → 与 proprioception 拼接 → MLP。这是最常见的方式,简单且有效。

depth [B, 1, 64, 64]
    → CNN → [B, 32](视觉特征)
proprio [B, 48]
    → concat → [B, 80]
    → MLP → action [B, 12]

Late fusion(动作级加权)。 视觉分支和本体感知分支各自产生一个动作向量,然后加权平均。不常用——因为两个分支可能产生矛盾的动作。

Attention fusion(注意力机制)。 视觉特征和本体感知特征通过 cross-attention 交互。计算成本较高,目前主要在 VLA(Vision-Language-Action)模型中使用,对标准 locomotion 任务过于复杂。

工程建议:对本章的 locomotion 视觉任务,early fusion 是默认选择。它简单、高效、在 extreme-parkour 等标杆工作中已被验证。只有在 proprioception 和视觉之间存在复杂交互关系(如视觉观测需要根据机器人姿态进行坐标变换)时才考虑更复杂的融合方式。

⚠️ 常见陷阱

⚠️ 编程陷阱:Isaac Lab 中忘记 --enable_cameras

如果任务配置了 TiledCamera 但启动时没有加 --enable_cameras,当前 Isaac Lab 会在相机初始化/使用时抛出 RuntimeError(提示 "A camera was spawned without the --enable_cameras flag..."),而不是静默返回全零或上一帧残留。所以现象通常是直接报错退出,而非"黑屏但能跑"。自检方法:先确认启动命令含 --enable_cameras;训练初期保存一帧 depth 图到磁盘可视化确认内容正确。

⚠️ 编程陷阱:Depth 归一化的 clip_range 不匹配

如果 TiledCameraCfgclipping_range=(0.01, 5.0) 但 depth 预处理的归一化用了 clip_range=(0.1, 3.0),那么 5cm-10cm 和 3m-5m 的深度值会被错误地截断或映射到边界值。两者的范围必须一致。

💡 概念误区:认为"Isaac Lab 渲染一定比 mjlab 好"

对 depth-only 任务,两个框架的 depth 质量没有本质差别——depth 是纯几何计算,不受渲染管线质量影响。Isaac Lab 的 RTX 优势主要在 RGB 渲染质量上(全局光照、PBR 材质、光线追踪反射)。如果你只用 depth,mjlab 可能因为更轻量的渲染器而有更高的吞吐。

练习

  1. [配置题] 在 Isaac Lab 中配置一个 TiledCameraCfg,前视 depth 相机,分辨率 64×64,安装在 Go1 的前胸位置(pos=(0.35, 0, 0.02)),FOV 60°。写出完整的配置代码。
  2. [实验题] 在 mjlab 中分别用 32×32 和 64×64 分辨率训练 YAM depth 版 1000 iteration。比较训练吞吐(steps/s)和 reaching reward。
  3. [对比题] 画一张表对比 distance_to_image_planedistance_to_camera 在以下场景的值:(a) 正前方 1m 处的平面,(b) 画面边缘 1m 处的点。两者差异多大?

18.4 CNN 视觉编码器:从 Flatten 到 SpatialSoftmax ⭐⭐⭐

这一节解决什么问题:depth 图经过 CNN 编码后如何变成策略可用的低维特征?不同的编码方式在 locomotion 中各有什么优劣?

视觉编码的核心问题

CNN encoder 需要完成一个信息压缩任务:把 \(H \times W \times 1\) 的 depth 图(对 locomotion 通常是单通道 depth)压缩成一个 \(d\) 维特征向量(通常 \(d = 32\)-\(128\)),保留与 locomotion 相关的地形信息,丢弃无关信息。

与 Ch17 的操作视觉不同,locomotion 视觉的关键信息不是"物体在哪里"(需要精确的 2D 位置),而是"前方地形的整体形状"(需要全局几何特征)。这个差异影响编码器的选择:

任务类型 关键信息 推荐编码 原因
操作(lift cube) 物体 2D 位置 SpatialSoftmax 保留精确位置
Locomotion(平地+台阶) 地形全局高度分布 GAP 或 small CNN 不需要精确位置
Locomotion(parkour) 障碍物轮廓 + 距离 SpatialSoftmax 或 ConvNet+GRU 需要障碍物的空间信息
Navigation(避障) 可通行区域 GAP 只需"哪里能走"

三种编码方式的对比

Flatten:保留一切,代价是维度爆炸。 最朴素的方式是把最后一层 feature map 直接 flatten 成向量。如果最后一层有 32 个 \(8 \times 8\) 的 feature map,flatten 后就是 \(32 \times 8 \times 8 = 2048\) 维。Flatten 保留了所有空间信息,但对空间位置的微小变化极其敏感——相机抖动一个像素就会改变整个 flatten 向量。对 locomotion 来说,相机在机器人运动过程中持续抖动是常态,flatten 编码因此非常脆弱。

Global Average Pooling(GAP):降维彻底,但丢失位置。 GAP 对每个 feature channel 在空间维度上取平均。32 个 \(8 \times 8\) 的 feature map 变成 32 维向量。GAP 的平移不变性在 locomotion 中反而是一个优势——"前方有台阶"无论台阶在图像的左边还是右边,对步态决策的影响是相似的。但 GAP 无法区分"台阶在 0.5m 处"和"台阶在 1.5m 处"——这对需要精确跳跃时机的 parkour 任务来说是致命的。

Spatial Softmax:保留位置,丢弃纹理。 对每个 feature channel 计算空间 softmax 权重,然后计算激活的期望坐标 \((x_c, y_c)\)。输出维度 \(C \times 2\)。对 locomotion parkour,SpatialSoftmax 可以精确定位"台阶边缘在图像的哪个位置"——这个位置信息经过相机模型转换后就是"台阶离机器人多远"。

SpatialSoftmax 的数学形式(回顾 Ch17,这里加入 locomotion 的物理解释):

\[w_c(i, j) = \frac{\exp(f_c(i, j) / \tau)}{\sum_{i', j'} \exp(f_c(i', j') / \tau)}\]
\[x_c = \sum_{i, j} w_c(i, j) \cdot \text{pos}_x(j), \quad y_c = \sum_{i, j} w_c(i, j) \cdot \text{pos}_y(i)\]

在 locomotion 中,\(x_c\) 对应水平方向(障碍物在左边还是右边),\(y_c\) 对应垂直方向(障碍物在地面附近还是高处)。对前视 depth 相机,\(y_c\) 接近图像底部意味着障碍物在近处地面上,\(y_c\) 接近图像顶部意味着障碍物在远处或高处——CNN 可以通过 \(y_c\) 的值来判断障碍物的距离。

用一个驾驶类比来理解 SpatialSoftmax 在 locomotion 中的作用:想象你开车时注视前方道路。你的"注意力焦点"不是均匀分布在整个挡风玻璃上——而是集中在几个关键位置:前方车辆的尾灯、路面上的坑洞边缘、交通信号灯。SpatialSoftmax 的关键点就是 CNN 学到的"注意力焦点"——每个 channel 关注地形的一个特征位置(如台阶边缘、缺口中心、坡面起始点)。策略网络看到的不是整张 depth 图的像素值,而是"前方 0.8m 处有一个台阶边缘(channel 3 的 y 坐标 = 0.6)"这样的结构化信息。

SpatialSoftmax 的 PyTorch 实现(与 Ch17 相同,但加入 locomotion 注释):

class SpatialSoftmax(torch.nn.Module):
    """Spatial softmax 编码器:从 depth feature map 提取地形关键点坐标。

    对 locomotion:每个 channel 的关键点对应一个地形特征——
    如"最近的台阶边缘"、"最深的缺口中心"、"最高的障碍物顶部"。
    策略网络看到的是这些关键点的图像坐标,
    结合相机内参可以隐式推断出障碍物的 3D 位置。
    """
    def __init__(self, num_channels: int, height: int, width: int,
                 temperature: float = 1.0):
        super().__init__()
        self.temperature = temperature
        pos_x = torch.linspace(-1.0, 1.0, width)
        pos_y = torch.linspace(-1.0, 1.0, height)
        self.register_buffer("pos_x", pos_x.view(1, 1, 1, width))
        self.register_buffer("pos_y", pos_y.view(1, 1, height, 1))

    def forward(self, feature_map: torch.Tensor) -> torch.Tensor:
        B, C, H, W = feature_map.shape
        weights = torch.softmax(
            feature_map.view(B, C, -1) / self.temperature, dim=-1
        ).view(B, C, H, W)
        x = (weights * self.pos_x).sum(dim=[2, 3])
        y = (weights * self.pos_y).sum(dim=[2, 3])
        return torch.cat([x, y], dim=-1)  # [B, C*2]

ConvNet + GRU:extreme-parkour 的时序视觉编码

extreme-parkour 不使用 SpatialSoftmax——它用 CNN 的 flatten 输出接一个 GRU,形成一个有时序记忆的视觉编码器:

depth [B, 1, 58, 87]
    → Conv2d(1→32, 5×5, stride=2) → ReLU → [B, 32, 27, 42]
    → Conv2d(32→32, 3×3, stride=1) → ReLU → [B, 32, 25, 40]
    → Flatten → [B, 32000]
    → Linear(32000→128) → [B, 128](当前帧视觉特征)
    → GRU(128→64) → [B, 64](融入历史的视觉特征)
    → concat(proprio [B, 48]) → [B, 112]
    → MLP → action [B, 12]

为什么 extreme-parkour 选择 flatten + GRU 而非 SpatialSoftmax?

第一,parkour 需要的地形信息是"整个障碍物的形状"(宽度、高度、坡度),不仅仅是"关键点位置"。SpatialSoftmax 只保留了每个 channel 的一个点,丢弃了形状细节。而 flatten + linear 虽然维度高,但通过 linear projection 到 128 维后,仍然保留了形状信息。

第二,GRU 的时序记忆对 parkour 至关重要。机器人在接近障碍物时,障碍物在图像中的大小和位置在变化——GRU 可以整合最近几帧的信息来估计障碍物的 3D 形状和自己接近它的速度。这是单帧 SpatialSoftmax 无法提供的。

第三,parkour 的动作决策(跳跃、高攀)需要精确的时机控制。GRU 的隐状态可以编码"我已经看到障碍物 N 帧了,根据它在图像中的大小变化,我大约还有 M 步就到跳跃点"。

工程权衡:SpatialSoftmax 输出维度低(\(C \times 2 \approx 64\)),计算快,适合不需要时序信息的任务。ConvNet + GRU 输出维度适中(64-128),但引入了 GRU 的序列依赖性——推理时需要维护隐状态,且 GRU 的 backpropagation through time (BPTT) 增加了训练的内存和计算需求。

温度参数的工程影响(SpatialSoftmax 特有):

\(\tau\) 控制 softmax 的"尖锐程度"。对 locomotion depth 图,\(\tau = 1.0\) 是一个合理的起点。如果观察到关键点在帧间跳变(表现为策略的步态突然抖动),增大 \(\tau\) 到 2.0-3.0 以平滑关键点。如果关键点缺乏空间分辨能力(所有 channel 聚集在图像中心),减小 \(\tau\) 到 0.5-0.8。

⚠️ 常见陷阱

⚠️ 编程陷阱:CNN 输入的 HWC/CHW 格式混淆

MuJoCo Warp 渲染器输出 depth 图为 [B, H, W, 1](HWC),但 PyTorch 的 Conv2d 期望 [B, 1, H, W](CHW)。如果忘记 permute(0, 3, 1, 2),CNN 会把 H 维当成 channel 维——对 64×64 的 depth 图,CNN 看到的是"64 个 1×64 的 feature map"而非"1 个 64×64 的 depth 图"。网络不会报错,但特征完全没有意义。

正确做法:

# ✅ 正确:HWC → CHW
depth_chw = depth_hwc.permute(0, 3, 1, 2).float()
# 归一化到 [0, 1]
depth_norm = (depth_chw - near_clip) / (far_clip - near_clip)
depth_norm = depth_norm.clamp(0.0, 1.0)

⚠️ 编程陷阱:GRU 隐状态在 episode reset 时未清零

如果 GRU 的隐状态在 episode 结束后没有重置,新 episode 的第一帧会受到上一个 episode 最后几帧的残留记忆影响——导致 reset 后的前几步动作异常。正确做法:在 env.reset() 时同步清零 GRU 隐状态。mjlab 和 Isaac Lab 的 RSL-RL runner 在 reset_ids 时自动处理这个问题(通过 done mask),但如果你自定义了 rollout 循环,需要手动处理。

💡 概念误区:认为"更深的 CNN 总是更好"

Locomotion 的 depth 图信息密度远低于 ImageNet 图像——它只有一个通道,且大部分像素是平坦的地面(信息量为零)。2-3 层 CNN 通常足够提取台阶边缘和障碍物轮廓。使用 ResNet-18 等深层网络不仅增加计算量,还容易过拟合到仿真 depth 的特定噪声模式——在真实 depth sensor 上反而表现更差。

完整的 Locomotion CNN 编码器实现

以下是一个可直接在 mjlab 或 Isaac Lab 中使用的完整视觉编码器,支持 SpatialSoftmax 和 GAP 两种池化方式:

import torch
import torch.nn as nn
import torch.nn.functional as F

class LocomotionVisualEncoder(nn.Module):
    """Locomotion 视觉编码器:从 depth 图提取地形特征。

    架构:3 层 CNN + 可选池化(SpatialSoftmax / GAP / Flatten)
    设计决策:
    - 3 层 CNN 足以提取台阶边缘、缺口轮廓等几何特征
    - 每层 stride=2 逐步降分辨率:64→32→16→8
    - 不使用 BatchNorm(RL 的 batch 内样本高度相关,BN 统计量不稳定)
    - 使用 ELU 激活(比 ReLU 更平滑,避免 dead neuron 问题)
    """
    def __init__(
        self,
        in_channels: int = 1,        # depth = 1 channel
        base_channels: int = 32,     # 第一层 filter 数
        pooling: str = "spatial_softmax",  # "spatial_softmax" / "gap" / "flatten"
        spatial_softmax_temp: float = 1.0,
        output_dim: int = None,      # 如果设置,加一个线性投影层
    ):
        super().__init__()

        # CNN 骨干——3 层逐步降分辨率
        self.conv1 = nn.Conv2d(in_channels, base_channels, 5, stride=2, padding=2)
        self.conv2 = nn.Conv2d(base_channels, base_channels, 3, stride=2, padding=1)
        self.conv3 = nn.Conv2d(base_channels, base_channels, 3, stride=2, padding=1)
        self.activation = nn.ELU()

        self.pooling_type = pooling

        if pooling == "spatial_softmax":
            self.pool = SpatialSoftmax(
                base_channels, 8, 8,  # 64×64 → 3次stride=2 → 8×8
                temperature=spatial_softmax_temp
            )
            feature_dim = base_channels * 2  # 每个 channel 贡献 (x,y)
        elif pooling == "gap":
            self.pool = nn.AdaptiveAvgPool2d(1)
            feature_dim = base_channels
        else:  # flatten
            feature_dim = base_channels * 8 * 8

        if output_dim is not None:
            self.projection = nn.Linear(feature_dim, output_dim)
            self.feature_dim = output_dim
        else:
            self.projection = None
            self.feature_dim = feature_dim

    def forward(self, depth: torch.Tensor) -> torch.Tensor:
        x = self.activation(self.conv1(depth))  # [B, 32, 32, 32]
        x = self.activation(self.conv2(x))      # [B, 32, 16, 16]
        x = self.activation(self.conv3(x))      # [B, 32, 8, 8]

        if self.pooling_type == "spatial_softmax":
            features = self.pool(x)              # [B, 64]
        elif self.pooling_type == "gap":
            features = self.pool(x).flatten(1)   # [B, 32]
        else:
            features = x.flatten(1)              # [B, 2048]

        if self.projection is not None:
            features = self.activation(self.projection(features))

        return features

class LocomotionVisualPolicy(nn.Module):
    """完整的视觉 locomotion 策略网络。

    数据流:depth → CNN → visual_feat → concat(proprio) → MLP → action
    """
    def __init__(
        self,
        proprio_dim: int = 48,
        action_dim: int = 12,
        visual_encoder: LocomotionVisualEncoder = None,
        hidden_dims: tuple = (256, 128),
    ):
        super().__init__()
        self.visual_encoder = visual_encoder or LocomotionVisualEncoder()

        input_dim = self.visual_encoder.feature_dim + proprio_dim
        layers = []
        prev_dim = input_dim
        for dim in hidden_dims:
            layers.extend([nn.Linear(prev_dim, dim), nn.ELU()])
            prev_dim = dim
        layers.append(nn.Linear(prev_dim, action_dim))
        self.mlp = nn.Sequential(*layers)

    def forward(self, depth, proprio):
        visual_feat = self.visual_encoder(depth)
        combined = torch.cat([visual_feat, proprio], dim=-1)
        return self.mlp(combined)

# 使用示例
encoder = LocomotionVisualEncoder(
    in_channels=1, base_channels=32,
    pooling="spatial_softmax", output_dim=64,
)
policy = LocomotionVisualPolicy(
    proprio_dim=48, action_dim=12, visual_encoder=encoder,
)
print(f"Policy parameters: {sum(p.numel() for p in policy.parameters()):,}")
# 输出约 ~70k 参数——足够小,可在 Jetson Orin 上实时推理

为什么不用 BatchNorm? 在标准视觉任务(分类、检测)中,BatchNorm 是标配。但在 RL 的视觉编码器中,BatchNorm 通常有害。原因是 RL 的 mini-batch 内样本高度相关——同一个 rollout 的连续帧几乎一样(机器人走路时相邻帧只差一步),导致 BN 的均值和方差估计不准确。BN 在训练/评估模式切换时还会引入行为不一致——training mode 用 batch 统计量,eval mode 用 running 统计量,两者可能差异很大。推荐使用 LayerNorm(对每个样本独立归一化)或完全不归一化。

参数量对照表

配置 池化 feature_dim 总参数 推理 (RTX 3090) 推理 (Jetson Orin)
3 层, 32 ch, SpatialSoftmax 64 64 ~70K 0.3 ms 1.5 ms
3 层, 32 ch, GAP 32 32 ~55K 0.2 ms 1.0 ms
3 层, 32 ch, Flatten 2048 2048 ~550K 0.4 ms 2.5 ms
2 层, 32 ch (parkour) + GRU 128+64 64 ~180K 0.5 ms 3.0 ms

CNN 架构的消融实验设计

在选定最终架构之前,以下消融实验矩阵可以系统性评估不同设计决策的影响:

消融组 变量 A 组 B 组 固定 评估指标
池化方式 pooling SpatialSoftmax GAP CNN 3 层 episode length
层数 num_layers 2 3 SpatialSoftmax episode length
通道数 base_channels 16 32 3 层 episode length + 推理速度
温度 τ temperature 0.5 1.0 3 层, SpatialSoftmax 关键点稳定性
GRU temporal CNN only CNN + GRU 3 层 需要时序的任务

每组消融使用 3 个 seed,5000 iteration,报告 episode length 的均值±标准差。差异不超过 1σ 不下结论。

练习

  1. [计算题] CNN 最后一层有 32 个 \(8 \times 8\) feature map。分别计算 flatten、GAP 和 spatial softmax 三种编码方式的输出维度。如果后面接一个 128 维的全连接层,三种情况下的参数量分别是多少?
  2. [设计题] 你的 locomotion 任务是"走平地+上下台阶"(不需要跳跃)。应该选择 SpatialSoftmax、GAP 还是 ConvNet+GRU?给出工程理由。
  3. [实验题] 使用 SpatialSoftmax 编码器训练一个 depth locomotion 策略。保存训练过程中关键点坐标,叠加到 depth 图上可视化。关键点是否收敛到台阶边缘附近?

18.5 Teacher-Student 视觉蒸馏管线 ⭐⭐⭐

这一节解决什么问题:如何从 height scan teacher 蒸馏到 depth student?三个阶段各自解决什么问题?DAgger 和 MTS 的工程细节是什么?

算法回顾:为什么不直接端到端训练视觉策略

回顾 §18.1 的讨论:视觉输入是一个带有投影、遮挡和噪声的测量系统。如果直接用 RL 的 reward 信号端到端训练 CNN + policy,面临三个困难:

  1. RL reward 对 CNN 的梯度极其稀疏——reward 来自最终的 locomotion 效果(是否成功通过障碍),从 reward 到 CNN 参数的梯度链太长(reward → action → policy MLP → CNN output → CNN parameters),大部分梯度是噪声。
  2. CNN 表示的 non-stationarity——CNN 参数更新改变了策略的输入表示,策略需要重新适应新表示。这形成一个"移动目标"问题:CNN 在变,policy 也在变,两者互相干扰。
  3. 视觉捷径(shortcut)——如果仿真环境中存在任何简单的视觉-reward 相关性(如 depth 图的均值与前进速度的相关),CNN 会优先学这个捷径而非真正的地形结构。

Teacher-student 蒸馏通过分离"学什么控制策略"和"学什么视觉表示"两个问题来解决这些困难。

三阶段管线的完整架构

标准的视觉 locomotion 蒸馏管线有三个阶段:

Stage 1: State Teacher (privileged RL)
  Input:  proprio(48-dim) + height_scan(~200-dim)
  Output: action(12-dim)
  Method: PPO + reward shaping
  Time:   24-72h on 1 GPU

Stage 2: Depth Student (DAgger distillation)
  Input:  proprio(48-dim) + depth_image(64×64×1)
  Output: action(12-dim)
  Supervision: teacher_action from Stage 1 teacher
  Method: DAgger (student rollout, teacher label)
  Time:   12-24h on 1 GPU

Stage 3: RGB Student (optional, visual DR)
  Input:  proprio(48-dim) + rgb_image(64×64×3)
  Output: action(12-dim)
  Supervision: Stage 2 depth student action
  Method: BC + extensive visual DR
  Time:   24-48h on 1 GPU

Stage 1 的工程细节。 Teacher 使用 height scan + proprioception 作为输入,标准 MLP 策略网络(不需要 CNN),PPO 训练。这个阶段与 Ch13 的四足进阶完全相同——唯一的区别是 height scan 的配置可能更密集(更多采样点、更远距离)。Teacher 的性能是整个管线的上限——如果 teacher 在某些障碍物上失败,student 也不可能成功。因此 Stage 1 需要充分训练(通常 10k-20k iteration,8-72h 取决于地形复杂度)。

Stage 1 的 observation 配置(mjlab 示例):

# Stage 1 Teacher: height scan + proprio
teacher_obs = ObservationsCfg(
    policy=ObservationGroupCfg(
        terms={
            "joint_pos": JointPositionObsCfg(...),
            "joint_vel": JointVelocityObsCfg(...),
            "base_ang_vel": BaseAngularVelocityObsCfg(...),
            "projected_gravity": ProjectedGravityObsCfg(...),
            "commands": CommandObsCfg(...),
            "last_action": LastActionObsCfg(...),
            # ↓ 特权信息:只有 teacher 使用
            "height_scan": RaycastSensorCfg(
                pattern_cfg=GridPatternCfg(
                    resolution=0.1,
                    size=(2.0, 1.0),   # 前方 2m × 左右 1m
                ),
            ),
        },
    ),
)

Stage 2 的工程细节:DAgger 蒸馏。 这是整个管线中工程复杂度最高的阶段。核心思想:用 student 的策略执行 rollout(而非 teacher),但用 teacher 对 student 看到的每一帧标注"正确动作"。这解决了分布不匹配问题——student 在训练时看到的状态分布与部署时一致。

# DAgger 蒸馏的核心循环(伪代码)
for iteration in range(num_dagger_iterations):
    # 1. 用 student 策略 rollout
    obs_buffer, action_buffer = [], []
    hidden_state = torch.zeros(num_envs, gru_hidden_size)  # GRU 隐状态
    for step in range(rollout_length):
        depth = env.get_camera_data("front_depth")    # [B, 64, 64, 1]
        proprio = env.get_proprio()                    # [B, 48]

        # Student 预测动作(用自己的 CNN + GRU)
        depth_feature = student_cnn(depth)             # [B, 128]
        visual_feature, hidden_state = student_gru(depth_feature, hidden_state)
        student_action = student_mlp(cat(visual_feature, proprio))

        # Teacher 标注正确动作(用 height scan)
        height_scan = env.get_height_scan()            # [B, ~200]
        teacher_action = teacher_policy(cat(proprio, height_scan))

        obs_buffer.append((depth, proprio, hidden_state.detach()))
        action_buffer.append(teacher_action)

        # 用 student 动作执行(不是 teacher 动作!)
        env.step(student_action)

    # 2. 用 teacher 标注更新 student
    loss = MSE(student_predictions, teacher_actions)
    loss.backward()
    optimizer.step()

DAgger 的关键工程决策

决策 选项 extreme-parkour 的选择 原因
谁执行 rollout teacher / student / 混合 student 训练时的分布要匹配部署
初始化 student 从零 / 从 teacher 复制 从 teacher 复制 减小初始的分布偏移
Loss 函数 MSE / L1 / Huber MSE 简单有效
GRU 隐状态截断 每步截断 / 每 episode 截断 每 rollout 截断 平衡计算量和时序学习
CNN 学习率 与 MLP 相同 / 独立更小 独立更小(0.1×) CNN 更新太快会不稳定

MTS(Mixture of Teacher and Student):extreme-parkour 的创新。 extreme-parkour 发现,如果 student 完全用自己预测的 heading command,在训练初期会产生灾难性的分布漂移——student 预测错误的 heading → 机器人转向错误方向 → 看到与训练完全不同的场景 → teacher 的标注动作对这个场景无意义 → student 学到更错误的映射。

MTS 的解决方案(论文实际机制是阈值切换,不是线性混合退火):每一步比较 student 预测的 heading 与 oracle heading 的偏差,只有当偏差小于阈值(0.6 rad)时才采用 student 自己的预测,否则回退到 oracle:

\[\text{obs}_\theta = \begin{cases} \theta_{\text{pred}}, & |\theta_{\text{pred}} - \hat{d}_w| < 0.6 \\ \hat{d}_w \ (\text{oracle}), & \text{否则} \end{cases}\]

这样在训练初期 student 预测还不准时大多回退到 oracle(避免分布漂移),随着预测变准,越来越多的步使用 student 自己的 heading——切换是按"逐步预测质量"自适应发生的,而不是按固定时间表退火 \(\alpha\)

(可选对比)其它过渡设计——alpha 退火: 另一类做法是让 heading command 在 teacher 与 student 预测之间做加权混合 \(\theta = \alpha\,\theta_{\text{teacher}} + (1-\alpha)\,\theta_{\text{pred}}\),并把 \(\alpha\) 从 1 退火到 0。下表列出常见退火曲线,但请注意这不是 extreme-parkour 采用的机制,仅作为一类替代 DAgger/MTS 设计参考:

退火方式 公式 特点 适用场景
线性 \(\alpha = 1 - t/T\) 均匀过渡 简单 baseline
余弦 \(\alpha = 0.5(1 + \cos(\pi t/T))\) 初期慢、中期快、末期慢 heading 预测较难学的场景
阶梯式 \(\alpha = 1\) if \(t < T/2\) else \(0\) 前半段全 teacher,后半段全 student 快速切换但有突变风险

RSL-RL 中的 DAgger 集成。 RSL-RL 内置了 OnPolicyRunner 用于标准 PPO 训练,但不直接支持 DAgger 蒸馏。两种工程路径:

路径一:自定义 DAgger Runner。 继承 OnPolicyRunner,重写 run() 方法中的数据采集和更新逻辑。这是 extreme-parkour 的方案——它在 rsl_rl 中自定义了一个 distillation runner。

路径二:使用 RSL-RL 官方的蒸馏 Runner。 当前 RSL-RL 提供 OnPolicyRunner(标准 PPO)与 DistillationRunner(蒸馏),蒸馏算法名为 Distillation;其配置把 actor/critic 键替换为 student/teacher 键。使用方式(以官方接口为准,没有 StudentTeacherDistillationRunner/DistillCfg 这些类):

# RSL-RL 官方 DistillationRunner(示意,字段以当前 rsl_rl 版本文档为准)
from rsl_rl.runners import DistillationRunner

# 训练配置:algorithm.class_name="Distillation",policy 用 student/teacher 键
train_cfg = {
    "algorithm": {"class_name": "Distillation", "num_learning_epochs": 1,
                  "learning_rate": 1e-4, "loss_type": "mse"},
    "policy": {"student": {...}, "teacher": {...}},  # 替代 actor/critic
    "num_steps_per_env": 24,
}
runner = DistillationRunner(env, train_cfg, log_dir, device="cuda:0")
runner.learn(num_learning_iterations=5000)

Stage 2 蒸馏的完整工程 checklist:

Stage 2 启动前检查:
  □ Stage 1 teacher 在目标地形上 episode length 达标
  □ Teacher checkpoint 已保存且可加载
  □ Student 网络的 MLP 部分从 teacher 初始化
  □ Student 网络的 CNN + GRU 随机初始化
  □ 渲染管线配置正确(--enable_cameras / MUJOCO_GL=egl)
  □ Depth 预处理与 §18.2 的流程一致
  □ num_envs 已减少(4096 → 512-1024)
  □ MTS alpha 退火参数已设定

Stage 2 训练中检查(每 500 iteration):
  □ MSE loss 持续下降
  □ Student 的 episode length 逐步接近 teacher
  □ 保存一帧 depth 图确认渲染正确
  □ 如果使用 SpatialSoftmax,可视化关键点

Stage 2 训练后检查:
  □ Student 在所有地形类型上 episode length > teacher × 60%
  □ Play 可视化中步态合理
  □ Latency injection 测试(1-3 帧)策略仍稳定

Stage 3(可选)的工程细节。 如果真实部署使用 depth camera,Stage 2 就是最终版本(不需要 Stage 3)。Stage 3 只有在需要 RGB 输入时才需要。RGB student 面对的额外挑战是外观变化——光照、材质、颜色在仿真和真实环境中差异巨大。因此 Stage 3 需要大量的视觉 Domain Randomization(下一节详述)。

Isaac Lab 的 RGB DR 使用 Omniverse Replicator API——可以随机化 MDL 材质的 albedo、roughness、metallic 参数,随机化 light source 的位置、颜色和强度,随机化 background texture。这比 mjlab 的基础颜色替换功能强大得多。如果你的最终目标是 RGB sim-to-real,Isaac Lab 是更合适的平台。

VIRAL 的大规模蒸馏工程。 NVIDIA 的 VIRAL 工作(2025)将这个三阶段管线推到了工业级规模——在 64 GPUs 上进行蒸馏,使用 Isaac Lab 的 tiled rendering 同时渲染数千个环境的 depth/RGB 图像。其关键工程创新包括:

  1. Mixed online DAgger + offline BC:不是纯 DAgger(太慢)也不是纯 offline BC(有分布偏移),而是混合使用——80% 的数据来自 student 的 online rollout(DAgger),20% 来自 teacher 的 offline 数据库(BC buffer),两者交替训练。
  2. Delta-action space:student 输出的是相对于当前关节位置的增量(delta action),而非绝对关节位置。这在长序列任务中更稳定——因为误差不会随时间累积。这与 Ch17 §18.8 讨论的 DiffIK use_relative_mode=True 是同一思想。
  3. Real-to-sim camera alignment:在训练前用真实相机拍摄已知场景,然后在仿真中调整相机参数直到渲染输出与真实图像匹配。这消除了系统性的外参偏差。

Teacher 和 Student 的 observation 配置对比

Observation 组件 Teacher (Stage 1) Student (Stage 2) Student (Stage 3)
joint_pos
joint_vel
base_ang_vel
projected_gravity
commands
last_action
height_scan ✅(特权)
depth_image ✅(64×64×1)
rgb_image ✅(64×64×3)

本质洞察:三阶段管线的每个阶段都在"降级"输入的质量——从完美的 height scan 到有噪声的 depth 到外观多变的 RGB。每次降级都伴随着一次蒸馏(用上一阶段的策略作为 teacher),确保信息损失被最小化。这与工程中"渐进式降级"(graceful degradation)的设计理念一致——先确保最高质量输入下任务可行,再逐步适应更低质量的输入。

训练时间和计算资源预算

阶段 GPU num_envs Iterations Wall-clock 收敛指标
Stage 1 (teacher) 1× RTX 3090/4090 4096 10k-20k 8-72h episode length > 阈值
Stage 2 (depth student) 1× RTX 3090/4090 512-1024 5k-10k 5-24h MSE loss < 阈值
Stage 3 (RGB student) 1× RTX 3090/4090 256-512 5k-10k 24-48h 同上 + visual DR 下稳定

Stage 2 的 num_envs 显著低于 Stage 1——因为每个环境需要渲染 depth 图,GPU 内存和渲染时间是瓶颈。Stage 3 进一步降低——RGB 渲染比 depth 更耗资源。

⚠️ 常见陷阱

⚠️ 编程陷阱:Stage 2 用 teacher 而非 student 执行 rollout

如果 Stage 2 用 teacher 执行 rollout(offline BC),student 在部署时看到的状态分布与训练时不同(因为 student 会犯错,而 teacher 不会)。这导致 compounding error——一个小错误导致看到新的状态,在新状态上犯更大的错误,越来越偏离。DAgger 通过用 student 执行来解决这个问题。

⚠️ 编程陷阱:Stage 2 初始化 student CNN 权重为随机

如果 student 的 MLP 部分从 teacher 复制权重(正确),但 CNN 部分随机初始化(常见错误),那么训练初期 CNN 输出的特征向量是噪声——MLP 接收到噪声输入后输出的动作也是噪声。用 student 噪声动作执行 rollout 会导致机器人立即摔倒,teacher 标注的恢复动作对 CNN 的梯度信号也很弱(因为摔倒后的场景不包含有意义的地形信息)。解决方案:Stage 2 初期用 teacher 执行 rollout(offline BC)热启动 CNN 几百个 iteration,等 CNN 输出稳定后再切换到 DAgger。

💡 概念误区:认为"student 永远不如 teacher"

大多数情况下 student 性能低于 teacher(信息损失)。但有一个重要的反例:当 teacher 使用的 height scan 恰好包含了在真实世界中不存在的"虚假地形信息"(如仿真中地形的精确边缘在真实世界中是圆滑的),teacher 会过拟合到这个虚假信息。depth student 因为看到的是"模糊"的深度图,反而学到了更鲁棒的策略。这种"信息瓶颈带来鲁棒性"的现象在蒸馏文献中被称为 information bottleneck regularization。

练习

  1. [设计题] 设计 Stage 2 的 DAgger 蒸馏循环。回答:rollout 长度设为多少步?多少 rollout 后更新一次 student?CNN 和 MLP 的学习率应该一样还是分开设?
  2. [分析题] MTS 的 \(\alpha\) 退火速率设为线性衰减 vs 余弦衰减会有什么工程差异?哪种更适合 heading prediction 的逐步过渡?
  3. [跨章综合题] 回顾 Ch09 的 teacher-student 架构。Ch09 的 student 是"盲"的(只用 proprioception),本章的 student 使用 depth。两者的蒸馏 loss 设计有什么区别?为什么 Ch09 不需要 DAgger 而本章需要?

18.6 视觉 Domain Randomization:理论与工程实践 ⭐⭐⭐

这一节解决什么问题:视觉 DR 应该覆盖哪些维度?哪些 DR 对 depth locomotion 有帮助?如何避免过度 DR 破坏任务的可解性?

为什么 Locomotion 的视觉 DR 与操作不同

Ch17 讨论了操作视觉的 DR——纹理、光照、相机噪声。locomotion 的视觉 DR 有以下不同点:

第一,depth 是主力模态。 操作任务中 RGB 和 depth 都常用;locomotion 以 depth 为主。Depth 图不受光照和材质影响——因此纹理随机化和光照随机化对 depth locomotion 无效。这大大简化了 DR 配置。

第二,相机抖动是常态。 操作中相机通常固定安装(手眼标定后不动);locomotion 中相机安装在移动的机器人上,随机器人运动持续抖动。这意味着相机外参的 DR 范围可以更大——因为真实世界中相机确实在持续变化。

第三,地形几何是核心 DR 维度。 操作中物体几何的 DR 是辅助的(方块大小 ±10%);locomotion 中地形几何的 DR 是核心的——台阶高度、缺口宽度、坡度角度都需要大范围随机化。但地形几何 DR 不是"视觉 DR"——它是物理 DR,在 Stage 1 teacher 训练时就需要。

Depth Locomotion 的 DR 维度

DR 维度 随机化内容 典型范围 影响的阶段 sim-to-real 帮助
Depth 高斯噪声 每像素加 \(\mathcal{N}(0, \sigma^2)\) \(\sigma = 0.001\)-\(0.01\) m Stage 2 模拟传感器热噪声
Depth dropout 随机将 \(p\)% 像素设为 0 \(p = 1\)-\(5\)% Stage 2 模拟深度传感器的"空洞"
Depth quantization 深度值量化到 \(n\) \(n = 64\)-\(256\) Stage 2 模拟低精度传感器
相机外参扰动 位置 ±Δp,旋转 ±Δr Δp=1cm, Δr=2° Stage 2 覆盖安装误差和机器人运动抖动
相机内参扰动 FOV ±5° ±5° Stage 2 覆盖镜头差异
深度延迟 延迟 1-3 帧 1-3 步 Stage 2 模拟真实相机的处理延迟
纹理/光照随机化 RGB 通道的颜色和光照 Stage 3 only 仅对 RGB student

关键工程决策:depth 噪声的类型和强度。 真实 depth sensor(如 Intel RealSense、Orbbec Astra)的噪声不是简单的高斯噪声——它包含: - 距离相关噪声:远处的 depth 噪声远大于近处(\(\sigma \propto d^2\) 对结构光传感器) - 边缘 artifact:物体边缘处 depth 值不可靠("飞散"现象) - 反射面问题:金属、玻璃等高反射面可能返回 0 或 inf - 红外干扰:日光中的红外成分干扰结构光传感器

在仿真中完美地模拟这些噪声很困难。工程上的做法是:用简单的高斯噪声 + dropout 作为近似,然后通过 DR 的范围来覆盖真实噪声的不确定性。如果你知道真机使用的具体传感器型号,可以测量其噪声特性并在仿真中实现更精确的噪声模型。

# Depth 噪声随机化的实现
def add_depth_noise(depth, cfg):
    """对 depth 图添加 sim-to-real 噪声。

    Args:
        depth: [B, 1, H, W] float32 depth tensor
        cfg: 噪声配置
    Returns:
        noisy_depth: [B, 1, H, W]
    """
    B, C, H, W = depth.shape

    # 1. 高斯噪声(距离相关)
    noise_std = cfg.gaussian_noise_base + cfg.gaussian_noise_slope * depth
    gaussian_noise = torch.randn_like(depth) * noise_std

    # 2. 随机 dropout(模拟传感器空洞)
    dropout_mask = torch.rand(B, 1, H, W, device=depth.device) > cfg.dropout_rate

    # 3. 组合
    noisy_depth = (depth + gaussian_noise) * dropout_mask.float()

    # 4. 裁剪到有效范围
    noisy_depth = noisy_depth.clamp(cfg.near_clip, cfg.far_clip)

    return noisy_depth

阶段化 DR 策略

与 Ch17 讨论的操作 DR 类似(但简化了,因为 depth 不需要纹理/光照 DR):

阶段零:无噪声蒸馏。 先在完美 depth 图上蒸馏 Stage 2 student,确认蒸馏管线(DAgger + MSE loss + CNN 架构)能工作。如果完美 depth 下 student 都不收敛,加噪声只会更糟。

阶段一:几何 DR。 加入相机外参扰动(小范围,±1cm / ±1°)和轻微的 depth 高斯噪声(\(\sigma = 0.001\) m)。确认 student 仍然能完成大部分任务。

阶段二:传感器 DR。 增大高斯噪声(\(\sigma = 0.005\)-\(0.01\) m),加入 dropout(1-5%),加入 1-3 帧延迟。这是为 sim-to-real 做准备的关键阶段——真实传感器的噪声特性被 DR 覆盖。

阶段三(仅 RGB):外观 DR。 如果需要 Stage 3 的 RGB student,在这个阶段加入纹理、光照、材质的大范围随机化。Isaac Lab 的 Replicator API 和 MDL 材质系统提供了丰富的 RGB DR 工具。

Isaac Lab Replicator API 的 RGB DR 配置示例

# Isaac Lab 的 RGB Domain Randomization(Stage 3 专用)
# 通过 EventTermCfg 配置随机化事件

# 光照随机化——在每次 reset 时重新采样
class LightRandomizationCfg:
    """Isaac Lab 支持通过 Replicator 随机化 USD 场景中的光源。"""
    intensity_range = (500.0, 3000.0)      # 光照强度 (lux)
    color_temperature_range = (3000, 7000)  # 色温 (K):暖光→冷光
    position_range = {                      # 光源位置范围
        "x": (-2.0, 2.0),
        "y": (-2.0, 2.0),
        "z": (2.0, 5.0),                  # 始终在上方
    }
    num_lights = 3                         # 场景中的光源数量

# 材质随机化——使用 MDL 材质系统
class MaterialRandomizationCfg:
    """随机化地面和障碍物的表面材质。"""
    ground_albedo_range = (0.1, 0.9)       # 地面反射率
    ground_roughness_range = (0.3, 0.9)    # 地面粗糙度
    obstacle_metallic_range = (0.0, 0.5)   # 障碍物金属度
    # 注意:不要让地面和障碍物的反射率相同
    # 否则 RGB 策略无法区分地面和障碍物

# 背景随机化
class BackgroundRandomizationCfg:
    """随机化远处背景的纹理。"""
    use_hdri_dome = True                    # 使用 HDRI 环境贴图
    hdri_rotation_range = (0, 360)          # HDRI 旋转角度
    # Isaac Lab 内置了多种 HDRI 贴图,覆盖室内/室外/工业场景

为什么 RGB DR 比 depth DR 困难得多? Depth DR 只需要处理噪声和延迟(数值扰动),但 RGB DR 需要处理外观的"质变"——同一个场景在不同光照下看起来完全不同。仿真中的 RGB 渲染与真实 RGB 图像之间的"domain gap"不仅是数值上的(亮度偏差),更是结构上的(反射模型不同、材质表示不同)。这就是为什么大多数视觉 locomotion 工作选择 depth 而非 RGB——depth 的 sim-to-real gap 可以通过简单的噪声 DR 覆盖,而 RGB 需要 PBR 渲染器 + 大量材质库 + 复杂的外观 DR。

边缘 artifact 的仿真:真实 depth sensor 在物体边缘处有严重的"飞散"现象——边缘像素的 depth 值在前景和背景之间随机跳变。在仿真中模拟这个效果:

def add_edge_artifact(depth, kernel_size=3, artifact_prob=0.3):
    """在 depth 图的边缘区域添加 artifact。

    原理:检测 depth 梯度大的区域(边缘),
    在这些区域随机用邻域值替换当前值。
    """
    # 计算 depth 梯度
    grad_x = torch.abs(depth[:, :, :, 1:] - depth[:, :, :, :-1])
    grad_y = torch.abs(depth[:, :, 1:, :] - depth[:, :, :-1, :])

    # 梯度大的区域 = 边缘
    edge_mask_x = F.pad(grad_x > 0.1, (0, 1, 0, 0))  # 阈值 10cm
    edge_mask_y = F.pad(grad_y > 0.1, (0, 0, 0, 1))
    edge_mask = edge_mask_x | edge_mask_y

    # 在边缘区域随机替换(模拟飞散)
    random_mask = torch.rand_like(depth) < artifact_prob
    apply_mask = edge_mask & random_mask

    # 用最大池化(背景值)或最小池化(前景值)替换
    max_pool = F.max_pool2d(depth, kernel_size, 1, kernel_size // 2)
    result = torch.where(apply_mask, max_pool, depth)

    return result

完整的 DR 配置模板(depth locomotion):

# 推荐的 depth DR 完整配置
depth_dr_config = {
    # Stage 2 阶段零(无 DR,验证蒸馏管线)
    "stage0": {},

    # Stage 2 阶段一(轻微 DR)
    "stage1": {
        "gaussian_noise_base": 0.001,    # m
        "gaussian_noise_slope": 0.0,     # 不做距离相关
        "dropout_rate": 0.01,
        "camera_pos_noise": 0.005,       # 5mm
        "camera_rot_noise": 0.5,         # 0.5°
        "delay_frames": 0,
    },

    # Stage 2 阶段二(标准 DR)
    "stage2": {
        "gaussian_noise_base": 0.003,
        "gaussian_noise_slope": 0.002,   # 远处噪声更大
        "dropout_rate": 0.03,
        "edge_artifact_prob": 0.2,
        "camera_pos_noise": 0.01,        # 1cm
        "camera_rot_noise": 2.0,         # 2°
        "delay_frames": 1,
        "fov_noise": 2.0,               # ±2° FOV 扰动
    },

    # Stage 2 阶段三(激进 DR,为 sim-to-real 做最终准备)
    "stage3": {
        "gaussian_noise_base": 0.005,
        "gaussian_noise_slope": 0.005,
        "dropout_rate": 0.05,
        "edge_artifact_prob": 0.3,
        "camera_pos_noise": 0.02,        # 2cm
        "camera_rot_noise": 3.0,         # 3°
        "delay_frames": 2,
        "fov_noise": 5.0,               # ±5° FOV 扰动
        "quantization_levels": 128,      # 模拟低精度 sensor
    },
}

如果某个阶段的 DR 导致训练不收敛,回退到上一个阶段——不要继续堆叠更多 DR。如果阶段二就不收敛,检查 CNN 是否有足够的容量处理噪声数据(增加 channel 数或层数),或减小噪声范围的一半重试。

视觉 DR 与物理 DR 的交互

一个容易忽略的工程问题:视觉 DR 和物理 DR 是否应该同时使用?

答案是:Stage 1 只用物理 DR(地形、质量、摩擦),Stage 2-3 同时使用物理 DR + 视觉 DR

原因:Stage 1 的 teacher 不使用视觉输入,视觉 DR 对它无效。但 Stage 2 的 student 使用视觉——如果 Stage 2 只有视觉 DR 而没有物理 DR,student 会过拟合到 Stage 1 teacher 训练时的固定物理参数。正确做法是在 Stage 2 蒸馏时同时开启物理 DR 和视觉 DR——teacher 在随机化的物理条件下标注动作,student 在随机化的物理条件 + 随机化的视觉条件下学习。

本质洞察:视觉 DR 不是"让仿真更真实"——而是"让策略对视觉条件变化更鲁棒"。真实世界只是 DR 覆盖的分布中的一个点。DR 范围太窄,真实世界可能在分布之外;DR 范围太宽,训练难度过大导致性能下降。这与 Ch08 讨论的物理 DR 原理完全相同。

用一个考试类比:DR 就像老师出的模拟卷——如果模拟卷只考课本原题(无 DR),学生会对课本内容滚瓜烂熟,但遇到没见过的题型就束手无策。如果模拟卷涵盖了各种变体和陷阱(适度 DR),学生被迫理解底层原理而非死记硬背,考场上遇到新题也能应对。但如果模拟卷包含了完全超纲的内容(过度 DR),学生会因为"什么都可能考"而无从准备,最终连基础题都答不好。

具体到 depth DR:适度的高斯噪声(\(\sigma = 0.005\) m)迫使 CNN 学会忽略传感器噪声而关注地形的"大结构"(台阶边缘、缺口轮廓)——这些大结构在真实 depth sensor 上同样可见。过度的噪声(\(\sigma = 0.05\) m)会模糊地形的大结构,CNN 无法学到有用信息。关键是找到"足够大以覆盖真实噪声,但不大到破坏信号"的甜点范围。

⚠️ 常见陷阱

⚠️ 编程陷阱:Depth dropout 将像素设为 0 而非 far_clip

如果 dropout 像素值设为 0,CNN 会把这些像素解读为"距离为零"(即物体紧贴相机)。正确做法:dropout 像素设为 far_clip(表示"没有检测到物体,视为无穷远")或设为一个特殊的标记值(如 -1),并在 CNN 前的预处理中统一处理。

💡 概念误区:认为"Depth 不需要任何 DR"

Depth 虽然不受光照和材质影响,但仍受传感器噪声、相机外参误差和处理延迟影响。不加任何 DR 的 depth student 在仿真中表现完美,但一到真机就退化——因为真机的 depth sensor 噪声、安装误差和通信延迟在训练分布之外。

练习

  1. [设计题] 你使用 Intel RealSense D435 作为 depth sensor,其典型噪声为距离 1m 处 \(\sigma \approx 2\) mm,距离 3m 处 \(\sigma \approx 14\) mm。设计一个距离相关的 depth 噪声模型,写出 PyTorch 实现。
  2. [实验题] 训练两个 depth student:一个不加 DR,一个加高斯噪声 + dropout。训练完成后,在 play 时手动增加 depth 噪声(\(\sigma = 0.02\) m),比较两个策略的稳定性。
  3. [分析题] 为什么 depth 延迟(1-3 帧)对 locomotion 策略的影响比对操作策略更大?从控制频率和运动速度两个角度分析。

18.7 精读:extreme-parkour 三阶段视觉管线 ⭐⭐⭐

这一节解决什么问题:通过 extreme-parkour 的完整代码结构精读,理解视觉 locomotion 管线从论文到代码的工程实现。

项目概览

extreme-parkour(Cheng et al., ICRA 2024)是视觉 locomotion 领域的标杆工作——在 Unitree A1 上实现了 2× 体高的跳跃和缺口穿越,只使用单个前视深度相机。其代码完全开源(github.com/chengxuxin/extreme-parkour)。

技术栈:IsaacGym Preview 3/4 + rsl_rl + legged_gym。虽然不是 mjlab 或 Isaac Lab,但工程模式与两者高度相似——理解 extreme-parkour 的代码结构可以直接迁移到双框架。

硬件:Unitree A1 + 一个廉价的前视深度相机(~$50)。"Low-cost robot with imprecise actuation and a single front-facing depth camera for perception which is low-frequency, jittery, and prone to artifacts."

Phase 1:Blind Oracle(特权 RL)

Phase 1 训练一个使用 scandots(height scan 的变体)的特权策略。其创新是 ROA(Regularized Online Adaptation)——将 teacher-student adaptation(Ch09 的两阶段管线)压缩到一个阶段中。

代码结构(Phase 1,按官方仓库实际结构):
legged_gym/
├── envs/
│   ├── base/
│   │   ├── legged_robot.py        # 环境主体类
│   │   └── legged_robot_config.py # 核心环境/训练参数(n_proprio、n_scan、reward scale 等)
│   ├── a1/
│   │   └── a1_parkour_config.py    # A1 parkour 覆盖配置
│   └── go1/
│       └── go1_config.py          # Go1 覆盖配置(初始状态/PD/asset 等,仅少量覆盖项)
├── scripts/
│   └── train.py                   # 入口脚本
rsl_rl/
├── runners/
│   └── on_policy_runner.py      # PPO 训练循环
└── algorithms/
    └── ppo.py                   # PPO 实现

关键配置参数: 注意 go1_config.py 主要覆盖 A1/Go1 的初始状态、PD、asset 和少量参数;核心环境参数(n_proprio=53n_scan=132num_envs=6144、PPO learning_rate=2e-4num_steps_per_env=24)和默认 reward scale 定义在 base config(legged_robot_config.py)。下面给出 base config 中实际的关键 reward scale(不是泛化的 legged_gym 默认):

# extreme-parkour base config(legged_robot_config.py)中的关键 reward scale
class rewards:
    tracking_sigma = 0.2           # 注意是 0.2
    class scales:
        tracking_goal_vel = 1.5    # 朝 waypoint 方向的速度跟踪(不是 tracking_lin_vel)
        tracking_yaw = 0.5
        orientation = -1.0         # 惩罚偏离水平
        collision = -10.0          # 碰撞惩罚(量级远大于通用配置)
        action_rate = -0.1         # 动作平滑
        # 此外还有 feet_edge、lin_vel_z、ang_vel_xy 等项,详见源码

地形难度课程(curriculum=True)确实启用,但具体 terrain_proportions 列表请以源码为准——上面只列经核对的 reward 字段。

Waypoint-based 方向命令:extreme-parkour 不使用随机采样的 velocity command。而是在地形上预放置 waypoint,策略的方向命令从 waypoint 计算得到:

\[\text{heading} = \arctan2(p_{\text{waypoint},y} - p_{\text{robot},y},\; p_{\text{waypoint},x} - p_{\text{robot},x})\]

这确保策略总是被引导向障碍物移动,而非随机方向。

extreme-parkour 的 Reward 设计哲学。 论文的关键贡献之一是"simple, unified reward formulation from which diverse behaviors emerge automatically"——不为每种障碍物设计专门的 reward,而是用一组通用 reward 让策略自动发现合适的行为。

核心 reward 函数包括:

# extreme-parkour 的核心 reward(从论文和代码重构)

# 1. 速度跟踪——但用 waypoint 方向而非随机命令
def tracking_lin_vel_reward(base_vel, waypoint_dir, cmd_speed):
    """跟踪沿 waypoint 方向的线速度。
    与 Ch13 的 velocity tracking 类似,但方向从 waypoint 计算。
    """
    target_vel = waypoint_dir * cmd_speed  # 世界系目标速度
    error = torch.norm(base_vel[:, :2] - target_vel, dim=-1)
    return torch.exp(-error / tracking_sigma)

# 2. 存活奖励——鼓励策略尽可能长时间不摔倒
def alive_reward():
    """每步存活就给 reward,简单但对 parkour 至关重要。
    没有这个项,策略可能学会"尽快冲过去然后摔倒"。
    """
    return 1.0  # 固定值

# 3. base 高度保持——防止策略"趴下"通过
def base_height_reward(base_height, target_height=0.34):
    """惩罚 base 高度偏离目标。
    对跳跃很重要——策略需要先蹲下再跳起,
    这个 reward 确保蹲下只是暂时的准备动作。
    """
    return -torch.abs(base_height - target_height)

# 4. 碰撞惩罚——只惩罚 base 碰撞,不惩罚脚碰撞
def collision_penalty(contact_forces, collision_bodies):
    """base/thigh 碰撞 = 策略行为不当;
    shin/foot 碰撞 = 正常行走,不惩罚。
    """
    penalty = 0
    for body in collision_bodies:  # base, thigh
        penalty += (contact_forces[body] > 1.0).float()
    return -penalty

# 5. 动作平滑——抑制高频震荡
def action_rate_penalty(action, last_action):
    return -torch.sum((action - last_action) ** 2, dim=-1)

注意 reward 中没有任何"跳跃 reward"或"攀爬 reward"——策略完全从速度跟踪 + 存活 + 惩罚的组合中自动学会跳跃和攀爬。这是因为在 gap terrain 上,唯一能保持高速度且存活的行为就是跳跃;在 high obstacle terrain 上,唯一能保持高速度且存活的行为就是攀爬。Reward 设计的 minimalism 是 extreme-parkour 的关键工程洞察。

如果为每种障碍物设计专门的 reward 会怎样?第一,reward engineering 的工作量会随障碍物类型线性增长。第二,不同障碍物的专门 reward 之间可能存在冲突(跳跃 reward 鼓励高速冲刺,攀爬 reward 鼓励缓慢爬升)。第三,策略可能学会区分"哪种障碍物用哪种行为"而非"根据当前地形几何自动选择最优行为"——前者在遇到训练中没见过的新障碍物类型时会失败。

地形课程(Terrain Curriculum)的工程实现。 extreme-parkour 使用 curriculum learning 逐步增加地形难度——策略先在简单地形上学会基本行走,再逐步面对更难的障碍物。

# 地形课程配置
class terrain_curriculum:
    num_levels = 10              # 10 级难度
    num_terrains_per_level = 4   # 每级 4 种地形类型

    # 每种地形的难度范围
    gap_width = (0.0, 1.0)      # 缺口宽度从 0 到 1m
    step_height = (0.0, 0.5)    # 台阶高度从 0 到 50cm
    slope_angle = (0.0, 30.0)   # 坡度从 0 到 30°

    # 课程推进规则:当 70% 的机器人在当前级别存活 > 500 步时,升级
    success_threshold = 0.7
    min_episode_length = 500

    # 重要:地形课程在 Phase 2 中继承,不从零开始
    inherit_from_phase1 = True

课程推进使用"粒子滤波"式的方法——把 4096 个并行环境看作 4096 个"粒子",每个粒子有一个当前难度级别。当一个粒子在当前级别存活足够长,它"升级"到更难的级别;如果一个粒子在某级别持续失败,它"降级"到更简单的级别。这种自适应课程避免了手动设定"什么时候切换难度"的问题。

完整的 Phase 1 训练配方(recipe)

# Phase 1 base policy 训练(官方 README 命令)
python train.py --exptid xxx-xx-WHATEVER --device cuda:0
# 官方推荐:训练 10-15k iterations(在 3090 上约 8-10 小时,建议至少 15k)
# 注意:README 并没有 --num_envs / --max_iterations / --terrain_curriculum / --log wandb
#       这些参数;num_envs、max_iterations 等需在配置文件中修改,wandb 默认开启(--no_wandb 关闭)

下表是本教程的调参建议起点,不是 extreme-parkour README 的官方参数(官方 base config 默认 num_envs=6144、PPO learning_rate=2e-4num_steps_per_env=24):

参数 官方默认 / 教学建议 调整方向
num_envs 官方 6144(显存不足可减) 内存不足 → 减小
max_iterations 10-15k(官方建议 ≥15k) 未收敛 → 增大
learning_rate 官方 2e-4 不稳定 → 更小
num_steps_per_env 官方 24
terrain curriculum 启用(按前进距离升降级) 见下

Phase 1 地形课程的升降级规则(论文):当某个环境的机器人前进距离超过地形段长度一半时,升级到更难地形;当前进距离小于期望距离 v_cmd · T 的一半时,降级。论文未给出"70% 存活 > 500 步"这类固定阈值,不要照搬。

Phase 2:Depth Vision Distillation

Phase 2 将 Phase 1 的 blind oracle 蒸馏到 depth camera student。核心是 ConvNet + GRU 编码器和 MTS(Mixture of Teacher and Student)策略。

训练命令

# Phase 2:depth 蒸馏
# 注意:续行反斜杠后不能再接行内注释,注释需另起一行
python train.py --exptid yyy-yy-WHATEVER \
  --device cuda:0 \
  --resume --resumeid xxx-xx \
  --delay \
  --use_camera
# --resume --resumeid xxx-xx : 从 Phase 1 恢复
# --delay                    : 启用 depth 延迟模拟
# --use_camera               : 启用深度相机渲染

--resume --resumeid xxx-xx 从 Phase 1 的 checkpoint 恢复——这不仅加载 teacher 的权重,还复制 Phase 1 的 MLP 策略权重作为 student MLP 的初始化。CNN 和 GRU 从随机初始化开始,但 MLP 的良好初始化确保 student 在 CNN 输出有意义之前就有合理的动作输出。

--delay 启用 depth 延迟模拟——在 student 的 depth 输入中注入 1-2 帧延迟,模拟真实相机的处理和通信延迟。

--use_camera 启用渲染管线——IsaacGym 开始为每个环境渲染 depth 图。这显著降低了训练吞吐(从 ~20k FPS 降到 ~3k FPS)。

ConvNet 架构(官方 depth_backbone.py): 官方 DepthOnlyFCBackbone58x87 是两层卷积 + MaxPool + 两层 Linear;时序部分 RecurrentDepthBackboneGRU(input_size=32, hidden_size=512),输出 32+2

# extreme-parkour 官方 depth backbone(DepthOnlyFCBackbone58x87)
class DepthOnlyFCBackbone58x87(nn.Module):
    def __init__(self, scandots_output_dim, num_frames=1):
        super().__init__()
        self.image_compression = nn.Sequential(
            nn.Conv2d(in_channels=num_frames, out_channels=32, kernel_size=5),
            nn.MaxPool2d(kernel_size=2, stride=2),
            activation,
            nn.Conv2d(in_channels=32, out_channels=64, kernel_size=3),
            activation,
            nn.Flatten(),
            nn.Linear(64 * 25 * 39, 128),     # 注意展平维度是 64*25*39
            activation,
            nn.Linear(128, scandots_output_dim),
        )

# RecurrentDepthBackbone:把 depth 特征与 proprio 拼接后过 GRU
#   combine: Linear(32 + n_proprio, 128) → act → Linear(128, 32)
#   self.rnn = nn.GRU(input_size=32, hidden_size=512, batch_first=True)
#   output:  Linear(512, 32+2)  (后 2 维用于 heading 预测)

MTS 实现(按论文公式): 论文的 MTS 不是线性插值退火,而是一个阈值切换——当 student 预测的 heading 与 oracle 偏差小于 0.6(rad)时就用 student 自己的预测,否则回退到 oracle heading:

# Mixed Teacher-Student(论文公式:阈值切换,不是 alpha 线性混合)
def get_heading_command(student_heading, oracle_heading, threshold=0.6):
    """
    若 |student_heading - oracle_heading| < threshold:信任 student 预测;
    否则:回退到 oracle heading,防止早期分布漂移。
    (论文 obs_θ = θ_pred if |θ_pred − d̂_w| < 0.6 else d̂_w)
    """
    use_student = (student_heading - oracle_heading).abs() < threshold
    return torch.where(use_student, student_heading, oracle_heading)

Phase 2 训练时间:README 推荐 distillation policy 为 5-10k iterations,5-10 hours on RTX 3090(8-10 hours 对应的是 base policy 的 10-15k iterations)。

Phase 1 + Phase 2 的完整数据流

Phase 1 (scandots oracle,官方 base config 维度):
  obs = [proprio(n_proprio=53) + scandots(n_scan=132)]
    scan encoder dims [128,64,32]; actor hidden dims [512,256,128] → action(12)
  Training: PPO, num_envs=6144(官方默认), 10-15k iters

Phase 2 (depth student,官方 depth_backbone.py):
  depth(58×87) → Conv2d(num_frames→32,k5)+MaxPool2d(2)+Conv2d(32→64,k3)
    → Linear(64*25*39→128) → Linear(128→scandots_dim)   # DepthOnlyFCBackbone58x87
  RecurrentDepthBackbone: GRU(input_size=32, hidden_size=512) → 输出 32+2
  proprio + depth latent → concat → actor → action(12)
  Training: DAgger + MTS, 5-10k iters
  Teacher: Phase 1 frozen, provides action labels + heading

向双框架的迁移路径

extreme-parkour 基于 IsaacGym(已停止更新),但其工程模式可以直接迁移到 mjlab 和 Isaac Lab:

extreme-parkour (IsaacGym) mjlab 对应 Isaac Lab 对应
legged_gym env class Manager-based env Manager-based env
go1_config.py env_cfg.py env_cfg.py + InteractiveSceneCfg
rsl_rl PPO RSL-RL PPO(相同后端) RSL-RL / rl_games
IsaacGym camera MuJoCo Warp Batch Renderer TiledCameraCfg
scandots (custom) RaycastSensorCfg RaycastSensorCfg

迁移的关键差异: 1. 渲染 API:IsaacGym 的相机 API 与 Isaac Lab 的 TiledCamera 不同——数据获取方式和 tensor 格式需要适配 2. terrain generation:IsaacGym 的 terrain 是 triangular mesh,Isaac Lab 支持 procedural terrain + USD import,mjlab 使用 MuJoCo 的 hfield 3. 训练循环:DAgger 蒸馏循环在 RSL-RL 中不是内置功能——需要自定义 runner 或使用 RSL-RL 的 student-teacher distillation 扩展

迁移到 mjlab 的具体步骤

Step 1:资产迁移
  - Go1 URDF → MJCF(mjlab 提供的 menagerie 已包含 Go1)
  - 地形从 Isaac trimesh → MuJoCo hfield(高度场)
  - 相机从 IsaacGym camera → MJCF <camera> 标签

Step 2:观测迁移
  - scandots → mjlab 的 RaycastSensorCfg
  - proprio terms → mjlab 的 ObservationManager terms
  - 确认 obs 维度一致(打印对比)

Step 3:Reward 迁移
  - IsaacGym 的 reward 函数 → mjlab 的 RewardManager terms
  - 注意 reward 中的坐标系差异(IsaacGym 使用 base frame,mjlab 使用 world frame)
  - 确认 waypoint 命令系统的实现一致

Step 4:训练管线迁移
  - Phase 1:直接使用 RSL-RL PPO(API 几乎一样)
  - Phase 2:需要自定义 DAgger runner(RSL-RL 3.x 的 Student-Teacher 扩展)
  - 确认 CNN 网络结构和初始化方式一致

Step 5:验证
  - Phase 1 的 episode length 与原始论文对齐(±20% 范围内正常)
  - Phase 2 的 MSE loss 收敛到类似水平
  - Play 可视化中策略行为与论文视频一致

迁移到 Isaac Lab 的额外优势:Isaac Lab 提供了 procedural terrain generation(台阶、坡面、缺口的参数化生成),比 IsaacGym 的手动 trimesh 更灵活。此外 Isaac Lab 的 TiledCamera 比 IsaacGym 的相机 API 吞吐更高(得益于 RTX 渲染器的优化)。如果你的最终目标是 RGB sim-to-real,Isaac Lab + Replicator API 是更好的平台。

一个常见的迁移陷阱:坐标系差异。 IsaacGym 的 reward 函数中,velocity tracking 使用 base frame 的线速度;extreme-parkour 论文中明确指出用的是 world frame("Note that [7] tracks velocity in the base frame but world frame is used")。如果你在迁移时没有注意这个差异,策略会学到"绕圈走"而非"直线通过障碍物"——因为 base frame 的速度跟踪在转弯时给出错误的 reward 信号。

⚠️ 常见陷阱

⚠️ 编程陷阱:Phase 2 的地形课程不应从零开始

Phase 2 应该继承 Phase 1 的地形课程进度——student 应该从 teacher 已经掌握的地形难度开始训练,而非从最简单的平地重新开始。否则 student 会在简单地形上过度训练,在复杂地形上训练不足。

💡 概念误区:认为"只需改 obs 就能从 Phase 1 切到 Phase 2"

Phase 2 不只是改了 observation——它改变了整个训练范式(从 PPO 到 DAgger)、网络架构(加入 CNN + GRU)、num_envs(减少)和训练目标(从 reward 最大化到 action MSE 最小化)。这是两个完全不同的训练流程。

练习

  1. [源码阅读题] 在 extreme-parkour 的代码中找到 MTS 的实现位置。\(\alpha\) 的退火速率是多少?如何从配置文件中修改?
  2. [迁移题] 写出将 extreme-parkour 的 Phase 1 迁移到 mjlab 需要修改的 5 个核心配置项。
  3. [设计题] 如果你想把 extreme-parkour 扩展到人形机器人(如 G1),Phase 1 和 Phase 2 各需要做什么修改?从 obs 维度、action 维度、地形类型和相机安装位置四个维度分析。

18.8 视觉 Sim-to-Real 工程 ⭐⭐

这一节解决什么问题:depth student 在仿真中训练好了,如何部署到真实机器人上?真实 depth sensor 与仿真 depth 有什么差异?如何弥补?

真实 Depth Sensor 的特性

真实 depth sensor(如 Intel RealSense D435、Orbbec Astra)的输出与仿真中的"完美 depth"有以下差异:

差异维度 仿真 Depth 真实 Depth 工程影响
噪声 距离相关高斯(近处 ~2mm,远处 ~14mm) 需要 DR 覆盖
空洞 反射面/边缘处有大量空洞 需要 dropout DR 覆盖
帧率 与仿真同步 30-90 Hz,有抖动 需要延迟 DR 覆盖
分辨率 可设任意值 传感器固定(如 640×480) 需要降采样到训练分辨率
视野 精确内参 存在畸变 需要内参 DR 覆盖
坐标系 与仿真一致 可能有偏差 需要标定

相机标定与外参对齐

部署前最关键的工程步骤是确保真实相机的外参与仿真中的一致。如果仿真中相机安装在 (0.3, 0, 0.05) 朝前,真实相机的安装位置偏差 > 2cm 或朝向偏差 > 5°,策略的地形感知会产生系统性偏差。

# 外参对齐的验证方法
def verify_camera_extrinsics(sim_depth, real_depth, robot_state):
    """对比仿真和真实 depth 在同一机器人状态下的输出。

    步骤:
    1. 把真实机器人放在已知地形上(如一个台阶前 1m 处)
    2. 在仿真中创建相同的场景和机器人状态
    3. 对比两者的 depth 图——台阶边缘在图像中的位置应一致
    4. 如果不一致,调整仿真中的相机外参直到匹配
    """
    # 计算台阶边缘在两个 depth 图中的像素位置
    sim_edge = find_edge_position(sim_depth)
    real_edge = find_edge_position(real_depth)
    offset = real_edge - sim_edge
    print(f"Edge position mismatch: {offset} pixels")
    # 如果偏差 > 5 pixels,需要调整外参

Depth 预处理管线

真实 depth 到策略输入的完整预处理管线:

RealSense D435 output: uint16, 640×480, mm 单位
    → 转换为 float32, m 单位(/1000.0)
    → 裁剪到有效范围 [0.01, 5.0] m
    → 降采样到训练分辨率(如 64×64)
    → 归一化到 [0, 1]:(depth - near) / (far - near)
    → 输入 CNN

每一步都必须与训练时的预处理完全一致。最常见的 sim-to-real 失败原因就是预处理不匹配——训练时 depth 单位是 m、归一化到 [0, 1],部署时 depth 单位是 mm、没有归一化。

部署频率与延迟预算

环节 典型耗时 累积延迟
Depth sensor 采集 10-33 ms(30-90 Hz) 10-33 ms
USB/网络传输 1-5 ms 11-38 ms
预处理(降采样+归一化) 0.5-1 ms 11.5-39 ms
CNN + GRU 推理 1-3 ms(GPU)/ 5-15 ms(CPU) 12.5-54 ms
MLP 推理 0.1 ms 12.6-54.1 ms
动作发送到电机 0.5-1 ms 13.1-55.1 ms

总延迟 13-55 ms,对 50 Hz 控制频率(20 ms/step)来说可能超过一个步长。解决方案: 1. CNN 推理和电机控制异步执行——CNN 在后台线程跑,MLP 用最新可用的 CNN 特征 2. 训练时加入 1-3 帧延迟 DR(已在 §18.6 讨论) 3. 使用 ONNX + TensorRT 加速推理

⚠️ 常见陷阱

⚠️ 编程陷阱:部署时 depth 的 uint16 → float32 转换溢出

RealSense 输出 uint16(0-65535),直接除以 1000 后值域是 0-65.535 m。但训练时 depth 归一化假设的范围是 0.01-5.0 m——超出 5.0m 的值应该被 clip 而非直接送入 CNN。如果不 clip,CNN 看到的输入分布与训练时完全不同。

💡 概念误区:认为"在仿真中用了 depth DR 就不需要真机标定"

Depth DR 覆盖的是"随机性"——传感器噪声、小幅外参偏差。它不覆盖"系统性偏差"——如相机安装在错误的位置、使用了错误的内参。系统性偏差需要通过标定来消除,DR 只处理标定后的残余不确定性。

练习

  1. [工程题] 编写一个 RealSense D435 的 depth 预处理管线(Python + pyrealsense2),输出与训练时一致的 tensor 格式。包括:获取深度帧、转换单位、降采样、裁剪、归一化。
  2. [分析题] 计算从 depth 采集到动作输出的总延迟预算。如果控制频率是 50 Hz,CNN 推理耗时 10 ms,最大允许的传感器采集+传输延迟是多少?

ONNX 导出与 TensorRT 加速:部署管线的工程实现

训练好的视觉策略需要导出为可在真实机器人上高效运行的格式。标准路径是 PyTorch → ONNX → TensorRT。

为什么不直接用 PyTorch 推理? PyTorch 的动态图机制在训练时灵活但在推理时引入了额外开销——每次前向传播都需要解释计算图。对 50 Hz 控制的 locomotion,20ms 的控制周期内 PyTorch 的 CNN 推理可能需要 5-15 ms(取决于模型大小和 GPU)。ONNX + TensorRT 通过静态编译优化可以将推理时间压缩到 1-3 ms——为传感器采集和通信留出更多余量。

Step 1:PyTorch → ONNX 导出

import torch

# 加载训练好的 student 策略
student = DepthStudent.load_from_checkpoint("phase2_best.pt")
student.eval()

# 定义输入示例——ONNX 需要知道输入 shape
dummy_depth = torch.randn(1, 1, 64, 64)       # [B, C, H, W]
dummy_proprio = torch.randn(1, 48)             # [B, proprio_dim]
dummy_hidden = torch.zeros(1, 1, 64)           # [num_layers, B, gru_hidden]

# 导出 ONNX
# 注意:如果模型包含 GRU,需要特殊处理——
# ONNX 不支持隐式的 hidden state 维护,必须显式传入/传出
torch.onnx.export(
    student,
    (dummy_depth, dummy_proprio, dummy_hidden),
    "visual_policy.onnx",
    input_names=["depth", "proprio", "hidden_in"],
    output_names=["action", "hidden_out"],
    dynamic_axes={
        "depth": {0: "batch"},
        "proprio": {0: "batch"},
        "hidden_in": {1: "batch"},
    },
    opset_version=17,
)
print("ONNX export successful")

GRU/LSTM 导出的工程陷阱:ONNX 对 RNN 的支持有版本依赖——opset 14+ 支持 GRU/LSTM 的标准导出,但 TensorRT 对某些 RNN 变体(如 bidirectional GRU)的支持不完整。如果导出后 TensorRT 报错,尝试将 GRU 展开为手动的矩阵运算(input @ weight_ih + hidden @ weight_hh + bias),这虽然代码量大但 TensorRT 兼容性更好。

Step 2:ONNX → TensorRT 引擎

# 使用 trtexec 将 ONNX 转换为 TensorRT 引擎
# --fp16 启用半精度推理(速度 2× 但精度足够)
# 注意 workspace 参数随版本变化:
#   TensorRT 8.x/9.x:  --workspace=1024            (单位 MB)
#   TensorRT 10.x:     --memPoolSize=workspace:1024  (--workspace 已被替换)
trtexec --onnx=visual_policy.onnx \
        --saveEngine=visual_policy.trt \
        --fp16 \
        --memPoolSize=workspace:1024

Step 3:真机推理循环

# 真机部署的推理循环(伪代码)
import tensorrt as trt
import numpy as np

class VisualPolicyDeployer:
    def __init__(self, engine_path):
        # 加载 TensorRT 引擎
        self.engine = load_trt_engine(engine_path)
        self.context = self.engine.create_execution_context()
        # 初始化 GRU 隐状态
        self.hidden = np.zeros((1, 1, 64), dtype=np.float32)

    def infer(self, depth_frame, proprio):
        """单步推理:depth + proprio → action。

        Args:
            depth_frame: np.array [1, 1, 64, 64] float32
            proprio: np.array [1, 48] float32
        Returns:
            action: np.array [1, 12] float32
        """
        # 拷贝输入到 GPU
        inputs = {
            "depth": depth_frame,
            "proprio": proprio,
            "hidden_in": self.hidden,
        }

        # TensorRT 推理(伪代码占位)
        # 真实 TensorRT API 不接受 dict、也不直接返回 dict:需要为每个 I/O tensor
        # 分配 device memory,用 context.set_tensor_address(name, ptr) 绑定地址,
        # 再 context.execute_async_v3(stream)(或旧版 execute_v2(bindings)),
        # 然后从对应 device buffer 拷回结果。下面仅示意数据流。
        outputs = self.context.execute(inputs)  # ← 占位,非可运行 API

        # 更新 GRU 隐状态
        self.hidden = outputs["hidden_out"]

        return outputs["action"]

    def reset(self):
        """Episode 结束时重置 GRU 隐状态"""
        self.hidden = np.zeros((1, 1, 64), dtype=np.float32)

部署架构:异步 CNN + 同步 MLP

真机部署架构(推荐):

Thread 1 (高频, 50Hz): MLP-only 推理
  每 20ms:
    → 读取最新 proprio
    → 读取最新 CNN 特征(来自 Thread 2)
    → MLP 推理 → action
    → 发送 action 到电机

Thread 2 (低频, 15-30Hz): CNN + GRU 推理
  每 33-66ms:
    → 读取最新 depth frame
    → CNN + GRU 推理 → visual_feature
    → 写入 shared buffer(Thread 1 读取)

这种异步架构的工程优势:MLP 推理极快(~0.1 ms),可以保持 50 Hz 控制频率不受 CNN 推理时间影响。CNN 以较低频率(15-30 Hz)更新视觉特征——因为地形几何在 33-66 ms 内变化不大。代价是视觉信息有 1-2 帧延迟——这与训练时的 delay DR 一致,策略已经学会了在延迟下工作。

extreme-parkour 的真机部署使用了类似的异步架构——按论文,depth backbone 与 base policy 都跑在 Jetson NX 上并通过 UDP 通信:depth backbone 以 10 Hz 运行,base policy 以 50 Hz 运行(相机 D435 输出 10±2 Hz)。

真机调试的紧急诊断流程

当视觉策略在真机上第一次运行时,通常不会立即正常工作。以下是按优先级排序的紧急诊断步骤:

Step 1(1 分钟):验证电机能动。 不加载视觉策略,用一个简单的正弦波关节命令确认电机控制链路正常。如果电机不动,问题在通信层而非策略。

Step 2(2 分钟):验证 depth 数据。 保存一帧 depth 到磁盘,用 matplotlib 可视化。确认:(a) 图像内容合理(能看到周围环境),(b) depth 值在预期范围内(如 0.1-3.0 m),(c) 没有大量无效像素(NaN/0)。如果 depth 全黑或全白,检查相机驱动和 USB 连接。

Step 3(5 分钟):对比 sim vs real depth。 把真机放在一个已知的简单场景中(如面前 1m 处一面白墙),在仿真中创建相同场景。对比两个 depth 图——如果 depth 值范围、物体在图像中的位置、或 depth 的值域差异显著,说明预处理不一致或外参有偏差。

Step 4(10 分钟):用 teacher 验证。 如果可能,在真机上运行 height scan teacher(需要外部传感器提供地形信息,如 LiDAR 或人工构建的 height map)。如果 teacher 在真机上工作但 student 不工作,问题在 CNN 的 sim-to-real gap。如果 teacher 也不工作,问题在物理层(电机延迟、通信丢包、物理参数不匹配)。


18.9 视觉策略诊断与调试 ⭐⭐

这一节解决什么问题:当视觉 locomotion 策略不工作时,如何系统性定位问题?

消融实验矩阵

视觉策略的调试比盲策略困难得多——失败可能发生在管线的任何一环。系统性消融是定位问题的唯一可靠方法。

消融 验证的假设 保持不变 关键指标
Height scan baseline 任务和 reward 可行 action 空间 episode length
Depth student (no DR) CNN 能学到地形特征 分辨率、架构 MSE loss 收敛
Depth student (with DR) DR 帮助而非破坏 同上 success vs baseline
Resolution sweep (32/64/128) 分辨率够用 CNN 结构 success vs steps/s
Latency injection (0/1/3 frame) 延迟鲁棒性 checkpoint episode length vs delay
Camera pose perturbation 外参鲁棒性 checkpoint success vs perturbation

消融的执行纪律:每次只改一个变量,每组跑 3 个 seed,报告均值±标准差。如果两组实验的差异在标准差范围内,不要下结论。

视觉策略的五层诊断流程

Layer 1:任务可行性(5 min)
  □ height scan teacher 的 episode length > 目标值
  → 如果不通过:问题在 reward/terrain,不是视觉

Layer 2:渲染正确性(5 min)
  □ 保存一帧 depth 图到磁盘,用 matplotlib 可视化
  □ depth 值在合理范围内(如 0.01-5.0 m)
  □ 地形轮廓在 depth 图中清晰可见
  → 如果不通过:检查 --enable_cameras、clipping_range、camera pose

Layer 3:CNN 学习(训练初期 500 iter)
  □ CNN 第一层权重的梯度范数 > 0
  □ MSE loss 在下降
  □ CNN 输出的方差随训练增加(从随机初始化的低方差 → 有意义的高方差)
  → 如果不通过:检查 CNN lr、depth 预处理、obs group 配置

Layer 4:蒸馏质量(训练中期 2k iter)
  □ student 在仿真中的 episode length 达到 teacher 的 60%+
  □ play 可视化中步态合理(无明显抖动或摔倒)
  □ SpatialSoftmax 关键点(如使用)在地形边缘附近
  → 如果不通过:检查 DAgger vs offline BC、MTS 退火速率、GRU hidden reset

Layer 5:sim-to-real(部署时)
  □ 真实 depth 图与仿真 depth 图在同一场景下视觉相似
  □ CNN 输出在真实和仿真 depth 上的分布相似
  □ 机器人在简单地形(平地+小台阶)上行为正常
  → 如果不通过:检查外参标定、depth 预处理一致性、延迟预算

关键诊断工具:可视化 CNN 特征

最强大的视觉调试工具是可视化 CNN 的中间层输出或 SpatialSoftmax 的关键点。如果使用 SpatialSoftmax:

def visualize_depth_features(depth_img, keypoints, save_path):
    """在 depth 图上叠加 SpatialSoftmax 关键点。

    关键点应该落在地形边缘附近——
    如果散落在平坦区域,说明 CNN 没学到有用特征。
    """
    import matplotlib.pyplot as plt
    fig, ax = plt.subplots()
    ax.imshow(depth_img.squeeze(), cmap="viridis")
    H, W = depth_img.shape[-2:]
    for i, (kx, ky) in enumerate(keypoints):
        px = (kx + 1) / 2 * W
        py = (ky + 1) / 2 * H
        ax.plot(px, py, "ro", markersize=4)
        ax.annotate(str(i), (px, py), fontsize=6, color="white")
    fig.savefig(save_path)
    plt.close(fig)

如果不使用 SpatialSoftmax(如 extreme-parkour 的 flatten + GRU),可以用 Grad-CAM 可视化 CNN 关注的区域——梯度大的区域是 CNN 认为对动作决策最重要的区域。

Grad-CAM 的简化实现(适用于 locomotion CNN):

def grad_cam_locomotion(model, depth_input, proprio_input, target_action_dim=0):
    """计算 CNN 对特定动作维度的 Grad-CAM 热力图。

    对 locomotion:target_action_dim=0 通常是前进速度控制,
    Grad-CAM 高亮的区域 = 对前进决策影响最大的 depth 区域。

    Args:
        model: LocomotionVisualPolicy
        depth_input: [1, 1, 64, 64] 单帧 depth
        proprio_input: [1, 48]
        target_action_dim: 关注哪个动作维度
    Returns:
        heatmap: [64, 64] 归一化的热力图
    """
    model.eval()

    # 注册 hook 捕获最后一层 conv 的 feature map
    activations = {}
    gradients = {}

    def fwd_hook(module, input, output):
        activations["last_conv"] = output

    def bwd_hook(module, grad_input, grad_output):
        gradients["last_conv"] = grad_output[0]

    # 假设最后一层 conv 是 model.visual_encoder.conv3
    handle_fwd = model.visual_encoder.conv3.register_forward_hook(fwd_hook)
    handle_bwd = model.visual_encoder.conv3.register_full_backward_hook(bwd_hook)

    # 前向传播
    action = model(depth_input, proprio_input)

    # 对目标动作维度反向传播
    model.zero_grad()
    action[0, target_action_dim].backward()

    # 计算 Grad-CAM
    grads = gradients["last_conv"]     # [1, C, H', W']
    acts = activations["last_conv"]    # [1, C, H', W']
    weights = grads.mean(dim=[2, 3])   # [1, C] — 全局平均池化梯度

    cam = (weights.unsqueeze(-1).unsqueeze(-1) * acts).sum(dim=1)  # [1, H', W']
    cam = F.relu(cam)  # 只关注正响应
    cam = F.interpolate(cam.unsqueeze(0), size=(64, 64), mode="bilinear")
    cam = cam.squeeze() / cam.max()  # 归一化到 [0, 1]

    handle_fwd.remove()
    handle_bwd.remove()

    return cam.detach().cpu().numpy()

Grad-CAM 的诊断解读:如果 Grad-CAM 高亮区域在地形边缘(台阶沿、缺口边缘),说明 CNN 学到了正确的特征。如果高亮区域在平坦地面或天空区域,说明 CNN 学到了无意义的相关性——可能是 depth 预处理有问题(如归一化不正确导致远处平地值也有梯度信号)。

训练过程的关键指标监控

在蒸馏训练过程中,应该监控以下指标(通过 TensorBoard 或 WandB):

# Stage 2 蒸馏训练中应记录的指标
metrics_to_log = {
    # 核心 loss
    "distill/mse_loss": mse_loss.item(),
    "distill/action_l1_error": l1_error.item(),

    # CNN 健康指标
    "cnn/conv1_grad_norm": conv1_grad.norm().item(),
    "cnn/conv3_grad_norm": conv3_grad.norm().item(),
    "cnn/feature_std": cnn_output.std().item(),
    # feature_std 应该随训练逐步增加——
    # 如果持续为零说明 CNN 没学到任何东西

    # 策略质量指标
    "policy/episode_length_mean": ep_len.mean().item(),
    "policy/episode_length_std": ep_len.std().item(),
    # episode length 应该逐步接近 teacher

    # MTS 指标(如使用)
    "mts/alpha": alpha,
    "mts/heading_prediction_error": heading_err.item(),

    # DR 指标
    "dr/depth_noise_std": current_noise_std,
    "dr/camera_offset_magnitude": cam_offset.norm().item(),
}

关键警示信号

指标 正常范围 异常信号 可能原因
mse_loss 持续下降 下降后反弹 CNN lr 太大,过拟合
conv1_grad_norm > 0 且稳定 为零 depth 未连接到 obs / CNN 冻结
feature_std 逐步增加 持续接近零 CNN 没学到 / depth 全为常数
episode_length 逐步增加 持平或下降 DAgger 分布漂移 / DR 太强
heading_error (MTS) 逐步减小 持平或增大 MTS α 退火太快

常见失败模式的根因分析

失败模式一:MSE loss 低但 episode length 不增长。

这是最常见也最令人困惑的失败模式。Student 在训练数据上完美模仿了 teacher 的动作(MSE 低),但实际执行时表现差(episode length 短)。

根因分析路径:

MSE loss 低但 episode length 短
├── Student 执行时的状态分布与训练分布不同
│   ├── 原因:使用了 offline BC 而非 DAgger
│   └── 解决:切换到 DAgger(用 student 执行 rollout)
├── Teacher 在某些关键状态下的动作方差大
│   ├── 原因:teacher 在台阶边缘的最优动作有多种等效选择
│   │         student 学到了"平均动作"(而非任一等效选择)
│   └── 解决:使用 L1 loss 替代 MSE(对异常值更鲁棒)
│             或对关键时刻增大 loss 权重
└── CNN 的时序记忆不足
    ├── 原因:GRU hidden size 太小,无法记住足够多的地形历史
    └── 解决:增大 GRU hidden size(64→128)或增加帧堆叠

失败模式二:Student 在平地正常但在障碍物前摔倒。

根因分析路径:

平地正常 + 障碍物摔倒
├── CNN 没看到障碍物
│   ├── 原因 A:depth 预处理的 far_clip 太小(障碍物在远处被截断)
│   ├── 原因 B:相机视角太低(障碍物在图像顶部被截断)
│   └── 诊断:保存障碍物前 1m 处的 depth 图,确认障碍物可见
├── CNN 看到了但没有学到
│   ├── 原因:训练地形中障碍物类型不够多样
│   └── 诊断:用 Grad-CAM 确认 CNN 是否关注障碍物区域
└── CNN 学到了但时机不对
    ├── 原因:GRU 对"还有多远到障碍物"的时序估计不准
    └── 诊断:在 play 中逐帧打印 CNN 输出的关键特征值
              观察是否在接近障碍物时有显著变化

失败模式三:Student 步态抖动严重。

根因分析路径:

步态抖动
├── SpatialSoftmax 关键点在帧间跳变
│   ├── 原因:温度 τ 太小,softmax 太尖锐
│   └── 解决:增大 τ 到 2.0-3.0
├── CNN 输出的 feature vector 不稳定
│   ├── 原因:CNN 过拟合到 depth 的高频噪声
│   └── 解决:增加 depth 高斯噪声 DR
├── action_rate 惩罚不足
│   ├── 原因:蒸馏 loss 中没有 action smoothness 约束
│   └── 解决:在 loss 中加入 ||a_t - a_{t-1}||^2 项
└── MLP 部分权重未从 teacher 初始化
    ├── 原因:Phase 2 启动时只复制了 teacher MLP,但复制不完整
    └── 诊断:对比 student MLP 前几层权重与 teacher 是否一致

⚠️ 常见陷阱

⚠️ 编程陷阱:诊断时用 play 模式但忘记加载 GRU 隐状态

如果 play 时 GRU 的隐状态从零开始,策略的前几步会因为没有时序记忆而动作异常。这不是 bug——而是 GRU 需要几步"热身"来积累足够的地形信息。正确评估方式:让机器人先在平地上走 20-30 步(GRU 热身),然后再进入复杂地形。

💡 概念误区:认为"MSE loss 低就说明蒸馏成功"

MSE loss 低只说明 student 在训练分布上模仿了 teacher 的动作。但如果训练分布太窄(只覆盖了少数地形类型),student 在其他地形上可能完全失效。正确的评估方式是在多种地形上测试 episode length,而非只看 MSE loss。

练习

  1. [实验题] 对你训练好的 depth student 进行 latency injection 测试:分别注入 0、1、3、5 帧延迟,记录 episode length。画出延迟-性能曲线。哪个延迟值是"性能悬崖"?
  2. [诊断题] 你的 depth student 的 MSE loss 很低,但 play 时机器人在台阶前犹豫不前(不迈步)。列出 3 个可能的原因和对应的检查步骤。

18.10 Foundation Model 替代方案:从自训练 CNN 到 DINOv2 ⭐⭐

这一节解决什么问题:自训练的小 CNN(2-3 层)是当前标准,但预训练 Foundation Model(如 DINOv2、ViT)是否能提供更好的视觉特征?这对 locomotion 的工程管线意味着什么?

自训练 CNN vs 预训练 Foundation Model

当前的视觉 locomotion 标准管线使用从零训练的小 CNN(如 extreme-parkour 的 2 层 ConvNet)——CNN 的权重完全由蒸馏 loss(模仿 teacher 动作)塑造。这种"task-specific"的 CNN 在目标任务上非常高效,但有两个局限:

  1. 迁移性差:为一个地形类型训练的 CNN 特征无法直接迁移到另一个地形类型——换一种障碍物可能需要重新蒸馏。
  2. 语义理解缺失:自训练 CNN 只学到了与当前 reward 相关的几何特征,不理解"这是台阶"vs"这是裂缝"的语义区别。

Foundation Model(如 DINOv2、SAM、CLIP)在大规模数据上预训练,学到了通用的视觉特征——包括几何结构、语义类别、深度估计等。这些特征可能无需蒸馏就直接用于 locomotion。

DINOv2 frozen encoder 的工程方案:

# DINOv2 作为 frozen 视觉编码器
import torch

class DINOv2LocomotionPolicy(nn.Module):
    def __init__(self, proprio_dim=48, action_dim=12):
        super().__init__()
        # 加载预训练 DINOv2(冻结权重)——Meta 官方加载方式是 torch.hub
        #   (torchvision 没有 vit_small_patch14_dinov2 + pretrained=True 这种写法;
        #    timm/HuggingFace 则用 'vit_small_patch14_dinov2.lvd142m' 之类的模型名)
        self.visual_encoder = torch.hub.load(
            'facebookresearch/dinov2', 'dinov2_vits14'
        )  # ViT-S/14,约 22M 参数
        self.visual_encoder.eval()
        for param in self.visual_encoder.parameters():
            param.requires_grad = False

        # DINOv2 ViT-S 输出 384 维特征
        visual_dim = 384

        # 可训练的策略头
        self.policy_mlp = nn.Sequential(
            nn.Linear(visual_dim + proprio_dim, 256),
            nn.ELU(),
            nn.Linear(256, 128),
            nn.ELU(),
            nn.Linear(128, action_dim),
        )

    def forward(self, depth_rgb, proprio):
        """
        depth_rgb: 需要将单通道 depth 复制为 3 通道
                   因为 DINOv2 期望 3-channel 输入
        """
        with torch.no_grad():
            # DINOv2 期望 [B, 3, 224, 224]——需要 resize
            visual_feat = self.visual_encoder(depth_rgb)  # [B, 384]

        combined = torch.cat([visual_feat, proprio], dim=-1)
        return self.policy_mlp(combined)

DINOv2 的工程代价

维度 自训练 2-layer CNN DINOv2 ViT-S (frozen)
参数量 ~50K ~22M(frozen,不占 GPU 内存梯度)
推理时间 ~0.5 ms ~5-10 ms
输入分辨率要求 任意(如 64×64) 固定 224×224(需要 resize)
训练时间 需要完整蒸馏(12-24h) 只训练 MLP 头(2-4h)
特征质量 task-specific,对当前任务最优 通用,可能不如 task-specific
泛化性 差(换任务需重训) 好(通用特征)

当前的工程结论:对 depth-only 的标准 locomotion 任务(台阶、缺口、坡面),自训练小 CNN 仍然是最佳选择——它更快、更小、更容易部署。DINOv2 的价值主要在以下场景:

  1. 需要语义理解:如区分可通行(草地)和不可通行(水面)区域——DINOv2 的预训练特征包含语义信息,自训练 CNN 没有。
  2. 需要跨任务泛化:一个视觉 encoder 服务多个下游任务(locomotion + manipulation + navigation)——DINOv2 的通用特征可以被多个任务头共享。
  3. RGB sim-to-real:DINOv2 在大量真实图像上预训练,其特征对光照/材质变化本身就很鲁棒——可能减少对 RGB DR 的依赖。

向前预告:Foundation Model 在 locomotion 中的应用是 2025-2026 年的前沿方向。LocoMamba (Wang & Tao, Advanced Engineering Informatics 2026) 使用 Mamba 架构替代 CNN+GRU,TTT-Parkour (2026) 在部署前用真实场景的 3D 重建做 test-time training。这些方法都超出了本章的范围,但它们的工程基础(depth 预处理、teacher-student 管线、visual DR)与本章完全相同——掌握了本章的工程管线,就具备了理解和实现这些前沿方法的能力。

⚠️ 常见陷阱

💡 概念误区:认为"Foundation Model 一定比小 CNN 好"

DINOv2 在 ImageNet 上的特征质量碾压 2-layer CNN。但 locomotion 不是 ImageNet——depth 图只有一个通道(不是 3 通道 RGB),分辨率通常只有 64×64(不是 224×224),且关键信息是几何边缘(不是纹理/语义)。在这种条件下,task-specific 的小 CNN 通过蒸馏学到的特征可能比 DINOv2 的通用特征更有效。不要因为"Foundation Model 很热门"就盲目替换已经工作良好的管线。

⚠️ 编程陷阱:DINOv2 输入要求 3-channel 224×224

DINOv2 期望 RGB 输入 [B, 3, 224, 224]。如果你的输入是单通道 depth [B, 1, 64, 64],需要:(1) 复制为 3 通道 [B, 3, 64, 64],(2) resize 到 224×224。resize 引入了额外的插值误差,且 224×224 的渲染成本远高于 64×64。确认这个代价在你的部署延迟预算内。


18.11 视觉 Locomotion 调参参考表 ⭐⭐

这一节解决什么问题:提供一张可快速查阅的参数表,当视觉策略出现特定现象时应该调什么。

全阶段参数汇总

阶段 参数类别 参数名 推荐值 调参方向
Stage 1 地形 height_scan resolution 0.1 m 精度不足→减小,计算太慢→增大
Stage 1 地形 height_scan size (2.0, 1.0) m 看不够远→增大前方,步态不稳→增大侧方
Stage 1 地形 terrain_levels 10 最难地形未学会→增加
Stage 1 训练 num_envs 4096 内存不足→2048
Stage 1 训练 max_iterations 15000 未收敛→20000
Stage 1 训练 learning_rate 1e-3 不稳定→3e-4
Stage 1 Reward tracking_sigma 0.25 跟踪不准→减小,太保守→增大
Stage 1 Reward alive_reward 1.0 策略自杀→增大
Stage 2 渲染 depth resolution 64×64 收敛慢→降到 32×32,精度差→升到 128×128
Stage 2 渲染 num_envs 512-1024 GPU 内存不足→256
Stage 2 CNN num_conv_layers 2-3 特征不够→加层,过拟合→减层
Stage 2 CNN conv_channels 32 特征不够→64,部署太慢→16
Stage 2 CNN cnn_lr_scale 0.1× CNN 不学→增大到 0.3,CNN 不稳定→减小到 0.05
Stage 2 GRU gru_hidden_size 64 记忆不足→128,部署太慢→32
Stage 2 蒸馏 DAgger rollout length 24 steps 分布偏移→增大,计算太慢→减小
Stage 2 蒸馏 MTS alpha 退火步数 5000 iter 退火太快→8000,训练太久→3000
Stage 2 SpatialSoftmax temperature τ 1.0 关键点跳变→增大到 2.0,不定位→减小到 0.5
Stage 2 DR depth_gaussian_std 0.003-0.01 m sim-to-real gap→增大,不收敛→减小
Stage 2 DR depth_dropout_rate 0.01-0.05 sim-to-real gap→增大,不收敛→减小
Stage 2 DR camera_pose_noise 1cm / 1° sim-to-real gap→增大到 2cm/3°
Stage 2 DR depth_delay_frames 1-3 真机延迟大→增大
Deploy 推理 CNN inference freq 15-30 Hz 延迟太大→升频,GPU 不够→降频
Deploy 推理 MLP control freq 50-100 Hz 保持与训练一致
Deploy 预处理 near_clip 0.01 m 与训练一致
Deploy 预处理 far_clip 3.0-5.0 m 与训练一致

症状→参数的快速对照

症状 最可能的原因 首先检查的参数
Phase 1 不收敛 reward 设计 / 地形太难 tracking_sigma, terrain_levels
Phase 2 MSE loss 高 CNN 没学到 / lr 不对 cnn_lr_scale, depth 预处理
Student 平地正常,障碍物摔 GRU 记忆不足 gru_hidden_size, frame_stack
Student 步态抖动 SpatialSoftmax 跳变 / action_rate 惩罚不足 temperature τ, action_rate weight
真机退化 预处理不一致 / 外参偏差 near_clip, far_clip, camera pose
训练极慢 分辨率太高 / envs 太多 depth resolution, num_envs
CNN 梯度为零 obs group 配置错误 camera obs 是否在 actor group 中
启动即报 RuntimeError(相机相关) --enable_cameras 缺失(Isaac Lab 会直接报错而非黑屏) 启动命令参数
depth 图全黑(能跑但无内容) 相机朝向/clip 范围错误 camera pose、clipping_range
延迟测试崩溃 depth delay DR 不足 depth_delay_frames

性能预算参考

配置 GPU num_envs Depth 分辨率 Phase 1 (steps/s) Phase 2 (steps/s)
最小 RTX 3060 1024 32×32 ~30k ~5k
标准 RTX 3090/4090 4096 64×64 ~60k ~12k
高端 A100 / H100 8192 64×64 ~120k ~25k
VIRAL 规模 64× H100 分布式 128×128 ~500k+ ~100k+

工程经验法则:Phase 2 的吞吐通常是 Phase 1 的 1/5 到 1/3——渲染是主要瓶颈。分辨率从 64×64 降到 32×32 通常能提升吞吐 2-3 倍,但可能影响精细地形的感知能力。先用低分辨率快速迭代 reward 和蒸馏配置,确认管线正确后再切换到高分辨率做最终训练。


本章小结

知识点 核心结论 重要程度
盲策略的感知边界 本体感知只能"反应"不能"预测",复杂地形需要视觉 ⭐⭐
Height scan vs depth height scan 是完美特权信息(teacher),depth 是可部署输入(student) ⭐⭐
TiledCamera 配置 Isaac Lab RTX 加速渲染,分辨率-吞吐-精度三角权衡 ⭐⭐
相机坐标系约定 ROS vs OpenGL vs World,OffsetCfg 的 (w,x,y,z) 格式 ⭐⭐
CNN 编码器选型 SpatialSoftmax(定位)vs GAP(全局)vs ConvNet+GRU(时序) ⭐⭐⭐
SpatialSoftmax 温度 τ 控制关键点的"尖锐程度",τ=1.0 是起点 ⭐⭐
三阶段蒸馏管线 state teacher → depth student → RGB student,每阶段降级输入 ⭐⭐⭐
DAgger vs offline BC DAgger 用 student 执行解决分布不匹配,是视觉蒸馏的标准 ⭐⭐⭐
MTS 策略 Mixture of Teacher and Student 防止 heading 预测的分布漂移 ⭐⭐⭐
Depth DR 维度 高斯噪声 + dropout + 延迟 + 外参扰动,阶段化引入 ⭐⭐⭐
距离相关 depth 噪声 σ ∝ d² 对结构光传感器,不是简单的均匀高斯 ⭐⭐
extreme-parkour 精读 ConvNet+GRU 编码器 + waypoint 方向 + MTS 蒸馏 + terrain curriculum ⭐⭐⭐
Waypoint-based reward 不为每种障碍物设计专门 reward,通用 reward 让行为 emerge ⭐⭐⭐
视觉 sim-to-real 外参标定 + 预处理一致性 + 延迟预算 ⭐⭐
ONNX + TensorRT 部署 PyTorch → ONNX → TRT,异步 CNN + 同步 MLP 架构 ⭐⭐
Foundation Model 替代 DINOv2 通用特征 vs task-specific CNN,各有适用场景 ⭐⭐
五层诊断流程 任务可行性→渲染正确→CNN学习→蒸馏质量→sim-to-real ⭐⭐⭐
Grad-CAM 可视化 CNN 关注区域应在地形边缘,若在平坦区域则 CNN 没学对 ⭐⭐

本章建立的四个核心心智模型

模型一:特权→可部署的渐进降级。 三阶段管线的本质是"从最好的输入开始,逐步降级到部署时可用的输入"。每次降级通过蒸馏传递控制能力。这个模式不仅适用于视觉——任何"训练时有更好信息、部署时只能用较差信息"的场景都可以套用。

模型二:感知与控制的分离。 Teacher-student 蒸馏把"学会控制"(teacher 用完美信息解决控制问题)和"学会感知"(student 用视觉恢复信息)分离为两个独立的学习问题。分离后每个问题都变得更简单。

模型三:DR 覆盖而非 DR 精确。 视觉 DR 不需要精确模拟真实传感器——只需要覆盖真实传感器可能出现的情况。过度精确反而限制了泛化性。

模型四:五层诊断,从粗到细。 视觉策略问题先查任务可行性(height scan teacher),再查渲染正确性,再查 CNN 学习,再查蒸馏质量,最后查 sim-to-real 对齐。跳过前面的层直接查后面的层是浪费时间。

本章建立的视觉 locomotion 工程能力将在后续章节中持续发挥作用——Ch19 的 loco-manipulation 需要同时用视觉感知地形和操作物体,Ch20 的 humanoid 全身控制中的视觉 parkour 直接复用本章的三阶段管线。

累积项目:本章新增模块

累积项目 E 在本章增加"视觉地形感知"模块。你应该能够:

  1. 在 mjlab 中配置 depth camera observation,跑通 zero play 确认渲染正常
  2. 训练一个 height scan teacher(Stage 1),在至少 3 种地形上达到合理的 episode length
  3. 实现 Stage 2 的 DAgger 蒸馏循环,蒸馏到 depth student
  4. 对比 depth student 和 height scan teacher 的性能差异
  5. 对 depth student 进行 latency injection 测试,评估延迟鲁棒性

完成标准

验收项 预期结果 验证方法
Height scan teacher episode length > 800 步(@50Hz) TensorBoard
Depth student (no DR) episode length > teacher × 60% 对比评估
Depth student (with DR) latency 2 frame 下不崩溃 latency injection 测试
CNN 特征可视化 关键点在地形边缘附近 保存可视化图片
渲染验证 depth 图中地形轮廓清晰 保存 matplotlib 图

推荐的工程执行顺序

Week 1: Phase 1 (height scan teacher)
  Day 1-2: 配置地形(台阶+坡面+缺口),验证 height scan obs 正确
  Day 3-5: 训练 PPO teacher,调整 reward 和 terrain curriculum
  Day 6-7: 确认 teacher 在所有地形类型上达到验收标准

Week 2: Phase 2 (depth student)
  Day 1: 配置 depth camera,验证渲染正常(保存 depth 图可视化)
  Day 2-3: 实现 DAgger 蒸馏循环(或使用 RSL-RL 扩展)
  Day 4-5: 训练 depth student(无 DR),确认 MSE loss 收敛
  Day 6: 加入 depth DR,验证 latency injection 鲁棒性
  Day 7: 可视化 CNN 特征,确认关键点在地形边缘

Week 3 (可选): 消融实验
  Day 1-2: 池化方式消融(SpatialSoftmax vs GAP)
  Day 3-4: 分辨率消融(32×32 vs 64×64)
  Day 5-7: 撰写实验报告,总结工程经验

如果你在 Week 1 Day 3 就卡住了(teacher 不收敛):回到 Ch13 检查四足 locomotion 的基础配置是否正确。teacher 的失败几乎 100% 是 reward 设计或地形配置的问题——不是视觉管线的问题。

常见的 Week 2 困难及解决方案

困难 典型原因 快速解决
depth 图全黑 忘记 --enable_cameras 检查启动命令
CNN 训练不动 lr 太小或图像未连接到 obs 打印梯度确认 > 0
student 立即摔倒 MLP 未从 teacher 初始化 检查 --resume 参数
蒸馏 loss 不下降 depth 预处理与 teacher 不一致 保存 depth 图检查
收敛比预期慢 5× num_envs 设太高(渲染瓶颈) 减少到 512

延伸阅读

资料 难度 推荐原因
Cheng et al. 2024, "Extreme Parkour with Legged Robots" (ICRA) ⭐⭐⭐ 三阶段视觉 locomotion 管线标杆,代码完全开源
Agarwal et al. 2023, "Legged Locomotion in Challenging Terrains using Egocentric Vision" (CoRL) ⭐⭐⭐ egocentric depth 视觉 locomotion 的先驱工作
Lee et al. 2020, "Learning Quadrupedal Locomotion over Challenging Terrain" (Science Robotics) ⭐⭐ 经典的 height scan teacher → blind student 两阶段管线
Kumar et al. 2021, "RMA: Rapid Motor Adaptation for Legged Robots" (RSS) ⭐⭐ 在线 adaptation 模块——本体感知历史编码环境信息
He et al. 2025, "VIRAL: Visual Sim-to-Real at Scale for Humanoid Loco-Manipulation" (NVIDIA) ⭐⭐⭐⭐ 大规模视觉 sim-to-real,64 GPUs,tiled rendering 最佳实践
Hoeller et al. 2024, "ANYmal Parkour: Learning Agile Navigation for Quadrupedal Robots" ⭐⭐⭐ 分层视觉 locomotion——高层选技能、低层控关节
Luo et al. 2024, "PIE: Parkour with Implicit-Explicit Learning" (IROS) ⭐⭐⭐ 隐式/显式约束结合的 parkour 学习
arXiv 2511.22744, "Beyond Egocentric Limits: Multi-View Depth" ⭐⭐ 多视角 depth 解决单相机盲区问题
Wang & Tao 2026, "LocoMamba" (Advanced Engineering Informatics) ⭐⭐⭐ Mamba 架构替代 CNN+GRU 的前沿探索
TTT-Parkour 2026 ⭐⭐⭐ Test-time training + 3D 重建的快速适应方法
Isaac Lab Tiled Rendering 文档 ⭐⭐ TiledCameraCfg 配置指南
Levine et al. 2016, "End-to-End Training of Deep Visuomotor Policies" (JMLR) ⭐⭐ SpatialSoftmax 的原始论文
MuJoCo Warp Batch Renderer 文档 ⭐⭐ mjlab 视觉配置参考
Ross et al. 2011, "A Reduction of Imitation Learning to No-Regret Online Learning" ⭐⭐ DAgger 算法的原始论文——理解蒸馏管线的理论基础

阅读顺序建议:先读 Lee et al. 2020(理解两阶段 teacher-student 的经典范式),再读 extreme-parkour(理解三阶段 depth student 的工程实现),然后读 VIRAL(理解大规模视觉 sim-to-real 的前沿工程)。RMA 和 Levine et al. 作为补充参考。

extreme-parkour 论文精读建议:重点关注 Section 3 的 reward design(为什么不需要为每种障碍物设计专门 reward)和 Section 4 的 Phase 2 distillation(MTS 的具体实现和退火策略)。代码中的 legged_gym/envs/go1/go1_config.py 是所有配置的入口——从这里出发可以追踪到 reward 函数、地形课程和 CNN 架构的完整定义。

Lee et al. 2020 论文精读建议:这篇 Science Robotics 论文定义了整个领域的范式——height scan teacher + proprioceptive student。重点关注 Fig. 2 的系统架构图和 Table 1 的 terrain curriculum 配置。注意这篇论文的 student 是"盲"的(不用视觉),与本章的 depth student 不同——理解这个区别有助于理解为什么需要第三代方法。

VIRAL 论文精读建议:VIRAL 是当前最大规模的视觉 locomotion sim-to-real 工作(64 GPUs)。重点关注 Section 4 的 visual domain randomization 配置(哪些 DR 维度对 sim-to-real 最重要)和 Section 5 的 real-world 实验(54/59 连续循环的工程原因分析)。注意 VIRAL 的目标是 humanoid loco-manipulation(不是纯 locomotion),但其视觉管线工程对任何视觉 locomotion 任务都有直接参考价值。

Multi-View Depth 论文阅读建议(arXiv 2511.22744):如果你的任务需要解决单相机盲区问题(如侧方障碍物检测),这篇论文提供了一个系统性的多视角融合方案。重点关注它如何在 teacher-student 框架中处理多个相机的特征融合——是在 CNN 层面融合还是在 feature 层面融合。

LocoMamba 论文阅读建议(Advanced Engineering Informatics, 2026):这是用 Mamba 架构(线性注意力的变体)替代 CNN+GRU 做视觉 locomotion 的最新探索。如果你对 Transformer/Mamba 在 locomotion 中的应用感兴趣,这是当前最好的参考——但注意 Mamba 的工程成熟度还远不如 CNN+GRU。

TTT-Parkour 论文阅读建议(2026):这是一个特别有工程价值的前沿工作——在部署前用真实场景的 RGB-D 扫描做 3D 重建,然后在重建的 mesh 上做 test-time training(<10 分钟)。这意味着策略可以快速适应特定的真实地形——而不是依赖 DR 来覆盖所有可能的地形。如果你的部署场景相对固定(如特定的工厂车间或仓库),这种 test-time adaptation 方法可能比大范围 DR 更高效。

故障排查手册

症状 可能原因 排查步骤 相关章节
渲染输出全黑 未加 --enable_cameras 1. 检查启动命令 2. 保存一帧验证 18.3
Depth 值全为 0 或全为 far_clip 相机朝向错误 / 裁剪范围不对 1. 可视化相机 frustum 2. 检查 clipping_range 3. 确认 OffsetCfg.rot 18.3
Depth 图看到天花板而非地面 OffsetCfg.convention 或 rot 配置错误 1. 确认 convention="ros" 2. 检查 quat (w,x,y,z) 格式 3. 保存图片验证 18.3
CNN 权重梯度为零 depth 未正确连接到 obs group 1. 检查 obs_groups 配置 2. 打印 CNN 第一层梯度 3. 确认 depth tensor requires_grad 18.4
CNN feature_std 持续为零 depth 预处理错误(全零或全常数) 1. 打印 depth min/max/mean 2. 检查归一化范围 3. 确认不是 uint8 18.4
MSE loss 不收敛 CNN lr 太大 / 图像预处理错误 1. 降低 CNN lr 到 1e-5 2. 可视化预处理后的 depth 3. 检查 teacher 标注是否正确 18.5
MSE loss 低但 episode length 短 分布不匹配 / offline BC 1. 切换到 DAgger 2. 检查 teacher 动作方差 3. 增加 BC 热启动步数 18.5, 18.9
Student 在平地正常、台阶摔倒 GRU 记忆不足 / depth 看不到障碍物 1. 增大 GRU hidden 2. 检查 far_clip 是否覆盖障碍物距离 3. Grad-CAM 诊断 18.4, 18.9
Student 行为突然抖动 SpatialSoftmax 关键点跳变 1. 增大温度参数 τ 2. 可视化关键点 3. 增加 action_rate loss 18.4
真机 depth 策略退化 预处理不一致 / 外参未标定 1. 对比仿真和真实 depth 2. 检查归一化一致性 3. 标定相机外参 18.8
ONNX 导出后行为不同 动态 shape / GRU 兼容性 1. 对比 PyTorch 和 ONNX 的输出 2. 固定 batch size 3. 展开 GRU 18.8
TensorRT 推理报错 不支持的 ONNX 算子 1. 检查 opset 版本 2. 简化 GRU 为手动矩阵运算 3. 升级 TRT 版本 18.8
训练吞吐极低 分辨率过高 / num_envs 过多 1. 降低分辨率到 32×32 2. 减少 num_envs 3. 切换到 performance 渲染模式 18.3
Phase 2 训练初期机器人全摔倒 student CNN 输出噪声 / MLP 未从 teacher 初始化 1. 先 offline BC 热启动 500 iter 2. 检查 --resume 是否生效 3. 打印 MLP 第一层权重对比 teacher 18.7
DAgger 后性能反而下降 student 执行产生的状态分布太偏 1. 增加 offline BC 热启动步数 2. 使用 MTS 稳定 heading 3. 减小 DAgger 的 student 执行比例 18.5
MTS heading 预测不收敛 α 退火太快 / heading 网络太小 1. 增大退火步数 T 2. 增大 heading prediction 网络容量 3. 检查 waypoint 命令是否正确 18.5, 18.7
depth DR 后收敛变差 噪声太强 / dropout 太高 1. 减小 gaussian_std 2. 减小 dropout_rate 3. 阶段化引入 18.6
GRU 隐状态在 reset 后残留 reset 时未清零 hidden state 1. 在 env.reset_ids 时同步清零 2. 检查 done mask 3. 打印 reset 后第一步 hidden norm 18.4, 18.9
关键点全聚集在图像中心 SpatialSoftmax τ 太大 / CNN 没学到 1. 减小 τ 到 0.5 2. 检查 CNN 是否有梯度 3. 增加训练步数 18.4
台阶前犹豫不前 depth 延迟导致时机判断偏移 1. 减小延迟 DR 范围 2. 增大 GRU hidden 3. 检查 waypoint 命令时序 18.6, 18.7
地形课程不推进 成功率阈值太高 / 地形跳变太大 1. 降低成功率阈值 2. 增加 terrain levels 使相邻级别差距更小 3. 检查是否有不可能通过的地形 18.7
多相机融合后性能反降 两个 CNN 分支的特征尺度不匹配 1. 对两个分支分别做 LayerNorm 2. 减小第二相机 CNN 的 lr 3. 先单独训练再联合训练 18.2

写在最后:本章建立了视觉 locomotion 的核心工程能力——从"眼睛"(相机配置)到"大脑"(CNN 编码器)到"训练"(teacher-student 蒸馏)到"调试"(五层诊断流程)。这套工程管线是当前学术界和工业界的主流范式,在 extreme-parkour、ANYmal perceptive locomotion、VIRAL 等标杆工作中被反复验证。掌握了这套管线,你就拥有了让机器人"用眼睛走路"的工程能力——无论是四足在崎岖地形上 parkour,还是人形在室内环境中导航。

本章与全书其他章节的联系

  • 向后回顾:Ch09 建立了 teacher-student 蒸馏的一般框架(特权 teacher → blind student),本章将其扩展到视觉领域(height scan teacher → depth student)。Ch17 引入了 CNN、SpatialSoftmax、visual DR 的操作版本,本章将这些技术迁移到 locomotion 语境——核心工程组件相同,但感知目标、相机配置和部署约束不同。

  • 向前预告:Ch19(四足 + 机械臂 Loco-Manipulation)将融合本章的视觉 locomotion 和 Ch17 的操作视觉。当机器人需要同时"走"和"抓"时,一个相机可能需要同时服务 locomotion 策略(看地形)和 manipulation 策略(看物体)——这引出了多任务视觉编码器和分层控制的工程挑战。Ch20(人形全身控制)中的 humanoid parkour 直接复用本章的三阶段管线——唯一的区别是动作维度更高(19-29 DOF vs 12 DOF)和平衡约束更严格。Ch23(Sim-to-Real 部署)将深入本章 §18.8 的 ONNX 导出和相机标定细节,给出完整的从仿真到真机的部署工程链。

一个总结性的工程经验法则:视觉 locomotion 的 80% 工作量不在"训练视觉策略"上,而在"确保视觉管线每一环正确"上。从相机坐标系约定到 depth 预处理到 obs group 配置到 GRU 隐状态 reset——任何一环出错都会导致"CNN 在完全无意义的输入上训练"。在开始任何训练之前,完成以下 5 分钟的"视觉健康检查":

text □ 保存一帧 depth 图 → matplotlib 可视化 → 确认能看到地形 □ 打印 depth tensor 的 min/max/mean → 确认值在 [near_clip, far_clip] 范围内 □ 打印 CNN 第一层权重的梯度范数 → 确认 > 0(CNN 连接正确) □ 确认 obs_groups 中 actor group 包含 depth obs(不是只在 critic group) □ 确认 GRU 隐状态在 env.reset 时被清零

这 5 项检查能在训练开始前就排除 90% 的视觉管线错误。做了这些检查后再开始训练——这是本章最重要的工程建议。