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"])Leave source_format out and it is read from codebase_version in
meta/info.json, so a LeRobot 2.1 directory uploads as lerobot_v2_1
without naming it. Qualia treats 2.1 and v3 as separate source
contracts; it never aliases one literal or layout to the other.
Video and row rates may differ at upload and are flagged for review; the viewer aligns them by time, but LeRobot download and fine-tuning require re-exporting the video at the row rate and uploading again.
The robot_type in meta/info.json becomes every episode’s rig, so the
dashboard can group and filter LeRobot data by robot. LeRobot carries no
operator or success verdict, so those stay empty, except for a handheld rig’s.
Handheld (UMI) recordings
Section titled “Handheld (UMI) recordings”A handheld pair records each hand at the tracker it carries, in a tracking
frame the headset sets up again every session, so its raw columns do not
compare across sessions. Qualia recognises a UMI upload by robot_type = "umi-handheld", by [rig] kind = "umi-handheld" in meta/ingest.toml, or by
its state channels (<side>_rot6d_0..5, <side>_x/y/z and <side>_gripper
for each hand), and derives each episode’s state in one frame for the pair:
- each tracker is moved through the rig’s mount to its gripper root;
- the frame is fixed from the first row of the episode that reads both hands (its first row, for a take tracked from its start): the origin halfway between the hands, z up, y level and towards the left hand;
- positions are in metres and roll, pitch and yaw in radians, with roll and yaw unwrapped so a turn through 180 degrees does not jump;
- the gripper angle becomes how far it has closed (
pi/4 - |angle|), and the rows the converter filled withpi/4keep the last real reading.
The result is published beside the untouched source as the channels
left.ee.x, left.ee.y, left.ee.z, left.ee.roll, left.ee.pitch,
left.ee.yaw, left.gripper.closure and the same for right, and it is the
state the dashboard’s charts, the episode trajectories and the dataset health
report read. The action stays as recorded, and training reads the source as
recorded. Because a handheld action is the next pose relative to this one, the
dataset health report does not measure a control delay for it.
An upload converts whole or not at all. If any take has no row that reads
both hands, or its hands start one above the other, no take is converted,
every take keeps its state as recorded, and the upload’s findings name the
takes that could not be placed. meta/ingest.toml is read only for [rig] on
a LeRobot upload; any other table it declares is named in the findings and not
applied. A LeRobot upload whose meta/ingest.toml cannot be read still
ingests: the file is ignored, as if absent, and a finding says why.
Two readers still see the raw state. An episode split into child episodes reads its children’s state as recorded, since the converted state belongs to the episode it was converted for. When an edit recomputes an episode’s quality evidence, that recomputation reads the state as recorded too.
A new handheld recording uploaded as ROS 2 MCAP converts the same way when
it declares [rig] kind = "umi-handheld", or when its selected state confidently
identifies a complete, unambiguous bilateral UMI pair without arm joints.
A declaration always wins over recognition. Its pair is found by
its state’s own topics: each hand’s tracker position and rotation (or
orientation) quaternion on a topic naming its side and a hand, controller,
end-effector, wrist or pose, and each jaw’s angle (<arm>_<side>_finger_joint1).
A sleeping controller (it reports exactly (0, 0, 0)) gives that row no pose,
and a jaw not yet read counts as open. A controller that loses tracking but
stays awake reports a pose the headset estimates, and that pose converts as if
it were measured. The recorded topics stay published
as they are; the converted state is published beside them, as for LeRobot. A
bag whose state holds no such pair publishes as recorded, and the upload’s
findings say why no take was converted. Missing or ambiguous jaws, a missing
hand or missing orientation keep the upload raw with an explanation, even if
its Cartesian hand positions identify a UMI collection tag.
To keep an undeclared pair raw, put infer_handheld = false immediately below
schema = 1 in meta/ingest.toml, before any table. This choice is persisted
and authenticated through corrective ingest and retries. An explicit converting
[rig] still wins; a non-converting declaration such as egocentric keeps the
recording raw. Conversion adds derived state bytes beside the unchanged bags.
Existing published recordings are not swept or bulk-converted: use Move to tool center point on a recording that needs repair or calibration.
An upload can also arrive converted without editing its file:
qualia dataset upload … --rig <gripper>(the SDK’srig=) names a gripper of your workspace: its name, its id, or its rig key (umi-handheldis the workspace’s UMI gripper). Qualia writes the gripper’s rig and TCP calibration into the upload’smeta/ingest.toml, so the upload converts at the TCP at once (at the gripper roots if the gripper has no calibration).- An upload that appends to a merged dataset (
--merge-target, or the destination the CLI saved) takes that dataset’s rig and calibration the same way when all of its uploads were converted with one, so it lands where they are and the append completes. If they disagree, or are not converted, the upload arrives as recorded and its append says what is needed.
Either way the recording itself is uploaded as recorded and your local file is
never written. What your own [rig] says is kept: the gripper or the merged
dataset only fills in what it leaves out, except that a named gripper’s TCP
replaces one your file gives (and a gripper with no calibration sets it aside,
converting at the gripper roots). The upload’s receipt (ingest_config) says what
was written, or why nothing was.
The UMI mount is built in. A rig calibrated differently overrides it per hand:
schema = 1
[rig]kind = "umi-handheld"
[rig.mount.left]xyz = [-0.063, 0.016, 0.052] # tracker in the gripper root's frame, metresrpy_deg = [0.0, -45.0, 45.0] # roll, pitch, yaw, degreesWhat kind of data an upload is
Section titled “What kind of data an upload is”Every episode carries a collection method: teleop, vr, handheld,
egocentric, policy_rollout or sim. Qualia fills it from what the upload
declares, in this order:
[rig] kindinmeta/ingest.toml:"umi-handheld"giveshandheld,"egocentric"givesegocentric(LeRobot and MCAP);- a rig the upload names: LeRobot’s
robot_type, or the recorder’srig_idin each bag’sepisode.json, when it is"umi-handheld"or"egocentric"; - on an upload that declares nothing more specific than
handheld, how its data is represented: a hand tracked in space, a left or right position recorded as x, y and z under any topic name, tags itumiunless the upload also records an arm’s joints, which are teleop (a gripper’s own finger joint is not an arm’s). A rotation alone, or a hand that two topics could be, is not enough; - otherwise the recording purpose: a recorder session whose
modeiscollection,teleopordemoisteleop, one ineval,inferenceorrolloutispolicy_rollout, and anything else is left empty. Nothing is guessed from channel names.
An egocentric rig (human capture from the wearer’s view, the hands as
Cartesian poses) has no tracker mount, so it takes no [rig.mount.*],
[rig.tcp.*] or [rig.calibration], and its state is published as recorded:
schema = 1
[rig]kind = "egocentric"Collection classification tags derive from saved method, environment, origin and
controller metadata. The dashboard’s common tag picker offers teleop,
vr, umi, egocentric, policy-rollout, sim, real-world, human and
gello, and also accepts your own tags. Environment, origin, collection
method and controller can coexist. Classification additions and removals edit
that metadata; custom tags remain ordinary editable tags. The picker’s Set
method to Unknown action explicitly clears human methods even when no chip is
visible, preserving axes and customs. A typed unknown is an ordinary custom tag.
Opening or reading a dataset does not declare it.
The SDK changes classifications atomically with
client.datasets.update(dataset, tags=["umi", "real-world", "human", "gello"]).
Nonempty custom-only input, or unchanged classifications with edited customs,
changes only custom tags and preserves declarations and inference. tags=[]
clears customs and explicitly makes every collection dimension Unknown. The CLI
uses qualia dataset tags <dataset> --tag umi --tag gello, custom-only --tag
values, or explicit full --clear. Both use the owner’s etag and require
independent collection metadata support. Older method and methods fields
remain compatibility views.
A merged dataset owns its collection tags. Editing them changes C alone,
and adding another upload keeps C’s choices. Dataset and episode filters,
mixture sources and reach selection use that owner’s tags. Recording and
curation metadata on an episode stays separate. Reach requires an umi or
egocentric collection tag and both hands’ physical pose channels
(<side>.ee.x/y/z/roll/pitch/yaw); the tag alone cannot supply missing poses.
On a merged dataset, Run solver checks every upload’s takes in
one run, with one progress, on the merged dataset’s target robot and reach
settings. Existing results are reused when the robot, reach settings and
pose calibration identity match. Combining TCP uploads with different
calibrations can require solving them again.
Poses at the gripper’s tool center point
Section titled “Poses at the gripper’s tool center point”The channels above are the gripper root’s. A rig whose trackers have been
calibrated to the gripper’s tool center point (TCP), the point where the gripper
holds things, can have an upload moved there too. Upload the calibration file
(tcp_calibration.json, one tracking controller → tool center point transform per hand) on an applicable robot’s
page under Embodiments. The file needs a
calibrated: YYYY-MM-DD date. In the Setup strip of the upload’s trajectories tab,
picking a robot under Play on robot whose arms all have a TCP moves the poses to the tool center point by itself,
once, with the calibration of the gripper whose rig key is the rig the upload was
recognised as recorded on, whichever of the three ways above recognised it. (The
Poses line also has a Move to tool center point button for where nothing starts by
itself, and a ⋯ menu with Move again and Back to the gripper root.)
Moving adds
left.tcp.x, left.tcp.y, left.tcp.z, left.tcp.roll, left.tcp.pitch,
left.tcp.yaw and the same for right, in the same frame as the gripper root’s
left.ee.*, which stay exactly as they were. Each TCP is the tracker’s pose
composed with the calibration’s transform, so the mount does not enter it.
Undoing it removes the channels again.
The automatic move does not run for a robot with no TCP, never undoes anything, and is not retried by itself: a failed one shows Try again. A move can be cancelled while it is preparing; once preparation commits, publication finishes. Moving rewrites the saved pose file and calibration metadata; it does not read or rewrite cameras, source tables or bags. Returning to the gripper root removes the TCP column. Both publish a new version, and neither is a rollback. Versions and pins made earlier keep the poses they had. If a review ruling or a trim lands while it runs, the move fails on the stale version and is run again. An upload with any take the pair frame could not place was not converted, and has nothing to move. A recording without complete saved pair-frame poses or published tracker mounts reports what is missing; the move does not silently re-ingest it. The dashboard’s trajectories draw each hand at its TCP once the upload is resolved there, and the upload’s versions tab lists it as “moved to tool center point” with an undo. A LeRobot export of converted handheld data requires calibrated TCP poses and emits twenty state values: left then right, each with six rotation values (the rotation matrix’s first two columns), TCP xyz and mirrored absolute finger-joint radians. It exports no action column: training derives targets from future state rows. Missing calibration is refused; move to tool center point and pin a new version first. Export preserves recorded success, validity, tracking and repair flags when present, and lists missing flags as training prerequisites. Raw MCAP conversion cannot manufacture that provenance. Roll and yaw swing through a turn where the TCP’s x axis stands vertical (pitch near +-90 degrees), so no check measures them.
The calibration is recorded in the upload’s own meta/ingest.toml, not looked up
from the rig, so replacing a rig’s calibration changes no upload until it is
moved again. The dashboard writes it; to author it yourself, give both hands
and where they came from:
[rig.tcp.left]xyz = [0.01758, -0.11946, -0.18229] # the TCP in the tracker's frame, metresrpy_deg = [143.8, 45.0, 180.0] # roll, pitch, yaw, degrees
[rig.tcp.right]xyz = [0.01758, 0.11946, -0.18229]rpy_deg = [36.2, 45.0, -180.0]
[rig.calibration]file_name = "tcp_calibration.json" # 1 to 256 charactersderive_date = "2026-10-13" # YYYY-MM-DD, or leave it outBoth hands are required (a pair converts whole), every number must be finite,
and [rig.calibration] needs the TCP tables. The stored rotation is roll, pitch
and yaw, so a rotation within a hair of pitch +-90 degrees keeps roll 0.
The operator’s verdicts come from the converter’s conversion_report.json at
the root of the upload: its converted attempts, in order, are the episodes, in
order. success marks an episode a success, failure and issue a failure.
A missing report, or one whose converted attempts do not match the episode
count, leaves every episode unjudged and says so in the upload’s findings; it
never refuses the upload.
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.
Cameras
Section titled “Cameras”A camera is any channel of one of these types. Its name comes from the topic,
so /cameras/head/compressed becomes cameras_head.
| Message type | What ingest reads |
|---|---|
sensor_msgs/msg/CompressedImage | one JPEG per message |
foxglove_msgs/msg/CompressedVideo (or the foxglove_msgs/CompressedVideo spelling) | an AV1 or H.264 stream, decoded in order |
Either way, each episode’s camera is stored as AV1 with one frame per row.
A video stream has to follow Foxglove’s rules for the type: one frame per message, no B-frames, and every keyframe carrying what a decoder needs to start there (the sequence header for AV1, SPS and PPS for H.264). An episode that starts between keyframes still decodes, from the keyframe before it. Frames before a channel’s first keyframe cannot be decoded and are left out. An episode whose stream cannot be decoded, or decodes with errors, publishes without that camera.
Other camera formats are left out with a camera_unsupported_encoding
finding: raw sensor_msgs/msg/Image, a non-JPEG CompressedImage, and video
in H.265, VP9 or any codec other than AV1 and H.264. An MCAP download writes its
cameras as AV1 CompressedVideo, so uploading one again brings its cameras
back. They are encoded once more on the way in.
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:
- A camera missing from some bags stays in the dataset: only the episodes from
bags without it publish without that camera, flagged
camera_absent_for_episode. - A camera that produced no decodable video anywhere is dropped from the
dataset, and every episode is flagged
camera_dropped. - 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" produced no video for 2 of 73 episode(s) (2 from bags with no decodable stream for it); those episodes publish without it and every other episode keeps itAn 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”.
The same report carries the upload’s report card, returned as validation:
every episode is ok or flagged, and only the flagged ones are listed,
each with the finding codes that flagged it. A flagged episode shows as
flagged on its data pages too, with the same codes as the reasons: it still
trains, but look at it. The card never excludes an episode; that stays a
reviewer’s decision. One case is more than advisory: an episode whose state or
action rows hold a NaN or an infinity (stream_non_finite), a missing value or
a wrong width is blocked. It is kept and flagged, and training skips it; the
dataset’s other episodes train. When every episode is blocked, nothing trains
and the values have to be fixed at the source, see
Declaring state and action channels. A
LeRobot download refuses a version that contains a blocked episode.
Unless every episode is blocked, publish a curated version without it into a
new dataset (qualia dataset curate <version> --idempotency-key <uuid> --name <new-dataset>) and download that; curation leaves out every episode it
recommends removing, not only blocked ones.
validation: flagged — 71 of 73 episode(s) ok, 2 flagged episode 17: stream_gap episode 40: episode_too_short, stream_rate_lowThe reasons are finding codes. An MCAP episode is checked for:
| Code | Flags when |
|---|---|
stream_gap | a state or action stream is silent for 0.1 s, or a camera for 0.5 s between its first and last frame, either raised to three of the stream’s own sample periods when that is longer — an operator pause inside an episode counts |
stream_rate_low | over two seconds, a camera delivers under half the frames of its best two seconds in the episode |
episode_too_short | the take spans less than 2 s |
camera_resolution_changed | frames inside the episode change size; the episode publishes without that camera |
stream_non_finite | a state or action stream carries a NaN or an infinity inside the episode; the values are published as they came, and one in a state or action row blocks the episode: training skips it |
stream_frozen | a state or action stream repeats one sample, bit for bit, for 5 s — judged on the stream’s own samples, not the row grid, so a slow command stream is not frozen by construction |
A LeRobot episode is checked for stream_gap, stream_non_finite and
stream_frozen on its state and action columns, with the same limits, timed by
its timestamp column. Timestamps generated from frame_index / fps sit on a
perfect grid, so they cannot show a gap. An episode too large to hold for these
checks (over 1 GiB of state and action) is flagged stream_checks_skipped
instead of being checked.
Findings ingest already reported also flag what they name: a camera that died,
went missing or could not be encoded (camera_absent_for_episode,
camera_dropped, camera_dropout, camera_unsupported_encoding,
camera_shape_changed, video_timeline_gap), a joint array that changed width
or a channel that could not be read (channel_width_coerced, channel_dropped,
bag_schema_mismatch), header stamps that step backwards even where the channel
runs on log_time (timestamp_non_monotonic; [clock] source = "log_time"
sets them aside), and data that did not publish (episode_skipped,
source_underivable, session_no_episodes). A finding that names no episode,
such as header stamps that step backwards somewhere in a bag, flags its source
and every episode that bag published: this is validation, not curation, and the
bag is unusable as a whole. Two read differently: episode_skipped is about an
episode that did not publish, so it flags the source alone, and a camera dropped
from the dataset flags every episode, since the dataset publishes without it.
LeRobot episodes appear on the card through the stream checks above and any
existing finding that names them. A LeRobot finding that names no episode flags
the source alone, because a LeRobot report lists no episodes under its source.
Flagged episodes publish and train like any other, apart from episodes with a NaN, an infinity, a missing value or a wrong width in their state or action rows. Those are blocked, stay flagged and are skipped by training. The card is there for you to decide.
The card also lists outliers: episodes that stand out against the rest of
the same upload. They are a separate axis from flagged. An outlier stays ok
when nothing flagged it, and it never changes the verdict.
validation: ok — 8 of 8 episode(s) ok, 0 flagged, 1 outlier(s) outlier episode 5: episode_no_movement| Code | Listed when |
|---|---|
episode_no_movement | on every action dimension that moves in the upload (state when there is no action), the episode’s range stays under 10% of the median range of its comparison group: the other episodes of its task when that task has at least 6, else the whole upload (at least 6 episodes). Dimensions whose median range is under 2% of the widest dimension’s are left out |
Every episode publishes with a task label. An episode that no source labels (no
task topic in the bag, no task in episode.json, no task in a LeRobot task
list) publishes as unlabeled, and the upload reports episode_task_missing,
a warning that marks the upload degraded. The episodes stay ok.
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. A press between two windows describes the take
that just ended, since an operator stops a take and then says how it went. A
press before the first window describes no take.
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.rotation.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].
A declared topic that is not a JointState, JointTrajectory or MultiArray
contributes every numeric field, so a controller message that carries
device_time_ns, pc_monotonic_ns and seq beside its pose puts those
counters into the state vector. [state.fields] lists the top-level fields to
keep for a topic, and [actions.fields] does the same for action topics:
[state]topics = ["/quest/controller/left/state"]
[state.fields]"/quest/controller/left/state" = ["position", "rotation"]Only the listed fields contribute, in the message’s own order whatever order you
write them, and a topic with no entry keeps every numeric field. It is a list of
fields to keep rather than to drop, so a field added to the message later cannot
widen the vector. The kept dims are always named by field path, never by a
name vector. Each key must be one of the declared topics, and each list
must be non-empty with no repeats. A name the bag’s message does not declare
fails that bag under [state], and the error lists the fields it does declare.
Under [actions] it drops the channel with a channel_dropped finding, as a
missing action topic does. A list cannot apply to a JointState,
JointTrajectory or MultiArray topic, which are read as fixed layouts, and
counts as a name the message does not declare. The telemetry table keeps every
field either way.
[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.
Left undeclared, topics that become the action only because their names say
cmd, command or action, with no joint command schema behind them, are
reported as action_elected_by_name warnings, and the upload is marked
degraded. There is one warning per topic in each bag, and each lists every
topic elected that way, because all of them are concatenated into the action.
Declare the ones that are commands under [actions] topics, or topics = []
to publish without an action column.
A successful MCAP upload prints the topics that fed each column, and returns
them as channel_summary:
state: /controller/left/pose, /controller/right/pose action: none (no action column)Read it before you train. action: none (no action column) is what
[actions] topics = [] produces, and also what a bag with no command topic
gets. Topics are listed by name; the vector follows the declared order.
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.
State and action channels that fit no episode publish a dataset nothing
can train on. If every episode has a NaN or an infinity in its state or
action vector, the upload still succeeds and the episodes are there to look
at, each flagged stream_non_finite with the topic that carried it. A request
for training data is refused and says that no episode can be trained on. On a
bag that usually means the election picked channels that do not describe the
rig — a sensor that publishes NaN while it has no reading, say. A topic that
records NaN to mean “not measured” cannot be declared under [state] or
[actions] yet. Declare the right topics under [state] and [actions]. The
bags are already stored, so you do not have to send them again: post
{"ingestConfig": "<the whole corrected file>"} to
POST /v1/accounts/{account}/datasets/{dataset}:reingest and the upload is
re-derived from them as a new version. The file replaces the published one, so
repeat everything it declared. Poll the reingest.name it returns
(GET /v1/accounts/{account}/datasets/{dataset}/reingests/{reingest}) until its
state is SUCCEEDED (or FAILED, with failureReason saying why), then
train on the new version: the original version
stays untrainable. The qualia.v1 API Reference has both
calls. Uploading again with the corrected file works too, and is the only way
for a LeRobot source. Uploading the original folder again re-derives the upload
from what that folder holds, so it undoes a re-ingest.
Re-cutting an upload into takes
Section titled “Re-cutting an upload into takes”A recording that published as one long episode, because its config only closed
an episode on pause, can be cut into takes after the fact. Compute the take
windows outside q-core and re-ingest with them as [episodes] spans, through the
same :reingest call. Keep event_topic and the label values, so presses still
tag the takes, and drop pause/resume, because a role that cuts is refused
under spans:
schema = 1
[episodes]event_topic = "/recorder_event"spans = [ { start = "2026-10-06T09:39:01.5Z", end = "2026-10-06T09:39:24Z" }, { start = "2026-10-06T09:39:31Z", end = "2026-10-06T09:39:52.25Z" },]
[episodes.events]success = "label"issue = "label"Two ways to get the windows:
-
Home rest. A handheld rig rests between takes. Read the take events your rig records (for a UMI rig, the started/ended pairs on
/umi/episode_events) from the bags, and give each take one window. These come from the bags, so the instants are already on the recorder’s clock. -
Operator marks. Each take ends on the operator’s
successorissuepress. The marks are already stored on the long episode, as itsmarkersin seconds from the start of the recording. Each window ends at a press, so the press tags the take it closes. The next window starts 1 ns after that press, because windows that touch are refused. With the recording starting at 09:39:00 and presses at 24 s and 52.25 s:spans = [{ start = "2026-10-06T09:39:00Z", end = "2026-10-06T09:39:24Z" },{ start = "2026-10-06T09:39:24.000000001Z", end = "2026-10-06T09:39:52.25Z" },]
Re-cut identity follows footage, not its position in the new list. An unchanged recording and exact selected frames keep the take’s ID and number, along with keeps/rejects, issues/notes, reaches and saved draft. New or changed footage gets a fresh take number above the upload’s highest historical ordinary number. Numbers can have gaps or be out of time order. Removed/changed old takes leave head; their identities and audit stay on record instead of moving to new footage.
After publication, source settings shows Not carried to the new takes for old takes with state that no longer applies. For example, a changed take may list a person’s keep, two issues and a saved draft. Legacy applied trims and splits instead return the take as recorded: the list names its applied trim or split-piece count. An unchanged take keeps its draft and person state even when such a legacy edit is reset. Old versions still contain exactly their pinned episodes, windows and cuts.
Only a verified published result can report no state lost. A zero count means nothing is listed; a missing or failed result read does not. Retry reading result reads the same committed report without running re-ingest again. A failed or canceled publication leaves the old head and state intact.
Older uploads need supported replay of their original recordings and ingest contract before identity can be established. Missing raw files, unsupported historical toolchains, ambiguous selections or mismatched reproduced output refuse with a reason; matching duration or take numbers is insufficient. Copies that deliberately did not carry direct raw identity cannot be re-cut through that indirect provenance. A same-span camera correction preserves identities only while its reference frames/grid are unchanged; retiming those frames is refused. After a sparse re-cut, an older worker without the required mapping is refused rather than renumbering existing footage. A non-re-cut full re-ingest retains its legacy split-child restriction.
Re-cut an upload before you merge it. The merged dataset holds the uploads’ takes as they were cut, and neither it nor the uploads it holds are re-cut afterwards (the request is refused, naming the merged dataset). Uploading a session again under the name of one that was merged is refused the same way: upload it as a new session, or add a new session to the merged dataset.
Takes the recorder marked nothing at
Section titled “Takes the recorder marked nothing at”Where no marker says where a take starts, find the instant yourself and
draw a split there. client.episodes.sample_series reads traces and
client.episodes.sign_videos signs camera links. Try the recording-time
instants with validate_only first:
client.episodes.split(dataset, {episode: [12.5, 31.0]}, validate_only=True)client.episodes.split(dataset, {episode: [12.5, 31.0]})preview = client.versions.preview(dataset, include=("kept",))# Read every preview_items page and review any unprocessable takes first.version = client.versions.create(dataset, "1.0", preview=preview)This explicitly includes every kept take in the upload, including a fresh,
unflagged take that no person has kept yet. Saving a split does not change its
standing. If the take is flagged or rejected, review it and choose the matching
include group. Takes outside the selected groups are filtered before the
unprocessable list, so review the frozen items as well as omissions.
Split saves a drawing, not new head episodes. Publish freezes its kept
windows, measures them and creates immutable pieces only when you create
a version. A saved split remains editable on the original take; a piece in
an old version is read only. From the CLI, draw with
qualia dataset split <episode> -d <dataset> --at 12.5 --at 31 --dry-run,
then without --dry-run; inspect
qualia dataset version-preview <dataset> --include kept
and publish with qualia dataset pin <dataset> 1.0 --preview <preview-name>.
With footage-based re-cut enabled, an unchanged take retains that saved drawing;
changed footage is reported if the drawing is not carried. See
Curation for preview selections and omissions.
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.