Skip to content

obs/action 对齐规则

三种 L2 来源(scripted / RL / teleop)必须用同一套 obs/action 表示,否则 L3 没法把它们合到一个 LeRobot dataset 里。这份 doc 是约束而不是描述:所有 L2 实现都要遵守。

任务锚点

第一阶段以 Isaac-Lift-Cube-Franka-IK-Abs-v0 为唯一任务,所有维度都按这个任务定。其他任务后续按需扩展。

Action 空间:8 维 IK absolute

维度含义单位 / 范围
[0:3]期望 EE 位置 (x, y, z),机器人 root framem
[3:7]期望 EE 朝向四元数 (qx, qy, qz, qw)unit quat(scalar-last
[7]二值夹爪指令+1=OPEN, -1=CLOSE

来自 Isaac-Lift-Cube-Franka-IK-Abs-v0ActionsCfgDifferentialInverseKinematicsActionCfg(command_type="pose", use_relative_mode=False) + BinaryJointPositionActionCfg>0 → 0.04m≤0 → 0.0m)。

三来源转换义务

来源自然产出对齐手段
l2/scriptedIK abs(SM 直接算 EE 目标 pose)无需转换
l2/rljoint_pos(PPO 学 7 个关节增量 + gripper)rollout 时录制层重写:用 ee_frame sensor 取当前 EE pose(root frame, scalar-last quat),gripper 取 finger joint state 二值化 → 写成 8d IK-abs 等价 action
l2/teleopIK rel (keyboard 出 7d [dx, dy, dz, rx, ry, rz, gripper],rotvec 形式)record loop 里读 ee_frame sensor 当前 EE pose,做 rel→abs 复合:abs_pos = ee_pos_curr + dposabs_quat = quat_mul(delta_quat, ee_quat_curr)gripper 透传 +1/-1。详见 l2/teleop/keyboard_adapter.py:keyboard_delta_to_ik_abs

l2/rl 的"等价 action"语义说明:因为 PPO 的策略空间是关节命令而不是 EE pose, 写盘的 8d action 不是策略真正的"决策变量",而是该步执行后预期 EE 跟踪到的 pose (= 当前帧 ee_frame 读数 + 一步控制延迟)。下游 BC / VLA 训练时把这视为 "demonstrate 出来的 EE 目标 pose"来学,跟 scripted/teleop 的语义一致:模型学的都是"下一帧应该到这个 位姿"。这一替换的副作用是 RL 策略的 explore 信号被丢失(action_rate 会更平滑,因为不再 反映 PPO 的 noise),但不影响 BC 蒸馏的目标分布。

详细推理见 L2 三种轨迹来源 §2 "action 空间对齐"。

Quat 顺序确认(重要)

当前 IsaacLab develop 分支(commit f0234a8已经统一所有内部 quat 为 scalar-last (x, y, z, w)

  • body_quat_w, root_quat_w(x, y, z, w),见 IsaacLab/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py:430
  • target_quat_w(FrameTransformer / ee_frame):(x, y, z, w),见 IsaacLab/source/isaaclab/isaaclab/sensors/frame_transformer/base_frame_transformer_data.py:57
  • UniformPoseCommand 输出:(x, y, z, w),见 IsaacLab/source/isaaclab/isaaclab/envs/mdp/commands/pose_command.py
  • DifferentialIKController.set_command 接受 (x, y, z, w),见 IsaacLab/source/isaaclab/isaaclab/controllers/differential_ik.py:112
  • HDF5 顶层 format_version=1 也是 XYZW(见 HDF5 Schema

Warp 的 wp.transform 同样是 scalar-last (x, y, z, w),所以 SM kernel 拿到的输入和输出 quat 可以直接送给 IK action / 直接喂给 RecorderManager 而无需 reorder

历史警告:早期文档说 "IsaacLab 内部 (w, x, y, z)" 是基于较老版本,已不适用于当前 commit。LeRobot v2 dataset 端的 quat 顺序约定(如果不一致)由 L3 convert.py 在转换时处理,HDF5 这一层保持 IsaacLab 原生格式即可。

Observation:分项存储,L3 按需挑

HDF5 中间产物里 obs 各项分开存/data/demo_{i}/obs/<key>),不预先 concat。这样:

  • 不同来源可能多 / 少几个字段,存着不冲突
  • L3 转换器决定哪些字段进 LeRobot 的 observation.state、哪些丢弃
  • 加新观测时不破坏旧数据

强制存的字段

HDF5 keyshape含义
obs/joint_pos(T, 9)7 个臂关节 + 2 个 finger,joint_pos_rel(相对 default)
obs/joint_vel(T, 9)同上 9 个关节速度
obs/object_position(T, 3)cube 在 robot root frame 下的 xyz
obs/target_object_position(T, 7)command 目标位姿 (xyz, qx, qy, qz, qw)
obs/actions(T, 8)上一步的 action(IsaacLab mdp.last_action term,属性名是 actions

命名说明:HDF5 key obs/actions 在直觉上是 last_action,但落盘 key 跟 LiftEnvCfg.ObservationsCfg.PolicyCfg 里的属性名一致(actions = ObsTerm(func=mdp.last_action),见 IsaacLab/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/lift_env_cfg.py:110)。L3 转换层把它映射成 LeRobot state.last_action

可选字段

HDF5 keyshape何时存
obs/images/front_cam(T, H, W, 3) uint8 RGB--with_camera 开关打开时
obs/images/wrist_cam(T, H, W, 3) uint8 RGB双目场景

L3 modality.json 切片(state)

observation.state 的 1-D concat 顺序由 convert.py 决定,并写入 modality.json。本仓库默认顺序:

[joint_pos(9), joint_vel(9), object_position(3), target_object_position(7), last_action(8)]
total state_dim = 36

modality.json 里的切片:

json
{
  "state": {
    "joint_pos": { "start": 0, "end": 9 },
    "joint_vel": { "start": 9, "end": 18 },
    "object_position": { "start": 18, "end": 21 },
    "target_object_position": { "start": 21, "end": 28 },
    "last_action": { "start": 28, "end": 36 }
  },
  "action": {
    "ee_pose": { "start": 0, "end": 7 },
    "gripper": { "start": 7, "end": 8 }
  }
}

L3 convert.py 把 HDF5 里的 obs/actions 在 LeRobot dataset 里重命名成 last_action(语义更清晰)。

图像 observation.images.front_cam 走外挂 mp4,不进 observation.state

频率

  • L2 venv 物理 100 Hz,policy 50 Hz(sim.dt=0.01decimation=2)。
  • l2/scripted / l2/rl 都按 50 Hz。
  • l2/teleop 调到 30 Hz(贴人手响应)——这意味着 LeRobot dataset 的 fps 字段在不同来源会不同,要么混合时降采样到 30 Hz、要么按来源分两个 dataset。第一阶段建议三个来源统一 30 Hz 简化合并。

l2/rl 实现细节

策略架构(基于 IsaacLab 现成基础设施):

                [训练]                       [rollout 录制]
Isaac-Lift-Cube-Franka-v0     →     Isaac-Lift-Cube-Franka-v0
(joint_pos, concat=True)            (joint_pos, concat=True 同训练,加 success term + 自定义录制器)
        ↓                                              ↓
PPO learns 9-dim joint_pos             policy 推理 → joint_pos action 进 sim
        ↓                                              ↓
checkpoint policy.pt                   IKAbsActionRecorder 覆写 actions:
                                       从 ee_frame sensor 拿当前 EE pose (xyz + xyzw quat)
                                       + 从 finger joint pos 二值化拿 gripper
                                       拼 8d IK-abs 等价 action 写到 HDF5 actions
                                       PolicySplitObsRecorder 把 1-D concat obs 按
                                       group_obs_term_dim 切回 dict,actions term 替成 8d

为什么 rollout 也保 concat=True:

  • PPO 网络 (MLPModel in rsl_rl) 硬要求 obs[obs_group](E, D) 的 1-D Tensor; concatenate_terms=Falseobs_buf["policy"] 是嵌套 dict,会触发 ValueError: The MLP model only supports 1D observations
  • 所以两阶段 obs 形态保持一致,落盘的 dict 形式由自定义 PolicySplitObsRecorder 自己切片 得到——这部分跟 PPO 主回路解耦,RecorderManager 在 record_pre_step 时点单独读 obs_buf["policy"] 1-D Tensor 切片,不动 PPO 路径。

为什么不用 IK-Abs task 训练 PPO:

  • Isaac-Lift-Cube-Franka-IK-Abs-v0IsaacLab/source/isaaclab_tasks/.../config/franka/__init__.py没注册任何 RL agent cfg——只 joint_pos 那条配了 PPO/SKRL/SB3 cfg。
  • PPO 直接学 7d EE pose(含 4d quat)的天然问题:quat 必须 unit length,单纯 box action space 学不出来;改 6D rotation 或 axis-angle 要写自定义 ActionTerm + 自定义 reward,工程量 大且收敛性不保证。
  • 工程成本最低、教学含金量最高的方案是直接复用 IsaacLab 验证过的 joint_pos PPO 配方, 把"action 空间对齐"挪到 rollout 阶段当数据后处理做。这条路径在 l2/rl/rollout_and_record.py 里 ~50 行就能搞定。

8d 等价 action 的具体计算(rollout 录制时由 IKAbsActionRecorder 完成):

python
# ee_frame sensor 在 root frame 下的当前 EE pose (xyzw quat)
ee_pos  = ee_sensor.data.target_pos_w[..., 0, :] - env.scene.env_origins   # (E, 3)
ee_quat = ee_sensor.data.target_quat_w[..., 0, :]                          # (E, 4) xyzw

# 从 finger joint 的当前 joint_pos 二值化 gripper:>=0.02 视为 OPEN(+1)、否则 CLOSE(-1)
finger_pos = robot.data.joint_pos[:, finger_joint_indices].mean(dim=-1)    # (E,)
gripper = torch.where(finger_pos >= 0.02, 1.0, -1.0).unsqueeze(-1)         # (E, 1)

action_8d = torch.cat([ee_pos, ee_quat, gripper], dim=-1)                  # (E, 8)

写盘语义(与 scripted IK abs cfg 对齐):

  • [0:3] EE 期望位置 = 当前 EE 位置(满足"模型学到的就是 EE 应该到达哪里")
  • [3:7] EE 期望朝向(xyzw)= 当前 EE 朝向
  • [7] gripper:+1 当 finger 张开(panda 默认 0.04),-1 当 finger 闭合(0.0)
  • 同步覆写 obs/actions 这个 last_action term(也是 8d),下一帧的 obs 自洽

副作用:last_action obs 在策略推理回路里是 9d(joint_pos),但写到 HDF5 的是 8d (IK-abs 等价)。这没问题,因为 obs/actions 在 HDF5 里只用于 BC 训练而不是 PPO 在线 策略;BC 模型读到的 last_action 是 8d,跟它要预测的 action 同分布,自洽。

l2/rl/recorders.pyIKAbsActionRecorder / PolicySplitObsRecorder 实现。

l2/teleop 实现细节

teleop 来源用的是 keyboard (本仓库教学最小版只支持 keyboard,SpaceMouse / VR 留扩展点)。 关键链路:

                    [record loop, num_envs=1, 30Hz]
Se3Keyboard.advance() (IsaacLab 自带,7d delta + gripper)

keyboard_delta_to_ik_abs(读 ee_frame 当前 pose):
    abs_pos  = ee_pos_curr + delta_pos
    abs_quat = quat_mul(delta_quat, ee_quat_curr)   # delta_quat 来自 rotvec
    gripper  = +1 OPEN / -1 CLOSE
         ↓ 8d action
env.step() (Isaac-Lift-Cube-Franka-IK-Abs-v0,与 l2/scripted 同 task)

ActionStateRecorderManagerCfg (与 l2/scripted 同 schema 录制)
         ↓ 用户连续 N 步在 success 圈内
record_pre_reset + set_success_to_episodes(True) + export_episodes

data/demos/teleop_lift_cube.hdf5

为什么 task 用 IK-Abs 而不是 IK-Rel:

  • 我们在脚本侧(keyboard_adapter)就把 keyboard 的 rel 复合成 abs 了,task 直接用 IK-Abs 最自然,HDF5 schema 与 l2/scripted 完全一致。
  • IsaacLab 自带 IK-Rel DifferentialInverseKinematicsAction 也能跑(把 keyboard 7d 直接喂 进去),但那样写盘的 actions 列就是 IK-rel 7d,跟 scripted/RL 的 8d 不一致——L3 convert.py 要按 source 分支,违背"三来源对齐到一份 schema"的核心契约。

为什么 success 走"连续 N 步"+"手动 export":

  • 直接挂 terminations.success = DoneTerm(...) 会在 cube 进 success 圈的第 1 帧 auto-reset, 人手抖动可能造成假 success,且 episode 切得太短。
  • 模式抄自 IsaacLab 官方 record_demos.py:240-242:把 success DoneTerm 抽出来设为 None, 在主 loop 里手动跑 success_term.func(env, ...) 计数,够 num_success_steps 帧才调 set_success_to_episodes(True) + export_episodes

为什么 terminations.time_out = None:

  • LiftEnvCfg 默认 episode_length_s=5.0,人手 30Hz × 5s = 150 步,够紧张。teleop 期望 让人慢慢操作,不被强制 reset;只在 success / object_dropping / 用户按 R 时 reset。

为什么不沿用 IsaacLab scripts/tools/record_demos.py:

  • 它强 import isaaclab_mimic (record_demos.py:123),需要装额外包;本仓库不依赖它。
  • 它支持 XR / IsaacTeleop / SpaceMouse 等多设备,本最小版用不上,自己写 ~150 行更干净、 注释好嵌入。

l2/teleop/record.py / l2/teleop/keyboard_adapter.py / l2/teleop/env_cfg.py 实现。