Ch18 · 视觉感知运动控制
定位:Part IV 第四章。从"盲"的本体感知控制跨越到视觉闭环控制——机器人如何用"眼睛"感知地形并做出精准的运动决策。 关键文献:extreme-parkour — Extreme Parkour with Legged Robots(ICRA'24,三阶段视觉管线标杆) 关键框架:mjlab MuJoCo Warp Batch Renderer · Isaac Lab
TiledCamera/TiledCameraCfgRTX 加速渲染 累积项目: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 题 → 先回前置章节复习
- [Ch09] Teacher-student 蒸馏中,teacher 使用什么类型的 privileged 信息?student 的输入被限制为什么?为什么这种分离有助于 sim-to-real?
- [Ch13] 四足 locomotion 中
RaycastSensor(height scan)返回的数据格式是什么?典型的采样点数和空间分布是怎样的? - [Ch17] CNN + SpatialSoftmax 的输出维度如何计算?SpatialSoftmax 相比 Global Average Pooling 保留了什么信息?
- [图像基础] 深度图
distance_to_image_plane和distance_to_camera有什么区别?对 CNN 的输入预处理有何影响? - [物理] 为什么前视深度相机无法直接感知机器人后脚下方的地形?这对自我中心视觉策略意味着什么?
- [工程] Isaac Lab 的
--enable_cameras标志是做什么的?如果不加这个标志而任务配置了相机,会发生什么? - [RL 基础] DAgger(Dataset Aggregation)与 offline behavior cloning 的核心区别是什么?为什么视觉蒸馏通常更偏好 DAgger?
本章目标
学完本章后,你应该能够:
- 解释为什么"盲"策略(仅使用本体感知)在平坦地面上有效但在复杂地形上失败,以及视觉如何填补这个感知缺口
- 对比 height scan(特权地形扫描)和 egocentric depth camera(自我中心深度相机)两种地形感知范式的工程优劣
- 配置 Isaac Lab 的
TiledCameraCfg和 mjlab 的 MuJoCo 相机,理解分辨率-吞吐-精度的三角权衡 - 设计 CNN 视觉编码器(SpatialSoftmax vs Global Pooling vs Flatten),知道不同池化策略在 locomotion 中的适用场景
- 实现 state teacher → depth student 的完整蒸馏管线,包括 DAgger 数据采集、MTS(Mixture of Teacher and Student)策略、loss 设计
- 配置视觉 Domain Randomization 的阶段化策略,理解视觉 DR 与物理 DR 的关键区别
- 精读 extreme-parkour 的三阶段代码结构(blind oracle → depth teacher → depth student),定位每个阶段的关键配置和训练参数
- 诊断视觉 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),而这些场景本体感知已经足够。视觉的价值在于它扩展了策略能处理的地形复杂度上限。不要为了"看起来更先进"而在不需要视觉的场景中强加视觉。
练习
- [分析题] 列出 3 个盲策略可以胜任的 locomotion 场景和 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 预处理中将近裁剪区域的像素标记为"近距离障碍"。
练习
- [计算题] 一个 height scan 配置为
size=(2.0, 1.0), resolution=0.1。计算总采样点数。如果 observation 中还包含 48 维 proprioception,总 obs 维度是多少? - [设计题] 你的机器人有两个深度相机:一个前视(看前方地形),一个下视(看脚下地形)。设计一个 observation group 配置,把两个相机的 depth 图和 proprioception 融合在一起。画出数据流图。
- [分析题] 为什么 extreme-parkour 选择了 ConvNet + GRU 而非简单的 4-frame 帧堆叠?从参数效率和时序建模能力两个角度分析。
Depth 图的预处理工程:从原始像素到 CNN 输入
Depth 图的预处理看起来简单("就是归一化嘛"),但工程细节直接影响 CNN 的学习效果。以下是从原始 depth 到 CNN 输入的完整处理流程:
Step 1:格式转换。 仿真器输出的 depth 数据可能是 float32(单位:m)或 uint16(单位:mm,需要除以 1000.0)。在 mjlab 中,CameraObsCfg 的 data_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}} = 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 不匹配
如果 TiledCameraCfg 的 clipping_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 可能因为更轻量的渲染器而有更高的吞吐。
练习
- [配置题] 在 Isaac Lab 中配置一个
TiledCameraCfg,前视 depth 相机,分辨率 64×64,安装在 Go1 的前胸位置(pos=(0.35, 0, 0.02)),FOV 60°。写出完整的配置代码。 - [实验题] 在 mjlab 中分别用 32×32 和 64×64 分辨率训练 YAM depth 版 1000 iteration。比较训练吞吐(steps/s)和 reaching reward。
- [对比题] 画一张表对比
distance_to_image_plane和distance_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 的物理解释):
在 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σ 不下结论。
练习
- [计算题] CNN 最后一层有 32 个 \(8 \times 8\) feature map。分别计算 flatten、GAP 和 spatial softmax 三种编码方式的输出维度。如果后面接一个 128 维的全连接层,三种情况下的参数量分别是多少?
- [设计题] 你的 locomotion 任务是"走平地+上下台阶"(不需要跳跃)。应该选择 SpatialSoftmax、GAP 还是 ConvNet+GRU?给出工程理由。
- [实验题] 使用 SpatialSoftmax 编码器训练一个 depth locomotion 策略。保存训练过程中关键点坐标,叠加到 depth 图上可视化。关键点是否收敛到台阶边缘附近?
18.5 Teacher-Student 视觉蒸馏管线 ⭐⭐⭐
这一节解决什么问题:如何从 height scan teacher 蒸馏到 depth student?三个阶段各自解决什么问题?DAgger 和 MTS 的工程细节是什么?
算法回顾:为什么不直接端到端训练视觉策略
回顾 §18.1 的讨论:视觉输入是一个带有投影、遮挡和噪声的测量系统。如果直接用 RL 的 reward 信号端到端训练 CNN + policy,面临三个困难:
- RL reward 对 CNN 的梯度极其稀疏——reward 来自最终的 locomotion 效果(是否成功通过障碍),从 reward 到 CNN 参数的梯度链太长(reward → action → policy MLP → CNN output → CNN parameters),大部分梯度是噪声。
- CNN 表示的 non-stationarity——CNN 参数更新改变了策略的输入表示,策略需要重新适应新表示。这形成一个"移动目标"问题:CNN 在变,policy 也在变,两者互相干扰。
- 视觉捷径(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:
这样在训练初期 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 图像。其关键工程创新包括:
- Mixed online DAgger + offline BC:不是纯 DAgger(太慢)也不是纯 offline BC(有分布偏移),而是混合使用——80% 的数据来自 student 的 online rollout(DAgger),20% 来自 teacher 的 offline 数据库(BC buffer),两者交替训练。
- Delta-action space:student 输出的是相对于当前关节位置的增量(delta action),而非绝对关节位置。这在长序列任务中更稳定——因为误差不会随时间累积。这与 Ch17 §18.8 讨论的 DiffIK
use_relative_mode=True是同一思想。 - 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。
练习
- [设计题] 设计 Stage 2 的 DAgger 蒸馏循环。回答:rollout 长度设为多少步?多少 rollout 后更新一次 student?CNN 和 MLP 的学习率应该一样还是分开设?
- [分析题] MTS 的 \(\alpha\) 退火速率设为线性衰减 vs 余弦衰减会有什么工程差异?哪种更适合 heading prediction 的逐步过渡?
- [跨章综合题] 回顾 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 噪声、安装误差和通信延迟在训练分布之外。
练习
- [设计题] 你使用 Intel RealSense D435 作为 depth sensor,其典型噪声为距离 1m 处 \(\sigma \approx 2\) mm,距离 3m 处 \(\sigma \approx 14\) mm。设计一个距离相关的 depth 噪声模型,写出 PyTorch 实现。
- [实验题] 训练两个 depth student:一个不加 DR,一个加高斯噪声 + dropout。训练完成后,在 play 时手动增加 depth 噪声(\(\sigma = 0.02\) m),比较两个策略的稳定性。
- [分析题] 为什么 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=53、n_scan=132、num_envs=6144、PPO learning_rate=2e-4、num_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 计算得到:
这确保策略总是被引导向障碍物移动,而非随机方向。
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、PPOlearning_rate=2e-4、num_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;时序部分 RecurrentDepthBackbone 用 GRU(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 最小化)。这是两个完全不同的训练流程。
练习
- [源码阅读题] 在 extreme-parkour 的代码中找到 MTS 的实现位置。\(\alpha\) 的退火速率是多少?如何从配置文件中修改?
- [迁移题] 写出将 extreme-parkour 的 Phase 1 迁移到 mjlab 需要修改的 5 个核心配置项。
- [设计题] 如果你想把 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 只处理标定后的残余不确定性。
练习
- [工程题] 编写一个 RealSense D435 的 depth 预处理管线(Python + pyrealsense2),输出与训练时一致的 tensor 格式。包括:获取深度帧、转换单位、降采样、裁剪、归一化。
- [分析题] 计算从 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。
练习
- [实验题] 对你训练好的 depth student 进行 latency injection 测试:分别注入 0、1、3、5 帧延迟,记录 episode length。画出延迟-性能曲线。哪个延迟值是"性能悬崖"?
- [诊断题] 你的 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 在目标任务上非常高效,但有两个局限:
- 迁移性差:为一个地形类型训练的 CNN 特征无法直接迁移到另一个地形类型——换一种障碍物可能需要重新蒸馏。
- 语义理解缺失:自训练 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 的价值主要在以下场景:
- 需要语义理解:如区分可通行(草地)和不可通行(水面)区域——DINOv2 的预训练特征包含语义信息,自训练 CNN 没有。
- 需要跨任务泛化:一个视觉 encoder 服务多个下游任务(locomotion + manipulation + navigation)——DINOv2 的通用特征可以被多个任务头共享。
- 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 在本章增加"视觉地形感知"模块。你应该能够:
- 在 mjlab 中配置 depth camera observation,跑通 zero play 确认渲染正常
- 训练一个 height scan teacher(Stage 1),在至少 3 种地形上达到合理的 episode length
- 实现 Stage 2 的 DAgger 蒸馏循环,蒸馏到 depth student
- 对比 depth student 和 height scan teacher 的性能差异
- 对 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% 的视觉管线错误。做了这些检查后再开始训练——这是本章最重要的工程建议。