
Two cameras and an action stream never sample the same instant. What LeRobot really records, why the timestamp column hides the skew, and how to measure it before 50 episodes.
A single camera gives you one timing question: how old is the picture the policy sees. Add a second and you have three streams that never sample the same instant. Wrist view, scene view and the joint positions that become the action label are each fixed by different hardware, and nothing forces them to agree. This article is about how far apart they drift in a LeRobot dataset and what a two-frame skew does to a policy trained on it.
Everything below about upstream behaviour comes from the lerobot source on main at commit 7427f318 (22 August 2026; latest tagged release v0.6.1, 3 August 2026). New to recording? Start with recording your first dataset and come back when you add the second camera.
What you need to know
- •One dataset row is not one instant: lerobot-record reads the joint state, peeks each camera in turn, then reads the teleoperator action.
- •The timestamp column is frame_index / fps, a nominal grid rather than a measurement, so the dataset cannot show you the real skew.
- •read_latest() is a non-blocking peek with a default max_age_ms of 500: a frame half a second old is accepted silently.
- •A constant skew is survivable. A skew redrawn every tick is label noise, and it shows up as hesitation near contact.
- •Two USB cameras on one host controller share periodic bandwidth. When one falls behind, the peek hands the loop a stale frame, not an error.
- •Measure the spread with a physical sync event before recording 50 episodes.
Three clocks and one row
Recording an episode on an SO-100 during teleoperation means running a loop at a fixed rate, usually 30 Hz, and writing one row per iteration: an image per camera, the follower arm's measured joint positions, and the action, which is the leader arm's position. Each value is fixed at a different moment inside the same 33.3 ms tick.
| Stream in the row | Where its instant is actually fixed | What controls the delay |
|---|---|---|
| observation.images.wrist | In a background thread, after videocapture.read() returns and the frame has been colour-converted and rotated | Exposure, the USB schedule, the decode cost |
| observation.images.front | The same, in a second thread with an independent phase | The other camera, independently of the first |
| observation.state | At the top of get_observation(), when bus.sync_read("Present_Position") returns | The serial round trip to the Feetech bus |
| action | After the whole observation, when teleop.get_action() reads the leader arm | The teleop device, plus everything above it in the tick |
The ordering matters. The action in row n was sampled later than the images in row n, and the images were captured before the loop asked for them. The dataset stores none of this.
What lerobot-record actually does per tick
The recording loop is a plain sequence with a pacing sleep at the end. Condensed from the upstream script (what it writes is covered in the dataset docs), one iteration looks like this:
# lerobot/scripts/lerobot_record.py, record_loop(), condensed
timer.tick()
with timer.section("observe"):
obs = robot.get_observation() # state first, then every camera
with timer.section("teleop"):
act = teleop.get_action() # leader arm, read after the images
with timer.section("send"):
robot.send_action(robot_action_to_send)
with timer.section("record"):
frame = {**observation_frame, **action_frame, "task": single_task}
dataset.add_frame(frame)
timer.wait() # sleep until this tick's 1/fps deadlineInside get_observation() the follower does the same thing for every camera you declared, in dictionary order:
# lerobot/robots/so_follower/so_follower.py, condensed:
# the timing logs and the optional depth branch are left out
def get_observation(self) -> RobotObservation:
obs_dict = self.bus.sync_read("Present_Position", num_retry=self.config.num_read_retries)
obs_dict = {f"{motor}.pos": val for motor, val in obs_dict.items()}
for cam_key, cam in self.cameras.items():
if getattr(cam, "use_rgb", True):
obs_dict[cam_key] = cam.read_latest() # non-blocking peek
return obs_dictLeRobot's cameras offer three access modes. read() blocks until the hardware delivers a new frame. async_read(timeout_ms) returns the latest unconsumed frame, blocking only on an empty buffer, default timeout 200 ms. read_latest(max_age_ms) peeks at whatever is in the buffer, may return a stale frame, and raises TimeoutError only once that frame is older than max_age_ms. The docs say plainly that during recording the loop peeks the freshest buffered frames non-blockingly via read_latest(): the right choice for a live loop, and the reason the two views are not the same instant.
The timestamp column is a grid, not a measurement
This is the part that catches people. When a frame is appended to the episode buffer, its timestamp is not read from any clock:
# lerobot/datasets/dataset_writer.py, DatasetWriter.add_frame()
frame_index = self.episode_buffer["size"]
timestamp = frame_index / self._meta.fps
self.episode_buffer["frame_index"].append(frame_index)
self.episode_buffer["timestamp"].append(timestamp)Row 90 of an episode recorded at 30 fps is stamped 3.0 s whether that tick took 33 ms or 61 ms. Each camera's MP4 is encoded at the same declared rate, so video and parquet timestamps agree by construction rather than by measurement. Two consequences follow.
- Real skew is invisible. A wrist camera consistently two frames behind the scene camera produces timestamps that are perfectly regular and perfectly wrong.
- A slow loop is recorded as if it had been fast. At 22.4 Hz, 90 rows are still stamped 3.0 s end to end when they really spanned about 4.0 s.
On load, the decoder verifies that the closest decodable frame is within tolerance_s of the requested timestamp (LeRobotDataset defaults it to 1e-4 s), otherwise raising FrameTimestampError with a message that even says the cause "might be due to synchronization issues with timestamps during data collection". It reads like the guard you want. It is not: query and presentation timestamps both come from the same nominal 1/fps grid, so it finds damaged files, never a camera that is genuinely two frames old. See the dataset rejection page for what this layer does catch.
Where the skew comes from
Six mechanisms contribute, and they do not add up in a fixed way. The sizes below are what the mechanism can produce, expressed in frames at 30 fps, where one frame is 33.3 ms.
| Source | Mechanism | Size at 30 fps |
|---|---|---|
| Peek phase | Each camera thread stores a frame when the device delivers one; the loop peeks at an unrelated moment | 0 to 1 frame per camera, redrawn each tick |
| Exposure | The timestamp is taken after the frame arrives, but the light was integrated earlier | A fraction of a frame, more in low light |
| USB scheduling | Two UVC streams on one host controller share the same periodic bandwidth budget | 0 to several frames once the bus saturates |
| Decode and conversion | MJPEG decode, cv2.cvtColor and any rotation run before latest_timestamp is taken | Sub-frame, scaling with resolution |
| Slow ticks | PNG writing or encoding overruns the 1/fps budget, so fewer rows are produced per second | Whole ticks, silently |
| State versus action | The follower is read at the top of the tick, the leader at the bottom | Sub-frame, systematic, one direction |
The recording example in the upstream imitation-learning guide uses one camera at 1920x1080 and 30 fps. Copy that line, add a second camera at the same size on the same USB controller, and the two isochronous streams compete for one periodic bandwidth budget. The failure mode is not an error: one camera delivers fewer frames, the peek starts handing the loop images it already used, and you find out weeks later when the policy will not close the gripper on time. The Linux UVC driver ships a UVC_QUIRK_FIX_BANDWIDTH workaround (0x00000080) for devices that misreport what they need, which says something about how common this is. Record both at 640x480, ask for MJPEG, and split them across controllers. A camera that vanishes entirely is a different failure.
What a two-frame skew does to a trained policy
A constant skew is a relabelling
Suppose the wrist image is always exactly two frames behind the action in the same row. At 30 fps that is 66.7 ms. The policy does not learn a wrong function, it learns a shifted one: emit the action that followed this scene by 66.7 ms. That is perfectly learnable by imitation learning, and it costs reaction time and nothing else, on one condition: the same offset has to exist when the policy runs.
That condition is what the Universal Manipulation Interface work formalises. UMI measures each observation stream's latency separately, then, in their words, aligns all observations with respect to the stream with the highest latency, usually the camera, using each image's capture timestamp to linearly interpolate the gripper and proprioception streams onto it. Its bimanual cameras are soft-synchronised by nearest-neighbour matching, off by at most 1/60 s. The standard is not zero skew but known and reproduced skew.
A skew that changes every tick is label noise
The peek-based loop does not give you a constant offset. It gives you one redrawn every tick, between zero and one camera frame interval, plus whatever the bus and the decoder add. The same visual scene now appears alongside several different actions, and gradient descent does the only thing it can with contradictory labels: it predicts near their mean. On the arm that reads as a policy which approaches confidently and hedges in the last few centimetres, exactly where a 66.7 ms visual error changes the correct action most.
Nothing in the architecture averages the jitter away. Chunking policies predict from the observation in front of them: ACT emits a chunk of 100 actions with nActionSteps 100 on this platform, with no image history for the jitter to cancel against. The averaging happens in the loss instead. If gripper timing is what fails, the gripper failure page walks the same symptom from the other end.
And the skew at deployment is a different number
Recording peeks inside a 30 Hz loop; running the policy adds inference on top. On this platform the per-action-step cost is 20 ms for ACT, 152 ms for GR00T N1.7, 165 ms for GR00T N1.5, 245 ms for SmolVLA and 485 ms for Pi0.5, all five on the policies page. Action chunking amortises that, since one forward pass yields a chunk. It does not repair training data, and it adds a seam at chunk boundaries, which is what the real-time chunking work addresses and what lerobot-rollout implements behind --inference.type=rtc.
- The loop never blocks on a camera, so a stalled USB device cannot freeze teleoperation
- The image handed to the loop is the freshest available
- A slow camera does not drag the other camera's phase along with it
- The same code path runs at recording and rollout, so the bias is the same kind in both
- The real age of each frame is written nowhere in the dataset
- max_age_ms defaults to 500, half a second of staleness accepted silently
- Two cameras drift independently, so the views are not the same instant
- The offset is redrawn every tick, the harmful kind rather than the survivable one

Measure it before you record 50 episodes
None of this needs special hardware. Four steps, about ten minutes, and you will know whether your rig has a one-frame spread or a five-frame spread.
- 1Confirm what the driver actually negotiated
Auto-discovery reports the default profile, which is often not the one you asked for. On Linux, check the negotiated format and frame interval rather than trusting the config you passed.
bashlerobot-find-cameras opencv # Linux: what the device really offers, and what it is currently set to v4l2-ctl -d /dev/video0 --list-formats-ext v4l2-ctl -d /dev/video0 --get-parm - 2Log how old each frame is at the instant the loop would peek it
Each camera object exposes latest_timestamp, taken with time.perf_counter() right after post-processing. Against the tick time it gives the frame's age; the difference between cameras is the skew.
pythonimport time from lerobot.cameras.opencv import OpenCVCamera, OpenCVCameraConfig cams = { "wrist": OpenCVCamera(OpenCVCameraConfig(index_or_path=0, width=640, height=480, fps=30)), "front": OpenCVCamera(OpenCVCameraConfig(index_or_path=2, width=640, height=480, fps=30)), } for c in cams.values(): c.connect() try: for _ in range(300): # 10 s at 30 Hz tick = time.perf_counter() ages = {} for name, c in cams.items(): c.read_latest() # the same call the record loop makes with c.frame_lock: ages[name] = (tick - c.latest_timestamp) * 1e3 spread = max(ages.values()) - min(ages.values()) print(" ".join(f"{n}={a:5.1f}ms" for n, a in ages.items()), f"spread={spread:5.1f}ms") time.sleep(1 / 30) finally: for c in cams.values(): c.disconnect() - 3Record ten seconds with a hard visual edge
Point both cameras at the same light switch or the same book being clapped shut, and record one short episode. This measures the whole chain end to end.
bashlerobot-record \ --robot.type=so100_follower \ --robot.port=/dev/ttyACM0 \ --robot.id=my_follower \ --robot.cameras='{front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}, wrist: {type: opencv, index_or_path: 2, width: 640, height: 480, fps: 30}}' \ --teleop.type=so100_leader \ --teleop.port=/dev/ttyACM1 \ --teleop.id=my_leader \ --dataset.repo_id=${HF_USER}/sync-check \ --dataset.num_episodes=1 \ --dataset.episode_time_s=10 \ --dataset.single_task="sync check" \ --dataset.push_to_hub=false - 4Find the flash in each video and subtract
Find the first frame in each MP4 whose mean brightness jumps. The difference in frame indices is the residual skew after exposure, bus, decode and peek phase have had their say.
pythonimport cv2 def first_flash(path, thresh=8.0): cap, prev, idx = cv2.VideoCapture(path), None, 0 try: while True: ok, frame = cap.read() if not ok: return None y = float(frame.mean()) if prev is not None and y - prev > thresh: return idx prev, idx = y, idx + 1 finally: cap.release() f, w = first_flash("front.mp4"), first_flash("wrist.mp4") print(f"front={f} wrist={w} skew={None if None in (f, w) else w - f} frames")
The fifth check is free: read what the recorder tells you. lerobot-record keeps a cadence timer and prints a per-episode line plus a full block at the end, broken down by loop-body section. The section named observe covers all of get_observation(), the Feetech state read plus every camera peek, so its mean and worst milliseconds bound what the cameras cost. The block also reports effective cadence, ticks over the work budget, and pacing headroom, where near zero means the loop is saturated. If it cannot hold the rate at all, you get this, verbatim:
Control loop is running slower (22.4 Hz) than the target FPS (30 Hz). Dataset frames
might be dropped and robot control might be unstable. Common causes are:
1) Camera FPS not keeping up 2) Policy inference (action or text) taking too long
3) CPU starvationFixes, in the order that pays off
| Fix | What it changes | What it costs |
|---|---|---|
| Drop both cameras to 640x480 | Less to move across the bus and less to decode, so both read threads keep their phase | Less detail; check the task is still doable from the images |
| Ask for MJPEG instead of raw YUYV | Compressed frames need a fraction of the bus | A CPU decode; and if the device refuses the four-character fourcc, LeRobot logs a warning and continues with the default format |
| Split the cameras across USB controllers | Removes the shared periodic bandwidth entirely | You need to know which ports map to which controller |
| Lower the loop rate to what the slowest camera holds | A 20 Hz loop that never overruns beats a 30 Hz loop that overruns a third of its ticks | A chunk of N actions covers more wall-clock time |
| Log frame age while you record | Turns an invisible property into a number you can compare between sessions | Nothing upstream stores it; you write and run it yourself |
| Cameras with a hardware sync input | One trigger fixes the exposure instant for both sensors | It never reaches the dataset: LeRobot's RealSense path also peeks with read_latest(), stamps a host perf_counter after post-processing, and writes the row on the nominal grid |
| Freeze the rig after the sync check | The skew you trained on is the skew you deploy with | Any change of camera, cable, port, resolution or format voids the measurement |
The last row is the one people skip. Placement and timing are separate properties of the same rig, and both are baked into the dataset. More on placement in the SO-100 data collection guide and in the guide to collecting high-quality VLA training data.
Two routes to a synchronised two-camera dataset
You own the whole chain: arm, cameras, USB topology, loop rate. Nothing hides from you, and nothing is done for you.
# install and check the rig
pip install 'lerobot[core_scripts]'
lerobot-find-cameras opencv
# measure the spread with the age logger from the previous section,
# then record with both cameras declared in one map
lerobot-record \
--robot.type=so100_follower \
--robot.port=/dev/ttyACM0 \
--robot.id=my_follower \
--robot.cameras='{front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}, wrist: {type: opencv, index_or_path: 2, width: 640, height: 480, fps: 30}}' \
--teleop.type=so100_leader \
--teleop.port=/dev/ttyACM1 \
--teleop.id=my_leader \
--dataset.repo_id=${HF_USER}/two-cam-task \
--dataset.num_episodes=50 \
--dataset.episode_time_s=30 \
--dataset.single_task="Pick up the cube and drop it in the bin"- You choose resolution, format and controller assignment, where most of the skew is decided
- You can log per-frame age around the loop, since the camera objects expose latest_timestamp
- Nobody warns you when the rig changes; re-running the sync check is your discipline
The desktop client records LeRobot-format datasets, episodes, camera streams and joint states, straight from a teleop session. The dataset directory lists public datasets, and training picks a model and dataset in a form, rents a GPU by required VRAM and writes checkpoints to object storage. See the training docs.
What that changes for synchronisation is narrow: the client records through the same LeRobot path, so the peek semantics and the nominal grid are identical. What you gain is one rig serving both recording and rollout, and the same operations reachable from the CLI and the MCP server when you want the sync check scripted rather than clicked.
It removes the parts that are not about timing: GPU provisioning, checkpointing, format conversion, and an inference pod that serves the policy without you building a server. It does not synchronise your cameras, and no cloud service can. That happens on your desk, in the ten minutes described above.

What the careful rigs do
| Project | Camera setup | How timing is handled |
|---|---|---|
| UMI (2024) | Hand-held gripper with a wrist camera; bimanual variants add a second | Each stream's latency measured, then everything aligned to the highest-latency stream by interpolating onto image capture timestamps; two cameras nearest-neighbour matched, off by at most 1/60 s |
| RH20T | 8 to 10 global RGBD cameras and 2 microphones per platform, RGB at 10 Hz | Each camera folder ships a timestamps.npy with the timestamp for every image; the project states all recorded data are synchronised in the temporal domain |
| DROID | Two adjustable Zed 2 stereo cameras and a wrist-mounted Zed Mini, identical across 13 institutions | Stereo units rather than independent webcams, with the hardware setup published so the rig can be reproduced |
| lerobot-record | Any number of OpenCV or RealSense cameras declared in --robot.cameras | Each camera runs its own read thread; the loop peeks the freshest frame and stamps the row on a nominal 1/fps grid |
The pattern is consistent: serious rigs either capture a real timestamp per image and align afterwards, or use hardware that fixes the instant for several sensors at once. The low-cost route does neither, a defensible trade for a 110 to 150 EUR arm as long as you know it and keep the rig stable. More in the DROID dataset write-up and the complete SO-100 guide.
Where this does not help, including on this platform
- No amount of training fixes a skewed dataset. A loss curve that falls while the arm does nothing useful is a data problem, and that page exists because it is common.
- AY-Robots adds no hardware synchronisation. The recording path is LeRobot's, with the same peek and the same synthetic timestamps.
- A public dataset carries the same blind spot as yours. Treat downloaded timing as unknown, not as fine.
- Cloud inference makes deployment worse. The control loop is 20 to 485 ms per action step depending on the model, and public-internet round trips on top turn a working policy into a hesitant one. Remote inference suits slow pick-and-place, not fast reactive motion.
- If the policy works on your desk and fails on another, timing is one candidate; the setup-specific page lists the rest.
The honest summary: a two-camera SO-100 rig has a frame-scale timing uncertainty that neither LeRobot nor this platform measures for you. Bound it once, keep the rig frozen, and stop treating inference latency and recording latency as separate topics. They are the same budget, spent at different ends of the pipeline.

Does LeRobot store a real capture timestamp for each camera?▾
Not in the dataset. The parquet timestamp column is frame_index / fps, computed when the frame is added. A real capture time exists at runtime as latest_timestamp on each camera object, taken with time.perf_counter() after post-processing, but nothing writes it into the dataset. Log it yourself if you want it.
How much skew between two cameras is acceptable?▾
There is no published threshold for an SO-100 class rig, and anyone quoting one is guessing. The useful target is a spread under one frame interval that stays constant. A stable two-frame offset is something a policy can learn around; an offset swinging between zero and two frames is noise on the input for the same action label.
Will the dataset loader warn me if my cameras are out of sync?▾
No. The decoder raises FrameTimestampError when the closest decodable frame is further than tolerance_s (LeRobotDataset defaults it to 1e-4 s) from the request, and the message mentions synchronisation. But query and presentation timestamps come from the same nominal grid, so it finds corrupt files, not a wrist camera that genuinely lags.
Do I have to re-record if I change a camera?▾
For placement, yes, because the viewpoint is part of what the policy learned. For timing the answer is the same, for a subtler reason: changing resolution, pixel format, USB port or hub changes how the streams share bandwidth, which changes the skew. Re-run the sync check and compare.
Does action chunking make the timing problem go away?▾
It addresses a different half. One forward pass produces many future actions, so inference need not finish inside every control tick, which is why it matters for models in the 152 to 485 ms range. It does nothing about misaligned training data, and it adds a seam between chunks, which real-time chunking was designed to smooth.
Can I repair a constant skew after recording?▾
In principle, by shifting one camera's video by k frames and dropping the orphaned frames. There is no flag for it in lerobot-record, so it means re-encoding and rewriting metadata yourself, and you then have to reproduce the same shift at inference or you have merely moved the mismatch. Measuring first is cheaper.
Timing defects look like a bad policy, not like an error
A wrist view two frames behind the action reads as hesitation at contact. Nothing logs it. The failure-mode pages map what the arm does back to what the data did.
Open the failure-mode pagesSources
- lerobot-record: the recording control loop (record_loop)
- SO follower get_observation(): state first, then read_latest() per camera
- OpenCVCamera: background read thread, latest_timestamp, read_latest and the fourcc fallback warning
- DatasetWriter.add_frame(): timestamp = frame_index / fps
- Video decoding and the tolerance_s check that raises FrameTimestampError
- LeRobotDataset: the tolerance_s default of 1e-4 s
- CycleTimer: loop pacing, the slow-loop warning and the cadence summary
- lerobot-rollout: --inference.type=sync and --inference.type=rtc
- LeRobot cameras: frame access modes, lerobot-find-cameras, read_latest during recording
- LeRobot: imitation learning on real-world robots, recording parameters and defaults
- Linux UVC driver: UVC_QUIRK_FIX_BANDWIDTH and the other device quirks
- Universal Manipulation Interface: inference-time latency matching (Chi et al., 2024)
- Real-Time Execution of Action Chunking Flow Policies (Black, Galliker, Levine, 2025)
- RH20T: multi-camera robotic dataset with per-image timestamps
- DROID: a large-scale in-the-wild robot manipulation dataset
Ready for high-quality robotics data?
AY-Robots connects your robots to skilled operators worldwide.
Get Started