rhoyn

Low latency eyes

github.com/rhoyn/teleop-camera-latency-analysis

A note before we start. This article is about measuring latency, not about solving it. Everything below is the instrument — the rig, the method, and the numbers that came out of them. The repository linked above holds the raw recordings of every experiment behind those numbers. How we actually got rhoyn’s camera link down to where it is now is a separate story, and it will be the subject of another article.

To illustrate the importance of low latency in teleoperation, we prepared an interactive simulation:

You buy your first humanoid — a Unitree G1. You unpack it, hang it on the crane, and notice the head camera is unplugged. Turns out that’s normal: most humanoids today don’t use vision at all, and balance on IMU data alone — the inertial measurement unit, the accelerometers and gyroscopes that sense tilt and acceleration. An inner ear, not eyes.

Close crop of the humanoid robot's head: the visor, the ring of light around it, and the sensor cluster above the eyeline

VLA models — vision-language-action: camera frames and an instruction in plain words go in, robot actions come out — are emerging but aren’t plug-and-play yet. Where a camera is fitted, it’s bolted on with no integration — like taping a RealSense to your own head and hoping your brain figures it out. Let’s plug it in and see what the robot sees.

The I have no idea what I am doing meme: a dog in safety glasses sitting at a lab bench among flasks and stands

A search turns up GR00T-WholeBodyControl and XRoboToolkit for PICO VR: install XRoboToolkit on a PICO headset and it streams the robot’s camera to you, while sending your controller and body pose back to GR00T-WholeBodyControl to drive the robot’s joints.

A PICO 4 Ultra Enterprise headset seen from the side, lying with its two controllers either side of it

First, just the camera feed. XRoboToolkit-Orin-Video-Sender goes on the G1’s Orin dev board — the one you’re allowed to SSH into — wired to the camera over the robot’s back panel. But the sender and the PICO client only support one camera natively, and it isn’t the D435 already on the robot’s head. It’s the ZED Mini.

The XRoboToolkit client UI: the Remote Vision panel with its State dropdown open, offering PICO4U and ZEDMINI and nothing else

No way to make the D435 work with the stock app, so you order a ZED Mini and tape it to the robot’s head.

A ZED Mini stereo camera: a slim bar with a lens at each end and the ZED-M label between them

Plug it in, start the sender, and the headset now shows what the robot sees instead of the room around you.

But move a hand in view of the robot’s camera and the image lags behind — you’re looking at where your hand was, not where it is. Fine for waving at the robot from across the room. Not fine for precise manipulation, where you need to react to now, not to a moment ago.

You can feel the lag. You can’t yet measure it.

Quote card: If you can't measure it, you can't manage it — attributed to Peter Drucker, beside his photograph

First idea: put a millisecond timestamp on a monitor, point both the robot’s camera and the headset’s passthrough at it, record the headset, and diff the two readings with a script.

Here is one camera frame of that panel, 25 ms exposure. The refresh is effectively instant — only the camera’s shutter is slow:

One camera frame of the panel: the leading digits of a nineteen-digit nanosecond timestamp are sharp, and the last eight are a stack of every digit the panel showed during the exposure, blurred into an unreadable band

Why that band comes out blurred at all: a camera doesn’t capture an instant, it opens the shutter, exposes the sensor to photons for the whole window, and records however those photons landed. This one is a mechanical shutter rather than the digital sensor above — the mechanism differs, the exposure doesn’t need it spelled out here — but the idea is the same one driving every frame in this piece:

The leading digits are sharp because they didn’t change while the shutter was open. The rest cycled through all ten values many times over, and the camera sums whatever light arrived during that window instead of picking one instant — so every value lands on top of every other as one grey smear. The monitor doesn’t help either: it only redraws sixty times a second, so most milliseconds never reach the glass at all.

Exposure and refresh rate: two separate ways to lose the same number.

Next idea: skip the screen. Ten LEDs in a row, driven by an ESP32 at 1 kHz, encoding the millisecond count in binary — 210 states covers a full second at 1 ms resolution, read off both camera streams and subtracted.

The display isn’t the bottleneck this time — it updates every millisecond, exactly as built. The camera is: a 20–30 ms exposure is ordinary indoors, and any bit that flips faster than that averages to grey.

One frame of the diodes, same 25 ms exposure:

One camera frame of the ten LEDs: the top two bits read cleanly lit and the next two cleanly dark, while the six below them all sit at a middle red that cannot be called on or off

The top bits read cleanly — they don’t change during a single exposure. The bottom bits, the ones carrying the millisecond precision we actually wanted, sit frozen at half brightness: unreadable. The lowest bit flips fastest, so it washes out first, and the effect climbs the display until it reaches a bit slow enough to survive the shutter.

Give up, then? Live with the lag?

Animated meme: a monkey sitting at a laptop, typing and then stopping, captioned I GIVE UP

At rhoyn we measure things instead. Both attempts failed the same way: we kept trying to read a number off a display, and the exposure kept averaging it away. So stop reading numbers — track something that moves continuously, and let that same averaging work for us instead of against us.

First, a look at what we’re actually measuring:

Plan view of the room from above. The marker board with its LED sits on the left. Two rays leave it at once: ray one to the robot camera, then through the dev board and router and over wi-fi to the headset; ray two straight into the headset cameras. Both end at the person wearing the headset.

A single LED flashes once. Some of its light reaches the robot’s camera, gets encoded, sent over wi-fi, decoded, and painted on the headset display. The rest, from the same flash, travels straight into the headset’s own passthrough cameras and lands on that same display — same departure, same destination, two routes. Whatever makes the first route longer than the second is the lag we’re after, and because both rays land in the same recorded frame, we never need to synchronise a clock.

Diagram of two paths to the eye. The top path runs camera, dev board, router, headset, display, labelled exposure, encode, transport, decode and scan-out. The shorter bottom path runs headset cameras, compositor, display. Both converge on the eye.

We don’t time each box in that chain individually — you can’t probe the encoder without changing what you’re timing. We time the whole path at once, from the outside, against the only reference the operator actually has: their own eyes.

The rig itself is deliberately dumb: one A0 ArUco board with an LED spinning on an arm around its centre, placed where both the robot and the headset-wearer can see it. A single headset recording then holds the same LED at least twice over — once via passthrough, once via the robot’s camera stream (three times if it’s stereo).

The big board above is the room via passthrough; the two small ones are the same board arriving through the robot’s stereo cameras. One LED, three views, one frame — and because it’s the same physical LED, everything about the views is identical except the transport between them. No clock to synchronise: the arm is the only clock in the experiment.

Here is the rig running:

For each frame the program fits the board’s pose from its ArUco markers, projects the LED into board coordinates, and records its angle. Because it’s one physical LED, every view traces the same angular speed over time — the fitted lines differ only in phase, and dividing that phase gap by the shared speed turns an angle into a time.

That clip is four panels, each one a step:

Panel one of the measurement clip: the headset passthrough view of the marker board on a wooden floor under a warm lamp, with the two robot camera views of the same board in a strip beneath it

One: the recording, untouched. The large view is the headset’s own passthrough; the two strips below are the robot’s left and right cameras, streamed back and composited into the same display. One board, one LED, three views, no clock anywhere.

Panel two: the same frame with the fit drawn on - marker outlines in green, the board edge in blue, the board's own compass axes, the LED orbit as a yellow circle and a white line from the centre to the LED's current position, shown for the passthrough view and both robot views

Two: the board’s pose, in every view. Green marks each ArUco marker found — 24 of them, in all three views; blue is the board edge reprojected from that fit, so you can see at a glance whether it’s right. The yellow circle is the LED’s orbit, the white line its current position, and everything outside the board is masked away. This is where exposure stops being the enemy: a long exposure just smears the LED into a short arc, and the middle of that arc is still a well-defined angle.

Panel three: the ring cut from the board around the LED orbit, laid on a compass rose, with the orbit in magenta and the tracked LED circled in red for the passthrough view and in green and blue for the two robot cameras

Three: the angle. The LED only ever lives on one circle, so the program crops to that ring alone. Magenta marks the orbit; the coloured circle is where each view puts the LED this frame — red for passthrough, green and blue for the robot’s two cameras. The output isn’t a pixel, it’s an angle in degrees, and a long exposure only makes that angle more precise. Frame rate stops mattering too: fitting a line across the whole clip, instead of comparing frame to frame, averages out per-frame noise and resolves well below the recording’s own frame interval — our numbers come from 30 fps video and land within a fraction of a millisecond.

A plot of unwrapped angle against time. Two parallel lines of identical slope, one solid for passthrough and one dashed for the robot camera, separated by a horizontal gap marked gap.

That’s the whole measurement, plotted: two parallel lines of identical slope — one LED, one speed — and the horizontal gap between them is the answer, voted on by every frame in the clip. The arm’s speed doesn’t need to be known or held constant; it’s measured fresh from each clip. Nothing here is calibrated, trusted, or timestamped.

What comes out is one signed number in milliseconds (camera minus passthrough), plus this four-panel video showing exactly how it was built:

Panel four: a table of the per-view fits with marker counts, LED position, revolutions and revolutions per second, R squared and RMSE, the arithmetic written out in words underneath, and the result, plus 25.7 milliseconds

Four: the arithmetic, written out. One row per view — markers found, LED position, revolutions, fitted speed in rev/s, and the R²/RMSE that say whether a straight line was the right fit — then the result in words. Here all three views turn at 1.838, 1.836 and 1.836 rev/s, and the robot’s two cameras sit 0.0472 rev behind passthrough: +25.7 ms, camera minus passthrough. This clip is the XRoboToolkit + ZED Mini stack the article started with, caught in the act.

Results

Eight recordings on each stack, same rig, same program, signed as camera minus passthrough — positive means the robot’s camera view is that far behind what the headset already shows you live:

#rhoyn + stock D435XRoboToolkit + ZED Mini
0−4.3 ms+25.7 ms
1−2.4 ms+31.7 ms
2+6.9 ms+40.5 ms
3−2.7 ms+39.7 ms
4+5.5 ms+41.2 ms
5−1.8 ms+28.7 ms
6−3.8 ms+35.0 ms
7+3.1 ms+35.6 ms
median−2.1 ms+35.3 ms
mean+0.1 ms+34.8 ms

The two sets never overlap: rhoyn runs −4.3 to +6.9 ms, straddling zero — no measurable difference from the headset’s own passthrough. XRoboToolkit + ZED Mini runs +25.7 to +41.2 ms, every clip behind every rhoyn clip. Pairing all 64 combinations and taking the median gap: 34.7 ms, measured by the same instrument on the same board.

Here is that first row, run 0, for rhoyn: −4.3 ms, camera minus passthrough — run through the same four panels above.

Everything is open sourced — the measurement program, the printable board, the spinning arm’s OpenSCAD source and STLs, and its bill of materials. MIT licensed, at github.com/rhoyn/teleop-camera-latency-analysis.

Play with it

Everything above is in one instrument here, nothing switched off. R/F moves the camera latency, T/G the control latency, arrow keys move both. Y cycles what’s on the floor — nothing, the monitor, the LED word, the marker board — and H toggles the robot’s camera between stereo and single view. Push the camera to 200 ms and try to place a hand exactly where you meant to.

Let’s talk!

hello@rhoyn.com