Skip to content

Data Collection

Qualia accepts three distinct structured robotics source formats: LeRobot 2.1 directories (lerobot_v2_1), LeRobot v3 directories (lerobot_v3), and ROS 2 MCAP sessions (mcap_ros2). It does not require a Hugging Face dataset import or Hub token; publish the local recording through the Qualia SDK, which validates and pairs its Git history with lakehouse rows.

Qualia supports several collection workflows for vision-language-action (VLA) models:

  • Manual data collection - Record demonstrations manually
  • Automated collection - Use scripts to gather data at scale
  • Existing recordings - Upload a local LeRobot 2.1, LeRobot v3, or ROS 2 MCAP recording

LeRobotDataset v3.0 stores synchronized robot trajectories, camera video, and indexing metadata. Record with a LeRobot release that writes the v3 layout, then upload the local dataset directory:

from qualia import Qualia
client = Qualia()
result = client.data.upload(
"/data/record-test",
name="record-test",
source_format="lerobot_v3",
)
print(result["dataset_id"], result["episode_count"])

For an existing LeRobot 2.1 directory, pass source_format="lerobot_v2_1". Qualia treats 2.1 and v3 as separate source contracts; it never aliases one literal or layout to the other.

Lay out one directory per recording and upload with source_format="mcap_ros2". Every .mcap file is ingested as its own source, so a rosbag2 size split (bag_0.mcap, bag_1.mcap, …) is fine — each file yields its own episodes, and a take that straddles a split boundary becomes two. An optional episode.json sidecar per directory can supply task and recording-purpose fallbacks.

There is no fixed single-file ceiling on the current data plane: a 400 GiB bag is admitted at upload/begin like any other, and the limits that apply are negotiated there. Still split long sessions at the recorder (rosbag2’s own split size defaults to 2 GB): a bag is the unit of containment, so a damaged or underivable file costs only its own episodes, and a smaller file is a smaller re-upload when a recording has to be fixed.

A source the platform can partly derive is published rather than rejected, so a successful upload can contain less than you recorded. The rules worth knowing before your first session:

  • Bags must agree on their camera set. If they disagree, the dataset is degraded to the intersection and the cameras outside it are dropped dataset-wide — not just from the bags that lacked them. Group bags by camera set and upload each group as its own dataset if your rig changed mid-session.
  • A camera that produced no decodable video anywhere is dropped the same way.
  • A camera that dies partway through keeps every episode it did cover; the episodes it missed publish without it.
  • A bag whose state/action dimensions disagree with the rest is skipped whole.
  • A damaged bag costs its own episodes, not the upload. A bag with a broken envelope — a bad record length, a recorder killed mid-write — still uploads, at generic chunk boundaries so it deduplicates worse, and ingest reports it source_underivable at warn. Every other bag publishes; the dataset comes back DEGRADED with that finding in ingest_warnings. Fix the recording and re-upload: the good bags are already deduplicated.
  • A row clock above the 240 Hz encode ceiling is re-elected onto a synthetic uniform grid at the slowest camera’s rate, floored at 30 Hz and capped at the ceiling (30 Hz when no camera is rateable). A 750 Hz /joint_states beside 30 fps cameras publishes at 30 Hz, beside 60 fps cameras at 60 Hz, unless you say otherwise — pin the cadence with [clock] fixed_hz = <rate> or name the channel that carries it with [clock] row_clock_topic = "<topic>". The lever is the slow camera: a grid faster than it would repeat frames it never sampled. The elected clock is recorded in the report’s row_clock.

Every one of these is recorded in meta/ingest_report.json, and the SDK prints them at the end of the upload and returns them as ingest_warnings:

done: kitchen generation 1 — 74 file(s), 41.2 GiB, 73 episode(s), 219000 frame(s)
1 ingest finding(s) — this dataset was published DEGRADED:
warn: camera "wrist_left" was dropped from the dataset: it is not present
in every bag (first disagreement: raw/session/bag_31.mcap)

An empty ingest_warnings means the report was read and was clean. If it could not be read, ingest_warnings_unavailable says why — that is not the same as clean, and should be treated as “go look at the report”.

Boundaries are resolved highest precedence first: anything you declare in meta/ingest.tomlspans, then an event vocabulary — then what can be read out of the bag: authored markers, an operator-verdict topic, a done-style boolean, and finally one episode per bag. A declaration always outranks a heuristic, so the two config blocks below are how you stop guessing what ingest will do. The report records which rule fired, as boundary_mechanism (e.g. ingest-toml-spans) and boundary_source (authored when you declared it).

A rig with its own marker vocabulary declares it in meta/ingest.toml instead of hoping for a heuristic:

schema = 1
[episodes]
event_topic = "/rig/marks"
[episodes.events] # your values; unlisted ones are ignored
go = "open"
halt = "close"
ok = "label" # a label describes a take, it does not cut one

A label describes the take it falls inside, and two rules decide what reaches the episode’s tags. The same value pressed repeatedly is one outcome, however many times it was pressed — a held or bounced pedal does not multiply a verdict. And a different value pressed within a second retracts the one before it: an operator who presses success, sees the mistake and immediately presses issue has corrected a mispress, not recorded two outcomes, so the take is tagged issue alone. Presses further apart are two real outcomes and both are kept.

Nothing is erased to do this. Every press stays on the episode’s marker timeline with its instant, so the dashboard still shows the correction that happened and a curation pass can disagree with it — the rule decides only what the take is tagged.

If your boundaries live outside the bag — a sidecar, a manifest, a database of take timestamps — compute them and declare the windows directly. This is the one strategy that reads nothing from the recording:

schema = 1
[episodes]
spans = [
{ start = "2026-08-12T09:00:00Z", end = "2026-08-12T09:02:30Z" },
{ start = "2026-08-12T09:05:00Z", end = "2026-08-12T09:07:10Z" },
]

Instants are RFC 3339 on the recorder’s clock (MCAP log_time). Windows are closed intervals, so they must be in time order and must neither overlap nor touch — start a back-to-back take one nanosecond later, or its boundary sample would belong to two episodes.

One list covers a whole multi-bag session: a window that names no message in a given bag simply contributes no episode to it. A bag that no window reaches is skipped and recorded in the ingest report at info — a session-wide list is expected to leave most of its bags out, so this never publishes the dataset degraded. A span list that misses a file costs you that file, not the upload.

You can declare event_topic alongside spans. They answer different questions: the windows decide where takes are cut, the vocabulary names what the operator pressed inside them. Under spans a value may only be label (or ignore) — mapping one to open/close/cut is an error, because the windows already decide the boundaries.

If a bag carries both the topic you declared and q-core’s own /recorder_event, the windows still decide every edge — neither topic moves one — and only the topic you declared is read for what its values mean. The report names the other one, so it is never silently unread.

Declaring windows changes only where takes are cut, not what the operator recorded inside them: a verdict topic in the bag is still carried through, it just no longer decides the boundaries.

By default, state is every JointState-schema channel and actions are elected from schema and topic-name evidence. Both elections can be replaced from meta/ingest.toml — the rig knows its own wiring, and a rig that publishes end-effector or controller poses (handheld, teleop or egocentric capture) has no JointState channel for the default election to find:

[state]
topics = ["/quest/controller/left/state", "/quest/controller/right/state"]
[actions]
topics = []

[state] topics lifts the JointState schema requirement: any schema contributes every numeric field in its .msg definition order (a nested message flattens depth-first within its field; header, strings and bools are skipped), each dim is named by its field path under the topic key — quest_controller_left_state.orientation.w, end_effector_pose.pose[0] — unless the message carries a name vector as wide as the row, and the topics concatenate in the declared order. The first topic is the row clock; a [clock] row_clock_topic moves its topic to the front. The topics key is required (a [state] table without it is refused, not read as no declaration), the list cannot be empty — rows are clocked by the first state channel — and a [clock] row_clock_topic must be one of the listed topics: the state channels are exactly this list, so a clock outside it could never match, and the config is refused at upload/begin rather than warning on every bag. A topic is one column or the other: it cannot be listed under both [state] and [actions].

[actions] topics = [] means explicitly no action column, which the election can never conclude on its own. Declare it on any rig whose only command-shaped topic is operator input hardware — otherwise a topic like /portable_joy_command matches the name election and button presses become the dataset’s action vector. Asking for cut_at_last_action alongside it is refused: there is no action column to cut at.

Every bag has to carry every topic you declare under [state], with samples on it. A bag missing one, or carrying it as a channel that recorded nothing, is dropped from the upload — the rest of the session publishes without it. This is deliberate: the alternative is a dataset whose state vector silently narrows partway through, which no training run would notice. It is also the one rule the config check at upload/begin cannot decide for you, because it depends on what each bag actually contains rather than on the config. So a config that legitimately spans a whole session should name only the channels every take recorded — if one arm is brought up for some takes and not others, declare the channels they share and let the per-bag findings tell you what was dropped.

[actions] is looser, because the action column is optional. A declared action topic the bag does not carry — whether it registered the channel and recorded nothing, or never registered it at all — is dropped with a channel_dropped finding, and the bag publishes without that column. Those are one physical situation (a controller that never published; rosbag2 opens a channel only for a subscription it made), so they degrade the same way.

A bad config costs you one second, not one transfer

Section titled “A bad config costs you one second, not one transfer”

meta/ingest.toml is validated at upload/begin, against the same parser the real ingest uses, before the repo exists and before a byte moves. A typo is refused in about a second with the line, the column and the accepted keys:

Error: /v1/datasets/upload/begin -> HTTP 400: invalid data: meta/ingest.toml: TOML parse error at line 5, column 1
|
5 | event_topics = ["/recorder_event"]
| ^^^^^^^^^^^^
unknown field `event_topics`, expected one of `cut_at_last_action`, `event_topic`, `events`, `spans`

Unknown keys are errors, not silently ignored, so a misspelled setting is caught here rather than discovered as a wrong result later.