> ## Documentation Index
> Fetch the complete documentation index at: https://docs.almond.bot/llms.txt
> Use this file to discover all available pages before exploring further.

# collect-data

> Record teleoperation episodes to a LeRobot-format dataset.

Records teleoperation episodes using VR controller inputs and locally attached ZED cameras. Saves to a [LeRobot](https://github.com/huggingface/lerobot)-format dataset. Loops until `Ctrl+C`.

<Tip>
  For the end-to-end workflow, see the [Data Collection guide](/operations/data-collection).
</Tip>

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](/cli/configuration).

| Flag                                                             | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--repo_id <user>/<dataset>`                                     | HuggingFace dataset repo ID (required)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `--task TEXT`                                                    | Natural language task description (required)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `--fps INT`                                                      | Dataset recording frame rate — camera frames captured at this rate (default: 60). On the Jetson recording path dataset rows are paced by camera frame arrival, so this **must match the camera capture fps** — a mismatch would stamp the dataset with a frame rate it wasn't captured at (replay/training would run at the wrong speed), so collect-data errors early instead.                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `--dataset_resolution {SVGA,HD1080,HD1200}`                      | Resolution the **dataset** video is recorded at (default: `SVGA` = 960×600), downscaled from the camera capture on the GPU's VIC. SVGA is \~4× lighter to encode and store than `HD1200` while the **headset still gets the full-resolution stream**. Resuming an existing dataset keeps its original resolution regardless of this flag.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `--vcodec STR`                                                   | Video codec recorded for the dataset (default: per-platform — `h264` on Jetson/aarch64, `auto` elsewhere). Accepts any LeRobot codec (e.g. `auto`, `h264`, `libsvtav1`). On the Jetson `h264` is encoded on the NVENC hardware via GStreamer, in **VBR mode with a peak cap** (\~0.17 bits/pixel target, ≈6 Mbps at SVGA/60fps) so every camera's dataset video stays bounded and uniformly sized — a pathologically noisy sensor is compressed down to the target instead of ballooning the dataset.                                                                                                                                                                                                                                                                                                                          |
| `--teleop_hz INT`                                                | Motor command rate in Hz; decoupled from `--fps` for smooth control (default: 120)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `--teleop_config.vr_teleop_config.reset_torque_threshold FLOAT`  | Contact watchdog for **every** return-to-rest — post-episode, pre-record **X** resets, and the startup move (default: 4.0 Nm). If any arm joint's torque residual (measured minus modeled gravity) stays above this threshold for a sustained window, the move is judged to have hit something — a gripper still hooked on the scene, or the operator grabbing an arm — and the arms drop into a limp **gravity-compensation hold** instead of pulling through. Hand-guide them clear, then press **X** (reset) to replan home **from wherever the arms were left**. Raise it if normal returns false-trip on friction/model error; `0` disables the watchdog. Shared with [`teleop`](/cli/teleop) (`--teleop.reset_torque_threshold` there), so both flows behave identically; also settable robot-wide in the control panel. |
| `--teleop_config.vr_teleop_config.reset_gravity_comp_kd FLOAT`   | Velocity damping (Nm·s/rad) for the arm joints during the contact-fallback gravity-comp hold (default: 0.25); same semantics as [`gravity-comp`](/cli/gravity-comp) `--kd`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `--teleop_config.vr_teleop_config.teleop_torque_threshold FLOAT` | Contact watchdog for the **tracking** phase — active while the operator drives the arms during a take, on top of the always-on return-to-rest guard. **Off by default** (`0`), since recording pushes on the scene on purpose; set a threshold (the control panel suggests `16.0`) and a trip **disengages tracking, discards the in-flight episode**, and drops the arms into the limp gravity-comp hold — press **X** to return to rest and re-record. Shared with [`teleop`](/cli/teleop) (`--teleop.teleop_torque_threshold`).                                                                                                                                                                                                                                                                                             |
| `--root PATH`                                                    | Local dataset root (default: `$HF_LEROBOT_HOME`)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `--push_to_hub true`                                             | Push to HuggingFace Hub when done                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `--robot_config.cameras DICT`                                    | The camera slots (`overhead` / `left_arm` / `right_arm`) to use, with each ZED camera's serial number, as one inline YAML/JSON value: `--robot_config.cameras "{overhead: {serial: 41234567}, left_arm: {serial: 41234568}, right_arm: {serial: 41234569}}"`. Unassigned slots are dropped, so you only configure the cameras you have. **At least one assigned camera must have recording enabled** (the default) — collect-data errors early otherwise.                                                                                                                                                                                                                                                                                                                                                                      |
| ↳ `record` / `stream` (per camera)                               | Each camera opts into two independent branches: `record` (default `true`) writes it to the dataset, and `stream` (default `true`) relays it to the headset. Set `record: false` to stream a camera to the operator without recording it, or `stream: false` to record it without sending it to the headset. A camera with both off is dropped.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| ↳ `stereo` (auto-detected)                                       | Stereo ZED X cameras are detected from their serial — on the CLI *and* the [control panel](/guides/control-panel#cameras) — so you never flag it by hand. Auto-detection applies the head/wrist eye convention: a stereo **overhead** records both eyes as two observation keys, `overhead_left` / `overhead_right` (one grab), while a stereo **wrist** (`left_arm` / `right_arm`) records only its left eye under the plain slot name, so it captures, encodes, and records exactly like a mono camera. Setting `stereo: true` by hand **skips** auto-detection and uses the `eyes` field instead (default `both` → `X_left` / `X_right`; `left` / `right` → a single eye under the plain name `X`) — so to force a single-eye wrist, also pass `eyes: left`.                                                                |
| ↳ `eyes` / `stream_eyes` (per stereo camera)                     | `eyes` picks which eye(s) are **recorded** (`both` / `left` / `right`); `stream_eyes` independently picks which are **streamed** to the headset (`null`, the default, follows `eyes`). Decouple them to e.g. stream `both` eyes for depth perception while recording only `left`: `--robot_config.cameras "{overhead: {serial: 41234567, eyes: left, stream_eyes: both}}"`.                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `--robot_config.video_backend {auto,gst,sdk}`                    | Camera capture backend (default: `auto`). `gst` is the GPU-resident zed-gstreamer pipeline (low latency, shared with the headset relay); `sdk` is the ZED Python SDK; `auto` prefers `gst` when its stack ([`gst.install`](/cli/gst-install) + [`gst.build-zed`](/cli/gst-build-zed)) is installed and falls back to the SDK.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `--robot_config.axol_config.<side>.gripper.torque_limit FLOAT`   | Max torque (Nm) for a gripper in POSITION\_FORCE mode (default: 0.5); `<side>` is `left`/`right`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `--robot_config.axol_config.left_stiffness S`                    | Compliance↔stiffness blend for the left arm in `[0, 1]`. Scalar or 7-element list (one per arm joint, in `Joint` enum order). `0` = fully compliant; `1` = pre-tuning industrial gains; `0.5` (default) is the geometric mean. Use `right_stiffness` for the right arm. See [`AxolConfig.left_stiffness`](/api/robot).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `--teleop_config.vr_teleop_config.position_multiplier FLOAT`     | Scale factor on hand **position** (not orientation): the end-effector target moves this many times as far as your hand. `1.0` (default) is 1:1; `2.0` moves the arm twice as far, helping cover the robot's full reach when the arm is longer than the operator's.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `--teleop_config.vr_teleop_config.rotation_multiplier FLOAT`     | Scale factor on hand **orientation** (not position): the end-effector target rotates this many times as far as your wrist. `1.0` (default) is 1:1; `2.0` rotates the arm twice as far.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `--rerun_ip IP`                                                  | IP of a Rerun viewer on your local machine for live visualization                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `--rerun_port INT`                                               | Rerun viewer port (default: 9876); only used when `--rerun_ip` is set                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `--log_level {DEBUG,INFO,WARNING,ERROR}`                         | Default: `INFO`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `--config_path PATH`                                             | Load a whole-config JSON/YAML file; CLI overrides layer on top.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |

```bash theme={null}
axol collect-data --repo_id myorg/pick-place --task "Pick the red cube and place it in the bin" \
    --robot_config.cameras "{overhead: {serial: 41234567}, left_arm: {serial: 41234568}, right_arm: {serial: 41234569}}"
axol collect-data --repo_id myorg/pick-place --task "Pick the red cube" --fps 30 \
    --robot_config.cameras "{overhead: {serial: 41234567, stereo: true}, left_arm: {serial: 41234568}, right_arm: {serial: 41234569}}" \
    --robot_config.axol_config.left_stiffness 1.0 --robot_config.axol_config.right_stiffness 1.0
```

## 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`](/cli/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`](/cli/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](/guides/vr-interface#camera-views).

## 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](/operations/data-collection#controller-layout).

| Button | Action                                                                                                                                                                                                                                                                                                                                    |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A**  | Start a take (3-second countdown). While recording, arms the **Save episode?** confirmation — press **A** again to save (the headset then enters `Saving` until the write completes), or **X** to cancel and keep recording                                                                                                               |
| **X**  | During the start countdown, cancels it; otherwise resets to the rest pose — including out of the contact-fallback gravity-comp hold, where it sends the (possibly hand-guided) arms home. While recording, arms the **Discard episode?** confirmation — press **X** again to discard and re-record, or **A** to cancel and keep recording |

Launched from the [control panel](/guides/control-panel#episode-controls) instead, the same decisions are also available as on-screen buttons — start a take, save it, discard it, and end a contact hold — alongside the mirrored camera feeds, so a session can be driven with the headset off. The controller stays live regardless; whichever input arrives first ends the take.

After each episode the arms return to the rest pose automatically, but **guarded**: a **torque watchdog** compares each joint's measured torque against the gravity model while the move plays. If the residual stays above the reset torque threshold — a gripper still hooked on the scene, or an operator grabbing an arm — the move stops where it is and the arms drop into a limp **gravity-compensation hold**, free to be hand-guided clear (the episode keeps saving in the background). Press **X** when they're clear and the collision-aware return replans from wherever the arms actually are. Recording is blocked while limp — a start press is answered with a reminder to reset first. The same watchdog can optionally cover the **tracking** phase too (`--teleop_config.vr_teleop_config.teleop_torque_threshold`, off by default): a trip mid-take **discards the in-flight episode** and drops the arms limp, then an **X** reset returns to rest to re-record. 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.

**Every episode is durable the moment it saves.** LeRobot's writers normally keep their parquet files open (footerless — unreadable) for the whole session and only make them readable at shutdown, so a killed session used to lose every episode it recorded, and shutdown had a session's worth of flushing and video verification to do. Collect-data instead flushes the parquet footers right after each `save_episode` and rotates to fresh per-episode files (a legal LeRobot packing), so at any point between saves the dataset on disk is complete, readable, and resumable. This also retires the per-save video concat that rewrote the whole accumulating chunk file (saves no longer slow down as the session grows), and the decode-verification of each episode's video runs in the background right after its save instead of over the whole session at exit — so `Ctrl+C` stops quickly, and killing the process outright costs at most the episode currently being written.

A resume still runs a **crash-consistency check** for that one remaining window (and for datasets recorded before durability landed): if the dataset's saved episodes aren't a contiguous `0…N-1` — the signature of a recorder killed mid-save (SIGKILL, OOM, power loss), losing episodes whose writers never flushed while the episode counter had already advanced — the dataset is **repaired in place** rather than recorded past the gap (a gap silently pairs every later episode with a different episode's video). The lost episodes' frames are unrecoverable (they died in footerless parquet files), so the repair truncates back to the longest verified contiguous prefix — metadata row present, data rows readable and complete, video files present — deletes the crashed session's orphaned files, rewrites `info.json`/`stats.json` to match, and resumes recording right after the last intact episode (logged loudly with exactly what was discarded). Only when not a single complete episode survives does collect-data refuse and point you to delete the dataset.

<Info>
  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.)
</Info>
