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

# Exporting datasets

> Turn a recorded session into dataset rows, check the correlation quality, and write your own dataset.

The SDK ships the join: `zeroruntime.teleops.export` reads a recorded session and
builds one row per observation. There is no ready-made LeRobot export script.
Write the rows into your dataset format yourself, because dataset layouts change
between LeRobot versions.

To check a session before you export it, print `export.summarize(rows)` for each
episode, as in [Walking a session](#walking-a-session). It reports the row count,
the measured frame rate, and how many rows were joined by id rather than by time.
To drop the guessed rows, keep only rows whose `correlation` is `explicit`.

<Tip>
  Run exports on your own machine, not the robot. Transcoding images is CPU
  heavy, and the robot needs its CPU for the control loop.
</Tip>

## Walking a session

```python theme={null}
from zeroruntime.teleops import export

session = "sessions/20260813-141200"
manifest = export.load_manifest(session)
joints = export.manifest_joints(manifest)

for ep_dir in export.find_episodes(session):
    episode = export.load_episode(ep_dir)
    rows = export.build_rows(episode, joints=joints)
    print(ep_dir, export.summarize(rows))
```

| Function | Returns |
| - | - |
| `find_episodes(session_dir)` | episode directories, sorted |
| `load_manifest(session_dir)` | the manifest dict |
| `manifest_joints(manifest)` | the flat joint vector, in order |
| `manifest_groups(manifest)` | each group with a `start`/`stop` slice into that vector |
| `load_episode(ep_dir)` | the four streams plus `meta`. Missing streams come back empty |
| `build_rows(episode, joints=None, require_images=True)` | `list[Row]`, one per observation |
| `effective_fps(rows)` | `(fps, jitter_ms)`, measured from real capture times |
| `summarize(rows)` | the quality dict below |

`build_rows()` is pure. It touches no filesystem beyond checking that the images
a row references exist, and it does not import LeRobot, so you can test a join
without a training stack installed.

## Correlation quality

Each row records how its action was matched to its observation.

| `correlation` | How the action was found |
| - | - |
| `explicit` | the command cited this observation's id. An id join, no timestamps |
| `temporal` | no id, so the first action applied at or after the capture time was used |
| `none` | no action was found at all |

`explicit` is what you want. `temporal` happens when the leader never stamped an
observation id, and those rows are labelled rather than mixed in silently, so you
can filter, weight or reject them.

<Note>
  Even an `explicit` row is an upper bound rather than proof. The id a command
  carries is the newest observation the leader had **received**, and video
  reaches the operator's screen by its own path with its own delay, so what they
  were actually looking at may be slightly older.

  The follower records both ids for exactly this reason: `observation_id` is
  what the leader claimed, `observation_id_local` is what was current on the
  robot at that moment. The gap between them in `applied.jsonl` is the size of
  the doubt, so you can measure it rather than assume it away.
</Note>

```python theme={null}
s = export.summarize(rows)
print(s["correlation_rate"])        # share of rows joined by id
print(s["correlation_temporal"])    # how many were guessed

good = [r for r in rows if r.correlation == "explicit"]
```

`summarize()` also gives you `rows`, `fps_effective`, `fps_jitter_ms`,
`camera_skew_ms_max`, `camera_skew_ms_mean`, `obs_to_applied_ms_mean` and
`rows_limited`.

<Note>
  `fps_effective` is measured from the joint read times rather than declared, so
  a recording that ran at 27 Hz reports 27 rather than the 30 you asked for.
  `fps_jitter_ms` is the spread around it.
</Note>

## What a row holds

`action` is the operator's target at the moment the frame was captured: what
a policy should predict from that frame. `applied` is what the arm was actually
told in response (after the safety limits), which arrives a fraction of a
second later, so training on it would teach a policy to lag. `target` is that
response before the limits, so the difference stays recoverable.

| Field | What it is |
| - | - |
| `state` | joints read on this observation |
| `action` | the operator's target at capture: train on this |
| `action_kind` | how `action` was found: `interp`, `hold`, or `none` (dropped unless `keep_unlabeled=True`) |
| `applied` | what the arm was told in response, after the limits |
| `target` | the same response, before the limits |
| `correlation` | how `applied` was matched: `explicit` (by id), `temporal` (a guess), or `none` |
| `images`, `frame_ids`, `frame_capture_ts` | per camera label |
| `skew_ms` | spread between the cameras in this row |
| `timestamp_s` | seconds from the start of the episode |
| `was_limited` | whether a limit fired on this row |

Per-camera capture times are kept rather than averaged away, so a wrist camera
running 60 ms behind the front camera is visible in the data.

## Related

<CardGroup cols={2}>
  <Card title="Recording format" icon="folder-tree" href="/robotics/guides/recording-format">
    The streams these functions read, and the drop counters to check first.
  </Card>

  <Card title="Recording" icon="floppy-disk" href="/robotics/concepts/recording">
    Turning recording on and bounding episodes.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.