Data Collection
Overview
Section titled “Overview”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
LeRobot 2.1 and v3
Section titled “LeRobot 2.1 and v3”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.
ROS 2 MCAP
Section titled “ROS 2 MCAP”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.
Ingest degrades, it does not refuse
Section titled “Ingest degrades, it does not refuse”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_underivableatwarn. Every other bag publishes; the dataset comes back DEGRADED with that finding iningest_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_statesbeside 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’srow_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”.
Declaring episode boundaries
Section titled “Declaring episode boundaries”Boundaries are resolved highest precedence first: anything you declare in
meta/ingest.toml — spans, 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 ignoredgo = "open"halt = "close"ok = "label" # a label describes a take, it does not cut oneA 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.
Declaring state and action channels
Section titled “Declaring state and action channels”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.