Skip to main content
Records teleoperation episodes using VR controller inputs and locally attached ZED cameras. Saves to a LeRobot-format dataset. Loops until Ctrl+C.
For the end-to-end workflow, see the Data Collection guide.
This command is configured via draccus. The robot and teleop subsystems are exposed as the nested robot_config / teleop_config configs (cameras, per-joint gains, IK, VR server) — nest into them with dots or pass a whole-config file. See Command configuration.

Headset camera views

During collection the camera feeds are also relayed to the headset over WebRTC (hardware-encoded on the Jetson’s NVENC, installed by gst.install), so the operator sees the overhead and both wrist views at once while recording — freely moved and resized, same as teleop. Just like teleop, the cameras run in a dedicated subprocess (almond_axol.video.video_proc.VideoRelayProcess) so the grab/encode/WebRTC pump never contends with the teleop/IK loops — running it in-process measurably halves the IK rate. A single grab per camera feeds two independent GPU NVENC branches: the headset branch (fixed-bitrate H.264 over WebRTC) and the dataset branch — downscaled to --dataset_resolution on the GPU’s VIC and encoded to capped-VBR H.264 (see the --vcodec note above). On the Jetson the relay ships the dataset branch’s encoded H.264 access units over a gst-native shmsink (the recorder reads them with shmsrc), so the relay runs no Python per frame and — crucially — the recorder never re-encodes, it only muxes the already-encoded stream into the dataset’s mp4. That removes the second, redundant encode that used to run in the recorder (and the ~51 MB/s raw-frame copy that fed it), which was saturating CPU and dropping frames. A raw multiprocessing shared-memory copy (recorder-side NVENC) is the fallback when gst’s shm plugin is absent, and when the zed-gstreamer plugins aren’t built (see gst.build-zed) collection falls back to opening the cameras in-process (SDK grab + in-Python NVENC). Either way it’s still one grab — no second capture. See the VR Interface guide.

VR controller controls

Episodes are driven from the Quest controllers. The session is locked to data collection mode (no in-headset mode toggle). For the full controller layout and step-by-step flow, see the Data Collection guide. After each episode the robot automatically returns to its rest pose before the next take begins. If an existing dataset is found at --root, collection resumes from where it left off; conversely, if no episodes were saved before exit the empty dataset directory is removed on shutdown so an aborted session does not leave a half-initialized dataset on disk.
Dataset capture runs in a dedicated recorder subprocess (almond_axol.recording.record_proc), not just a thread — a thread would still share the control loop’s GIL, and the per-frame numpy / dataset.add_frame() / stats work was enough to stall the 120 Hz loop even with spare CPU cores. The control loop now only writes a small joint/action snapshot to shared memory each tick. On the Jetson (encoded shmsink path) the recorder is frame-driven: it consumes one pre-encoded H.264 access unit per camera, pairs it with the joint snapshot captured at the same perf_counter instant, muxes it into the mp4 (no re-encode), and saves the episode — so per-frame dataset work off the hot control loop is just the mux and the row bookkeeping. On the fallback paths the recorder instead ticks at --fps and NVENC-encodes the raw frames itself. Since the cameras are captured locally, frames and joint samples share a single perf_counter clock, so the pairing matches the cameras’ exposure timeline with no cross-machine time sync.The hot control loop runs on the robot’s own asyncio event loop (AxolRobot.event_loop), so each motion_control is awaited inline — cooperatively interleaved on a single thread, exactly like axol teleop. This removes the per-step cross-thread send_action round trip (run_coroutine_threadsafe(...).result()) that otherwise capped — and jittered — the control rate once teleop was engaged.For the same reason, data collection defaults to telemetry_hz=0: the control loop already issues motion_control every step, and every impedance/gripper command returns a feedback frame that refreshes the joint position/torque cache. Running the background 120 Hz × 16-motor poll loop on top of that just adds redundant CAN traffic and CPU that contends with motion_control — so collect-data skips it and relies on command replies, matching teleop’s bus load. (run-policy and other AxolRobot users keep the poll loop, since they don’t command every step.)