Skip to content

Commit 6da60d7

Browse files
committed
Record LinkerHand actions in sim2real datasets
1 parent ea7cb7d commit 6da60d7

13 files changed

Lines changed: 342 additions & 36 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ target_dof_pos = clip(action, -10, 10) × action_scale + default_dof_pos
148148
- Optional LinkerHand control uses `hands.enabled=true`, `hands.driver=linkerhand_l6|linkerhand_o6`, and `hands.mode=gripper|vr_hand_pose`; default is disabled
149149
- Optional Pico sim2real LeRobot v3 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
150150
- 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
151-
- Recording captures `observation.images.d435i_rgb` RealSense RGB video at 30Hz plus `observation.state(68)`, `observation.mode(1)`, and `action(36)`; RealSense capture lives in `pico_input` through the normal `input.video` path
151+
- Recording captures `observation.images.d435i_rgb` RealSense RGB video at 30Hz plus `observation.state(68)`, `observation.mode(1)`, `action(36)`, and `action.hand(12)`; RealSense capture lives in `pico_input` through the normal `input.video` path
152152
- `gripper` mode reuses `Pico4InputProvider.get_controller_snapshot()` for Pico grip/trigger open-close control and supports LinkerHand L6 and O6
153153
- `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
154154
- Teleopit owns Pico 26-joint hand-state to 21-landmark conversion; do not import `somehand.pico_input`

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,9 @@ python scripts/run/run_sim2real.py --config-name sim2real_record \
9898
Recording uses the terminal controls `R` start, `S` save, `D` discard, and `Q`
9999
shutdown. `STANDING`, `MOCAP`, `ARMS`, and paused mocap can be recorded. The
100100
dataset schema is `observation.images.d435i_rgb` video at 30 Hz,
101-
`observation.state(68)`, `observation.mode(1)`, and `action(36)` as the aligned
102-
reference qpos sent to the policy path.
101+
`observation.state(68)`, `observation.mode(1)`, `action(36)` as the aligned
102+
reference qpos sent to the policy path, and `action.hand(12)` as the latest
103+
LinkerHand left/right 6D pose commands.
103104

104105
## Documentation
105106

@@ -112,7 +113,7 @@ Full docs at **[BotRunner64.github.io/Teleopit](https://BotRunner64.github.io/Te
112113
- Added Pico sim2real `ARMS` mode: Pico/controller `B` toggles between whole-body `MOCAP` and stand-pose body/legs with live retargeted arms.
113114
- Bumped Pico input support to pico-bridge 0.2.1 and its corrected tracking pose semantics.
114115
- Added optional LinkerHand L6 sim2real modes under `hands.*`: `gripper` from Pico grip/trigger and `vr_hand_pose` from Pico hand pose through somehand 0.2.0 public API.
115-
- Added manual Pico sim2real LeRobot v3 recording with RealSense D435i RGB video, 68D robot state, mode labels, and 36D reference-qpos action labels.
116+
- Added manual Pico sim2real LeRobot v3 recording with RealSense D435i RGB video, 68D robot state, mode labels, 36D reference-qpos action labels, and 12D LinkerHand pose action labels.
116117
- Added LinkerHand O6 support for Pico `gripper` mode with an O6-specific grasp pose.
117118
- Set LinkerHand L6 `vr_hand_pose` control to maximum speed while keeping `gripper` at the configured default speed.
118119
- Switched default `vr_hand_pose` to a low-latency somehand path with 60 Hz hand retargeting and reduced smoothing.

docs/docs/configuration/config-reference.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,16 @@ observation.images.d435i_rgb video [480,640,3] uint8
186186
observation.state float32[68]
187187
observation.mode float32[1]
188188
action float32[36]
189+
action.hand float32[12]
189190
```
190191

191192
`observation.state` is ordered as `joint_pos(29)`, `joint_vel(29)`,
192193
`base_quat_wxyz(4)`, `base_ang_vel(3)`, and `projected_gravity(3)`.
193194
`observation.mode` is a numeric categorical: `standing=0`, `mocap=1`,
194195
`arms=2`, and `pause=3`. `action` is the current reference qpos:
195196
`root_pos(3) + root_quat_wxyz(4) + joint_pos(29)`.
197+
`action.hand` is the latest LinkerHand command from the hand worker:
198+
`left_pose(6) + right_pose(6)`, using the SDK's 0-255 pose values.
196199

197200
## Critical: `default_dof_pos`
198201

docs/docs/tutorials/pico-sim2real.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ Terminal controls are `R` start episode, `S` save, `D` discard, and `Q`
117117
shutdown. `STANDING`, `MOCAP`, `ARMS`, and paused mocap can be recorded;
118118
saved episodes cannot be discarded afterward. The v1 schema records
119119
`observation.images.d435i_rgb`, `observation.state(68)`,
120-
`observation.mode(1)`, and `action(36)` at 30 Hz.
120+
`observation.mode(1)`, `action(36)`, and `action.hand(12)` at 30 Hz.
121121

122122
## Operator Flow
123123

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,13 @@ observation.images.d435i_rgb video [480,640,3] uint8
203203
observation.state float32[68]
204204
observation.mode float32[1]
205205
action float32[36]
206+
action.hand float32[12]
206207
```
207208

208209
`observation.state` 的顺序是 `joint_pos(29)``joint_vel(29)`
209210
`base_quat_wxyz(4)``base_ang_vel(3)``projected_gravity(3)`
210211
`observation.mode` 是数值类别:`standing=0``mocap=1`
211212
`arms=2``pause=3``action` 是当前 reference qpos:
212213
`root_pos(3) + root_quat_wxyz(4) + joint_pos(29)`
214+
`action.hand` 是手部 worker 最新的 LinkerHand 命令:
215+
`left_pose(6) + right_pose(6)`,使用 SDK 的 0-255 pose 数值。

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ python scripts/run/run_sim2real.py \
112112
终端控制为:`R` 开始 episode,`S` 保存,`D` 丢弃,`Q` 关闭。可以录制
113113
`STANDING``MOCAP``ARMS` 和暂停状态的 mocap;已经保存的 episode 不支持再丢弃。
114114
v1 schema 以 30 Hz 记录 `observation.images.d435i_rgb``observation.state(68)`
115-
`observation.mode(1)``action(36)`
115+
`observation.mode(1)``action(36)``action.hand(12)`
116116

117117
## 操作流程
118118

teleopit/recording/lerobot_v3.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@
1919
STATE_KEY = "observation.state"
2020
MODE_KEY = "observation.mode"
2121
ACTION_KEY = "action"
22+
HAND_ACTION_KEY = "action.hand"
2223
STATE_DIM = 68
2324
MODE_DIM = 1
2425
ACTION_DIM = FULL_QPOS_DIM
26+
HAND_ACTION_DIM = 12
2527
DEFAULT_IMAGE_SHAPE = (480, 640, 3)
2628
MODE_CODES = {
2729
"standing": 0,
@@ -41,6 +43,8 @@ class RecordingSchema:
4143
mode_dim: int = MODE_DIM
4244
action_key: str = ACTION_KEY
4345
action_dim: int = ACTION_DIM
46+
hand_action_key: str = HAND_ACTION_KEY
47+
hand_action_dim: int = HAND_ACTION_DIM
4448

4549

4650
def build_recording_schema(camera_cfg: Any) -> RecordingSchema:
@@ -74,6 +78,11 @@ def lerobot_features(schema: RecordingSchema) -> dict[str, dict[str, object]]:
7478
"shape": (schema.action_dim,),
7579
"names": ["action"],
7680
},
81+
schema.hand_action_key: {
82+
"dtype": "float32",
83+
"shape": (schema.hand_action_dim,),
84+
"names": ["hand_action"],
85+
},
7786
}
7887

7988

@@ -114,6 +123,16 @@ def modality_sidecar(schema: RecordingSchema) -> dict[str, object]:
114123
"joint_pos": [7, 36],
115124
},
116125
},
126+
schema.hand_action_key: {
127+
"type": "low_dim",
128+
"shape": [schema.hand_action_dim],
129+
"dtype": "float32",
130+
"units": "linkerhand_uint8_pose",
131+
"slices": {
132+
"left_pose": [0, 6],
133+
"right_pose": [6, 12],
134+
},
135+
},
117136
},
118137
}
119138

@@ -149,6 +168,19 @@ def normalize_action_reference_qpos(reference_qpos: object) -> np.ndarray:
149168
return action
150169

151170

171+
def normalize_hand_action(left_pose: object, right_pose: object) -> np.ndarray:
172+
left = np.asarray(left_pose, dtype=np.float32).reshape(-1)
173+
right = np.asarray(right_pose, dtype=np.float32).reshape(-1)
174+
if left.shape[0] != 6:
175+
raise ValueError(f"recording left hand pose must be 6D, got {left.shape[0]}")
176+
if right.shape[0] != 6:
177+
raise ValueError(f"recording right hand pose must be 6D, got {right.shape[0]}")
178+
action = np.concatenate([left, right], dtype=np.float32)
179+
if action.shape[0] != HAND_ACTION_DIM:
180+
raise ValueError(f"recording action.hand must be {HAND_ACTION_DIM}D, got {action.shape[0]}")
181+
return action
182+
183+
152184
def build_mode_observation(mode: str) -> np.ndarray:
153185
normalized = str(mode).strip().lower()
154186
if normalized not in MODE_CODES:
@@ -228,6 +260,7 @@ def add_frame(
228260
state: np.ndarray,
229261
mode: np.ndarray,
230262
action: np.ndarray,
263+
hand_action: np.ndarray,
231264
task: str,
232265
) -> None:
233266
if not self._active:
@@ -238,18 +271,22 @@ def add_frame(
238271
state_arr = np.asarray(state, dtype=np.float32).reshape(-1)
239272
mode_arr = np.asarray(mode, dtype=np.float32).reshape(-1)
240273
action_arr = np.asarray(action, dtype=np.float32).reshape(-1)
274+
hand_action_arr = np.asarray(hand_action, dtype=np.float32).reshape(-1)
241275
if state_arr.shape[0] != self._schema.state_dim:
242276
raise ValueError(f"{self._schema.state_key} must be {self._schema.state_dim}D")
243277
if mode_arr.shape[0] != self._schema.mode_dim:
244278
raise ValueError(f"{self._schema.mode_key} must be {self._schema.mode_dim}D")
245279
if action_arr.shape[0] != self._schema.action_dim:
246280
raise ValueError(f"{self._schema.action_key} must be {self._schema.action_dim}D")
281+
if hand_action_arr.shape[0] != self._schema.hand_action_dim:
282+
raise ValueError(f"{self._schema.hand_action_key} must be {self._schema.hand_action_dim}D")
247283
self._dataset.add_frame(
248284
{
249285
self._schema.image_key: image_arr,
250286
self._schema.state_key: state_arr,
251287
self._schema.mode_key: mode_arr,
252288
self._schema.action_key: action_arr,
289+
self._schema.hand_action_key: hand_action_arr,
253290
"task": str(task),
254291
}
255292
)

teleopit/sim2real/hands/worker.py

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,71 +2,107 @@
22

33
import logging
44
import time
5-
from typing import Any
5+
from typing import Any, Sequence
66

77
from teleopit.runtime.common import cfg_get
8-
from teleopit.sim2real.hands.base import HandDevice, HandInputMapper
8+
from teleopit.sim2real.hands.base import HandDevice, HandInputMapper, HandPoseCommand
99
from teleopit.sim2real.hands.linkerhand_l6 import build_linkerhand_l6
1010
from teleopit.sim2real.hands.linkerhand_o6 import build_linkerhand_o6
1111

1212
logger = logging.getLogger(__name__)
1313

1414

1515
class HandRuntime:
16-
def __init__(self, device: HandDevice, mapper: HandInputMapper):
16+
def __init__(
17+
self,
18+
device: HandDevice,
19+
mapper: HandInputMapper,
20+
*,
21+
open_commands: Sequence[HandPoseCommand] = (),
22+
):
1723
self._device = device
1824
self._mapper = mapper
1925
self.enabled = True
2026
self._failed = False
27+
self._open_commands = tuple(open_commands)
2128

22-
def start(self) -> None:
29+
def start(self) -> tuple[HandPoseCommand, ...]:
2330
try:
2431
self._device.connect()
2532
self._mapper.start()
33+
return self._open_pose_commands("startup")
2634
except Exception:
2735
try:
2836
self._device.close()
2937
finally:
3038
raise
3139

32-
def tick(self, *, controller_snapshot: object | None, hand_snapshot: object | None, active: bool, now_s: float | None = None) -> None:
40+
def tick(
41+
self,
42+
*,
43+
controller_snapshot: object | None,
44+
hand_snapshot: object | None,
45+
active: bool,
46+
now_s: float | None = None,
47+
) -> tuple[HandPoseCommand, ...]:
3348
if self._failed:
34-
return
49+
return ()
3550
now = time.monotonic() if now_s is None else float(now_s)
3651
try:
37-
for command in self._mapper.map(
52+
commands = self._mapper.map(
3853
controller_snapshot=controller_snapshot,
3954
hand_snapshot=hand_snapshot,
4055
active=active,
4156
now_s=now,
42-
):
57+
)
58+
sent: list[HandPoseCommand] = []
59+
for command in commands:
4360
self._device.send_pose(command.side, command.pose, force=command.force, reason=command.reason)
61+
sent.append(command)
62+
return tuple(sent)
4463
except Exception:
4564
self._failed = True
4665
logger.exception("Hand runtime failed; disabling hand control")
4766
try:
4867
self._device.open_all(force=True, reason="failure")
4968
except Exception:
5069
logger.exception("Failed to open hand after hand runtime failure")
70+
return ()
71+
return self._open_pose_commands("failure")
5172

52-
def close(self) -> None:
73+
def close(self) -> tuple[HandPoseCommand, ...]:
5374
try:
5475
self._mapper.close()
5576
finally:
5677
self._device.close()
78+
return self._open_pose_commands("shutdown")
79+
80+
def _open_pose_commands(self, reason: str) -> tuple[HandPoseCommand, ...]:
81+
return tuple(
82+
HandPoseCommand(command.side, command.pose, True, reason)
83+
for command in self._open_commands
84+
)
5785

5886

5987
class DisabledHandRuntime:
6088
enabled = False
6189

62-
def start(self) -> None:
63-
pass
90+
def start(self) -> tuple[HandPoseCommand, ...]:
91+
return ()
6492

65-
def tick(self, *, controller_snapshot: object | None, hand_snapshot: object | None, active: bool, now_s: float | None = None) -> None:
93+
def tick(
94+
self,
95+
*,
96+
controller_snapshot: object | None,
97+
hand_snapshot: object | None,
98+
active: bool,
99+
now_s: float | None = None,
100+
) -> tuple[HandPoseCommand, ...]:
66101
del controller_snapshot, hand_snapshot, active, now_s
102+
return ()
67103

68-
def close(self) -> None:
69-
pass
104+
def close(self) -> tuple[HandPoseCommand, ...]:
105+
return ()
70106

71107

72108
def build_hand_runtime(cfg: Any) -> HandRuntime | DisabledHandRuntime:
@@ -80,4 +116,13 @@ def build_hand_runtime(cfg: Any) -> HandRuntime | DisabledHandRuntime:
80116
device, mapper = build_linkerhand_o6(cfg)
81117
else:
82118
raise ValueError(f"Unsupported hands.driver={driver!r}; supported drivers: linkerhand_l6, linkerhand_o6")
83-
return HandRuntime(device, mapper)
119+
return HandRuntime(device, mapper, open_commands=_open_commands_from_device(device))
120+
121+
122+
def _open_commands_from_device(device: HandDevice) -> tuple[HandPoseCommand, ...]:
123+
config = getattr(device, "config", None)
124+
sides = tuple(str(side).strip().lower() for side in getattr(config, "sides", ()))
125+
open_pose = tuple(int(value) for value in getattr(config, "open_pose", ()))
126+
if not sides or len(open_pose) != 6:
127+
return ()
128+
return tuple(HandPoseCommand(side, open_pose, True, "open") for side in sides)

teleopit/sim2real/mp/ipc.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
BODY_TOPIC = "body"
1414
HAND_TOPIC = "hand"
15+
HAND_COMMAND_TOPIC = "hand_command"
1516
CONTROLLER_TOPIC = "controller"
1617
CONTROL_EVENTS_TOPIC = "control_events"
1718
REFERENCE_TOPIC = "reference"
@@ -26,6 +27,7 @@
2627
class Sim2RealIpcEndpoints:
2728
body_pub: str
2829
hand_pub: str
30+
hand_command_pub: str
2931
controller_pub: str
3032
control_events_pub: str
3133
reference_pub: str
@@ -43,15 +45,16 @@ def default_endpoints(*, host: str = "127.0.0.1", base_port: int = 39700) -> Sim
4345
return Sim2RealIpcEndpoints(
4446
body_pub=f"{prefix}{base_port}",
4547
hand_pub=f"{prefix}{base_port + 1}",
46-
controller_pub=f"{prefix}{base_port + 2}",
47-
control_events_pub=f"{prefix}{base_port + 3}",
48-
reference_pub=f"{prefix}{base_port + 4}",
49-
mode_pub=f"{prefix}{base_port + 5}",
50-
video_pub=f"{prefix}{base_port + 6}",
51-
record_pub=f"{prefix}{base_port + 7}",
52-
health_pub=f"{prefix}{base_port + 8}",
53-
command_pub=f"{prefix}{base_port + 9}",
54-
reference_command_pub=f"{prefix}{base_port + 10}",
48+
hand_command_pub=f"{prefix}{base_port + 2}",
49+
controller_pub=f"{prefix}{base_port + 3}",
50+
control_events_pub=f"{prefix}{base_port + 4}",
51+
reference_pub=f"{prefix}{base_port + 5}",
52+
mode_pub=f"{prefix}{base_port + 6}",
53+
video_pub=f"{prefix}{base_port + 7}",
54+
record_pub=f"{prefix}{base_port + 8}",
55+
health_pub=f"{prefix}{base_port + 9}",
56+
command_pub=f"{prefix}{base_port + 10}",
57+
reference_command_pub=f"{prefix}{base_port + 11}",
5558
)
5659

5760

teleopit/sim2real/mp/messages.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ class RecordStepPacket:
7171
seq: int
7272

7373

74+
@dataclass(frozen=True)
75+
class HandCommandPacket:
76+
timestamp_s: float
77+
driver: str
78+
mode: str
79+
active: bool
80+
left_pose: Float64Array
81+
right_pose: Float64Array
82+
seq: int
83+
84+
7485
@dataclass(frozen=True)
7586
class HealthPacket:
7687
worker: str

0 commit comments

Comments
 (0)