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"])

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.

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 with pi/4 keep 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’s rig=) names a gripper of your workspace: its name, its id, or its rig key (umi-handheld is the workspace’s UMI gripper). Qualia writes the gripper’s rig and TCP calibration into the upload’s meta/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, metres
rpy_deg = [0.0, -45.0, 45.0] # roll, pitch, yaw, degrees

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:

  1. [rig] kind in meta/ingest.toml: "umi-handheld" gives handheld, "egocentric" gives egocentric (LeRobot and MCAP);
  2. a rig the upload names: LeRobot’s robot_type, or the recorder’s rig_id in each bag’s episode.json, when it is "umi-handheld" or "egocentric";
  3. 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 it umi unless 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;
  4. otherwise the recording purpose: a recorder session whose mode is collection, teleop or demo is teleop, one in eval, inference or rollout is policy_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, metres
rpy_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 characters
derive_date = "2026-10-13" # YYYY-MM-DD, or leave it out

Both 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.

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 camera is any channel of one of these types. Its name comes from the topic, so /cameras/head/compressed becomes cameras_head.

Message typeWhat ingest reads
sensor_msgs/msg/CompressedImageone 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.

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_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" 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 it

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”.

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_low

The reasons are finding codes. An MCAP episode is checked for:

CodeFlags when
stream_gapa 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_lowover two seconds, a camera delivers under half the frames of its best two seconds in the episode
episode_too_shortthe take spans less than 2 s
camera_resolution_changedframes inside the episode change size; the episode publishes without that camera
stream_non_finitea 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_frozena 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
CodeListed when
episode_no_movementon 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.

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 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. 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.

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.

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 success or issue press. The marks are already stored on the long episode, as its markers in 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.

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.