Skip to content

Commit a396273

Browse files
committed
feat: record OpenNeck actions in sim2real datasets
1 parent ba50d77 commit a396273

14 files changed

Lines changed: 344 additions & 49 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,9 @@ target_dof_pos = clip(action, -10, 10) × action_scale + default_dof_pos
150150
- Optional LinkerHand control uses `hands.enabled=true`, `hands.driver=linkerhand_l6|linkerhand_o6`, and `hands.mode=gripper|vr_hand_pose`; default is disabled
151151
- Optional Pico sim2real HDF5 recording uses `--config-name sim2real_record` or `recording.enabled=true`; it requires `input.provider=pico4`, `input.video.enabled=true`, `input.video.source=realsense`, an interactive terminal, and the `recording` extra
152152
- Recording is manual only: terminal `R` starts an episode, `S` saves, `D` discards the active episode, and `Q` shuts down; `STANDING`, `MOCAP`, `ARMS`, and paused mocap are recordable
153-
- Recording captures `observation.images.d435i_rgb` RealSense RGB video at 30Hz plus `observation.state(68)`, scalar `observation.mode`, and `action(36)` as the root-plus-joint reference consumed by the motion tracker; `action.hand(12)` is present when LinkerHand control is enabled
153+
- Recording captures `observation.images.d435i_rgb` RealSense RGB video at 30Hz plus `observation.state(68)`, scalar `observation.mode`, and `action(36)` as the root-plus-joint reference consumed by the motion tracker; `action.hand(12)` is present when LinkerHand control is enabled, and `action.neck(2)` is present when OpenNeck control is enabled
154154
- Sim2real recording uses an editable source layout: `schema.json`, `episodes.jsonl`, per-episode HDF5 files under `recording.output_dir/data/`, and compressed MP4 files under `recording.output_dir/videos/d435i_rgb/`; task prompts live only in `episodes.jsonl`, and HDF5 files contain only frame arrays with no metadata attributes or raw RGB datasets
155-
- Recording `schema.json` stores `robot_type=unitree_g1_29dof`, `hand_type=none|linkerhand_l6|linkerhand_o6`, FPS, and feature definitions; the recording worker rejects an existing mismatched schema without writing episodes, but remains non-critical and must not stop the G1 control runtime; the previous attribute-based HDF5 layout is unsupported
155+
- Recording `schema.json` stores `robot_type=unitree_g1_29dof`, `hand_type=none|linkerhand_l6|linkerhand_o6`, `neck_type=none|openneck`, FPS, and feature definitions; optional action fields are controlled directly by `hands.enabled` and `neck.enabled`; the recording worker rejects an existing mismatched schema without writing episodes, but remains non-critical and must not stop the G1 control runtime; the previous attribute-based HDF5 layout is unsupported
156156
- Episodes interrupted before their `episodes.jsonl` entry is committed are discarded on the next recording-worker startup and do not consume an episode index
157157
- `gripper` mode reuses `Pico4InputProvider.get_controller_snapshot()` for Pico grip/trigger open-close control and supports LinkerHand L6 and O6
158158
- `vr_hand_pose` mode reuses `Pico4InputProvider.get_hand_snapshot()` and somehand 0.2.0 public `somehand.api` for continuous Pico hand-pose retargeting; do not start a second `PicoBridge` for hand control
@@ -294,6 +294,11 @@ Critical note: align robot root orientation to the BVH human forward direction b
294294

295295
## Development
296296

297+
### Simplicity Policy
298+
- Prefer the smallest implementation that satisfies the current requirement
299+
- Do not add speculative configuration switches, abstraction layers, compatibility paths, or extensibility without a concrete use case
300+
- Reuse existing enable flags and data flows when they already express the required behavior
301+
297302
### Runtime Validation Policy
298303
- Fail fast for logical mismatches such as observation definition vs. ONNX signature mismatch
299304
- Do not silently pad, trim, clip, or replace invalid data/config to "make it run"

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,14 @@ python scripts/run/run_sim2real.py --config-name sim2real_record \
101101
Recording uses the terminal controls `R` start, `S` save, `D` discard, and `Q`
102102
shutdown. `STANDING`, `MOCAP`, `ARMS`, and paused mocap can be recorded. Saved
103103
episodes are written under `data/recordings/sim2real_hdf5/data/`, with compressed
104-
MP4 files under `videos/d435i_rgb/`. `schema.json` records the FPS, robot and
105-
hand types, feature shapes, names, and groups. `episodes.jsonl` maps each episode
104+
MP4 files under `videos/d435i_rgb/`. `schema.json` records the FPS, robot, hand,
105+
and neck types, plus feature shapes, names, and groups. `episodes.jsonl` maps each episode
106106
to its HDF5/video files and stores its editable task prompt. HDF5 contains only
107107
frame-aligned arrays: `observation.state(68)`, scalar `observation.mode`, and
108108
`action(36)` as the aligned reference qpos consumed by the motion tracker.
109-
`action.hand(12)` is present when LinkerHand control is enabled.
109+
`action.hand(12)` is present exactly when LinkerHand control is enabled, and
110+
`action.neck(2)` contains the latest normalized OpenNeck yaw/pitch command exactly
111+
when OpenNeck control is enabled.
110112
Recording is non-critical: an incompatible output schema stops only the
111113
recording worker while G1 control continues. Episodes interrupted before their
112114
manifest entry is committed are discarded on the next recording startup.

docs/docs/configuration/config-reference.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,12 @@ recording.output_dir/
221221
└── episode_000000.mp4
222222
```
223223

224-
`schema.json` contains the FPS, `robot_type`, `hand_type`, and feature
225-
definitions. `robot_type` comes from `robot.type`; `hand_type` is `none` when
226-
hands are disabled, otherwise it is the configured `hands.driver`.
224+
`schema.json` contains the FPS, `robot_type`, `hand_type`, `neck_type`, and
225+
feature definitions. `robot_type` comes from `robot.type`; `hand_type` is `none`
226+
when hands are disabled, otherwise it is the configured `hands.driver`.
227+
`neck_type` is `none` when active-neck control is disabled, otherwise it is the
228+
configured `neck.driver`. These enabled flags directly control whether their
229+
action fields are recorded; there are no separate recording switches.
227230
`episodes.jsonl` contains one object per saved episode with `episode_index`,
228231
`frames`, editable `task`, HDF5 path, and video paths. Task prompts can therefore
229232
be relabeled without rewriting HDF5 or MP4 data. Starting another recording run
@@ -247,6 +250,7 @@ observation.state float32[N, 68]
247250
observation.mode int8[N]
248251
action float32[N, 36]
249252
action.hand float32[N, 12] # only when hands are enabled
253+
action.neck float32[N, 2] # only when OpenNeck is enabled
250254
```
251255

252256
HDF5 files contain only these frame arrays and have no recording metadata root
@@ -262,6 +266,8 @@ high-level reference consumed by the motion tracker, not the tracker policy's
262266
raw output or the final joint targets sent to G1.
263267
`action.hand` is the latest LinkerHand command from the hand worker:
264268
`left_pose(6) + right_pose(6)`, using the SDK's 0-255 pose values.
269+
`action.neck` is the latest command successfully sent to OpenNeck by the neck
270+
worker: normalized `[yaw, pitch]`, each in `[-1, 1]`.
265271

266272
## Critical: `default_dof_pos`
267273

docs/docs/tutorials/pico-sim2real.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,13 @@ shutdown. `STANDING`, `MOCAP`, `ARMS`, and paused mocap can be recorded;
118118
saved episodes cannot be discarded afterward. Episodes are saved as `.h5` files
119119
under `data/recordings/sim2real_hdf5/data/`, with compressed MP4 files under
120120
`data/recordings/sim2real_hdf5/videos/d435i_rgb/`. The dataset-level
121-
`schema.json` records robot/hand types and feature definitions, while
121+
`schema.json` records robot/hand/neck types and feature definitions, while
122122
`episodes.jsonl` stores file mappings and the editable task prompt for every
123123
episode. HDF5 stores `frame_index`, `timestamp`, `observation.state(68)`, scalar
124124
`observation.mode`, and the 36D motion-tracker reference `action` at 30 Hz.
125-
When hand control is enabled, it also stores `action.hand(12)`.
125+
When hand control is enabled, it also stores `action.hand(12)`. When OpenNeck
126+
control is enabled, it stores the latest normalized yaw/pitch command as
127+
`action.neck(2)`. Disabled devices do not add their action fields.
126128

127129
## Operator Flow
128130

docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/config-reference.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,9 +236,11 @@ recording.output_dir/
236236
└── episode_000000.mp4
237237
```
238238

239-
`schema.json` 保存 FPS、`robot_type``hand_type` 和 feature 定义。
239+
`schema.json` 保存 FPS、`robot_type``hand_type``neck_type` 和 feature 定义。
240240
`robot_type` 来自 `robot.type`;未启用灵巧手时 `hand_type``none`,否则为
241-
配置的 `hands.driver``episodes.jsonl` 每行对应一个已保存的 episode,包含
241+
配置的 `hands.driver`。未启用主动视觉颈部控制时 `neck_type``none`,否则为
242+
配置的 `neck.driver`。这些 enabled 标志直接决定是否录制对应的 action 字段;没有
243+
单独的录制开关。`episodes.jsonl` 每行对应一个已保存的 episode,包含
242244
`episode_index``frames`、可编辑的 `task`、HDF5 路径和视频路径。因此修改任务
243245
prompt 不需要重写 HDF5 或 MP4。使用相同 schema 再次启动录制时,会从下一个
244246
episode index 继续追加,并且可以使用不同的 `recording.task`
@@ -258,6 +260,7 @@ observation.state float32[N, 68]
258260
observation.mode int8[N]
259261
action float32[N, 36]
260262
action.hand float32[N, 12] # 仅启用灵巧手时存在
263+
action.neck float32[N, 2] # 仅启用 OpenNeck 时存在
261264
```
262265

263266
HDF5 文件仅包含上述逐帧数组,不保存录制元数据根属性。RGB 帧保存在 MP4 中,
@@ -271,3 +274,5 @@ HDF5 文件仅包含上述逐帧数组,不保存录制元数据根属性。RGB
271274
消费的高层参考,不是 tracker policy 的原始输出,也不是最终下发给 G1 的关节目标。
272275
`action.hand` 是手部 worker 最新的 LinkerHand 命令:
273276
`left_pose(6) + right_pose(6)`,使用 SDK 的 0-255 pose 数值。
277+
`action.neck` 是颈部 worker 最近一次成功发送给 OpenNeck 的命令:归一化的
278+
`[yaw, pitch]`,两个值的范围均为 `[-1, 1]`

docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/tutorials/pico-sim2real.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,12 @@ python scripts/run/run_sim2real.py \
113113
`STANDING``MOCAP``ARMS` 和暂停状态的 mocap;已经保存的 episode 不支持再丢弃。
114114
episode 会保存为 `data/recordings/sim2real_hdf5/data/` 下的 `.h5` 文件,
115115
压缩 MP4 视频保存在 `data/recordings/sim2real_hdf5/videos/d435i_rgb/` 下。
116-
数据集级 `schema.json` 保存机器人/灵巧手类型和 feature 定义,
116+
数据集级 `schema.json` 保存机器人/灵巧手/颈部类型和 feature 定义,
117117
`episodes.jsonl` 保存每个 episode 的文件映射与可编辑任务 prompt。HDF5 以
118118
30 Hz 保存 `frame_index``timestamp``observation.state(68)`、标量
119119
`observation.mode` 和作为 motion-tracker reference 的 36D `action`。启用灵巧手
120-
控制时还会保存 `action.hand(12)`
120+
控制时还会保存 `action.hand(12)`。启用 OpenNeck 控制时,会把最新的归一化
121+
yaw/pitch 命令保存为 `action.neck(2)`。未启用的设备不会添加对应的 action 字段。
121122

122123
## 操作流程
123124

scripts/dev/test_openneck.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,13 @@ def run_pico(args: argparse.Namespace) -> None:
181181
if provider.has_frame():
182182
frame, timestamp_s, seq = provider.get_frame_packet()
183183
if int(seq) != last_seq:
184-
moved = runtime.tick(
184+
command = runtime.tick(
185185
frame=frame,
186186
frame_timestamp_s=timestamp_s,
187187
active=True,
188188
now_s=now_s,
189189
)
190+
moved = command is not None
190191
if moved:
191192
command_count += 1
192193
last_seq = int(seq)

teleopit/recording/hdf5.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,21 @@
2828
MODE_KEY = "observation.mode"
2929
ACTION_KEY = "action"
3030
HAND_ACTION_KEY = "action.hand"
31+
NECK_ACTION_KEY = "action.neck"
3132
FRAME_INDEX_KEY = "frame_index"
3233
TIMESTAMP_KEY = "timestamp"
3334
STATE_DIM = 68
3435
ACTION_DIM = FULL_QPOS_DIM
3536
HAND_ACTION_DIM = 12
37+
NECK_ACTION_DIM = 2
3638
DEFAULT_IMAGE_SHAPE = (480, 640, 3)
3739
HDF5_RECORDING_FORMAT = "teleopit_hdf5"
38-
HDF5_RECORDING_VERSION = 1
40+
HDF5_RECORDING_VERSION = 2
3941
DEFAULT_ROBOT_TYPE = "unitree_g1_29dof"
4042
NO_HAND_TYPE = "none"
4143
SUPPORTED_HAND_TYPES = (NO_HAND_TYPE, "linkerhand_l6", "linkerhand_o6")
44+
NO_NECK_TYPE = "none"
45+
SUPPORTED_NECK_TYPES = (NO_NECK_TYPE, "openneck")
4246
MODE_CODES = {
4347
"standing": 0,
4448
"mocap": 1,
@@ -56,18 +60,25 @@ class RecordingSchema:
5660
hand_type: str
5761
image_key: str
5862
image_shape: tuple[int, int, int]
63+
neck_type: str = NO_NECK_TYPE
5964
state_key: str = STATE_KEY
6065
state_dim: int = STATE_DIM
6166
mode_key: str = MODE_KEY
6267
action_key: str = ACTION_KEY
6368
action_dim: int = ACTION_DIM
6469
hand_action_key: str = HAND_ACTION_KEY
6570
hand_action_dim: int = HAND_ACTION_DIM
71+
neck_action_key: str = NECK_ACTION_KEY
72+
neck_action_dim: int = NECK_ACTION_DIM
6673

6774
@property
6875
def has_hand_action(self) -> bool:
6976
return self.hand_type != NO_HAND_TYPE
7077

78+
@property
79+
def has_neck_action(self) -> bool:
80+
return self.neck_type != NO_NECK_TYPE
81+
7182

7283
@dataclass(frozen=True)
7384
class MP4VideoConfig:
@@ -82,13 +93,15 @@ def build_recording_schema(
8293
fps: int = 30,
8394
robot_type: str = DEFAULT_ROBOT_TYPE,
8495
hand_type: str = NO_HAND_TYPE,
96+
neck_type: str = NO_NECK_TYPE,
8597
) -> RecordingSchema:
8698
key = str(cfg_get(camera_cfg, "key", IMAGE_KEY)).strip()
8799
width = int(cfg_get(camera_cfg, "width", DEFAULT_IMAGE_SHAPE[1]))
88100
height = int(cfg_get(camera_cfg, "height", DEFAULT_IMAGE_SHAPE[0]))
89101
parsed_fps = int(fps)
90102
parsed_robot_type = str(robot_type).strip().lower()
91103
parsed_hand_type = str(hand_type).strip().lower()
104+
parsed_neck_type = str(neck_type).strip().lower()
92105
if not key:
93106
raise ValueError("recording.camera.key must not be empty")
94107
if width <= 0 or height <= 0:
@@ -103,10 +116,15 @@ def build_recording_schema(
103116
raise ValueError(
104117
f"Unsupported recording hand_type={parsed_hand_type!r}; expected one of {SUPPORTED_HAND_TYPES}"
105118
)
119+
if parsed_neck_type not in SUPPORTED_NECK_TYPES:
120+
raise ValueError(
121+
f"Unsupported recording neck_type={parsed_neck_type!r}; expected one of {SUPPORTED_NECK_TYPES}"
122+
)
106123
return RecordingSchema(
107124
fps=parsed_fps,
108125
robot_type=parsed_robot_type,
109126
hand_type=parsed_hand_type,
127+
neck_type=parsed_neck_type,
110128
image_key=key,
111129
image_shape=(height, width, 3),
112130
)
@@ -172,6 +190,14 @@ def hdf5_schema(schema: RecordingSchema) -> dict[str, object]:
172190
"right_hand_target": [6, 12],
173191
},
174192
}
193+
if schema.has_neck_action:
194+
features[schema.neck_action_key] = {
195+
"dtype": "float32",
196+
"shape": [schema.neck_action_dim],
197+
"names": ["yaw", "pitch"],
198+
"units": "normalized",
199+
"range": [-1.0, 1.0],
200+
}
175201
features[schema.image_key] = {
176202
"dtype": "video",
177203
"shape": list(schema.image_shape),
@@ -183,6 +209,7 @@ def hdf5_schema(schema: RecordingSchema) -> dict[str, object]:
183209
"fps": schema.fps,
184210
"robot_type": schema.robot_type,
185211
"hand_type": schema.hand_type,
212+
"neck_type": schema.neck_type,
186213
"features": features,
187214
}
188215

@@ -231,6 +258,13 @@ def normalize_hand_action(left_pose: object, right_pose: object) -> np.ndarray:
231258
return action
232259

233260

261+
def normalize_neck_action(yaw: object, pitch: object) -> np.ndarray:
262+
action = np.asarray([yaw, pitch], dtype=np.float32).reshape(-1)
263+
if action.shape[0] != NECK_ACTION_DIM:
264+
raise ValueError(f"recording action.neck must be {NECK_ACTION_DIM}D, got {action.shape[0]}")
265+
return action
266+
267+
234268
def build_mode_observation(mode: str) -> np.int8:
235269
normalized = str(mode).strip().lower()
236270
if normalized not in MODE_CODES:
@@ -332,6 +366,7 @@ def add_frame(
332366
mode: object,
333367
action: np.ndarray,
334368
hand_action: np.ndarray | None = None,
369+
neck_action: np.ndarray | None = None,
335370
) -> None:
336371
if not self._active or self._h5 is None:
337372
raise RuntimeError("Cannot add a recording frame without an active episode")
@@ -352,6 +387,17 @@ def add_frame(
352387
)
353388
elif hand_action is not None:
354389
raise ValueError(f"{self._schema.hand_action_key} must be omitted for hand_type={NO_HAND_TYPE}")
390+
neck_action_arr: np.ndarray | None = None
391+
if self._schema.has_neck_action:
392+
if neck_action is None:
393+
raise ValueError(f"{self._schema.neck_action_key} is required for neck_type={self._schema.neck_type}")
394+
neck_action_arr = self._validate_vector(
395+
neck_action,
396+
self._schema.neck_action_key,
397+
self._schema.neck_action_dim,
398+
)
399+
elif neck_action is not None:
400+
raise ValueError(f"{self._schema.neck_action_key} must be omitted for neck_type={NO_NECK_TYPE}")
355401

356402
row = self._frames_in_episode
357403
for dataset in self._datasets.values():
@@ -366,6 +412,8 @@ def add_frame(
366412
self._datasets[self._schema.action_key][row] = action_arr
367413
if hand_action_arr is not None:
368414
self._datasets[self._schema.hand_action_key][row] = hand_action_arr
415+
if neck_action_arr is not None:
416+
self._datasets[self._schema.neck_action_key][row] = neck_action_arr
369417
self._frames_in_episode += 1
370418

371419
def save_episode(self) -> None:
@@ -623,6 +671,12 @@ def _create_datasets(self, h5: h5py.File) -> dict[str, h5py.Dataset]:
623671
self._schema.hand_action_key,
624672
self._schema.hand_action_dim,
625673
)
674+
if self._schema.has_neck_action:
675+
datasets[self._schema.neck_action_key] = self._create_vector_dataset(
676+
h5,
677+
self._schema.neck_action_key,
678+
self._schema.neck_action_dim,
679+
)
626680
return datasets
627681

628682
@staticmethod

teleopit/sim2real/mp/ipc.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
BODY_TOPIC = "body"
1414
HAND_TOPIC = "hand"
1515
HAND_COMMAND_TOPIC = "hand_command"
16+
NECK_COMMAND_TOPIC = "neck_command"
1617
CONTROLLER_TOPIC = "controller"
1718
CONTROL_EVENTS_TOPIC = "control_events"
1819
REFERENCE_TOPIC = "reference"
@@ -28,6 +29,7 @@ class Sim2RealIpcEndpoints:
2829
body_pub: str
2930
hand_pub: str
3031
hand_command_pub: str
32+
neck_command_pub: str
3133
controller_pub: str
3234
control_events_pub: str
3335
reference_pub: str
@@ -46,6 +48,7 @@ def default_endpoints(*, host: str = "127.0.0.1", base_port: int = 39700) -> Sim
4648
body_pub=f"{prefix}{base_port}",
4749
hand_pub=f"{prefix}{base_port + 1}",
4850
hand_command_pub=f"{prefix}{base_port + 2}",
51+
neck_command_pub=f"{prefix}{base_port + 12}",
4952
controller_pub=f"{prefix}{base_port + 3}",
5053
control_events_pub=f"{prefix}{base_port + 4}",
5154
reference_pub=f"{prefix}{base_port + 5}",

teleopit/sim2real/mp/messages.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ class HandCommandPacket:
8282
seq: int
8383

8484

85+
@dataclass(frozen=True)
86+
class NeckCommandPacket:
87+
timestamp_s: float
88+
driver: str
89+
active: bool
90+
yaw: float
91+
pitch: float
92+
seq: int
93+
94+
8595
@dataclass(frozen=True)
8696
class HealthPacket:
8797
worker: str

0 commit comments

Comments
 (0)