Wire Format
This is the interface. Anything that produces packets in this shape can drive body tracking in LABO — the language and the platform are up to you.
A packet
One packet is one sample: a single instant, for every joint at once.
time, j0a, j0b, j0c, j0d, j1a, j1b, j1c, j1d, ...
time— seconds, as a float. Present when First value is time is on.- Then four values per channel — one rotation, as a unit quaternion.
There are no positions. Rotations are all LABO needs to pose a skeleton; limb lengths come from the avatar and the Bone length setting.
With the default 17-segment channel map and a time value, a packet carries 69 values.
The two packet formats
CsvText — comma-separated ASCII, one sample per packet. Lines that fail to parse are skipped, so a sender can replay a CSV file verbatim including its header row. If a packet carries several lines, the newest parseable one is used.
12.480,0.0123,-0.7419,0.3560,0.5588,0.2668,...
BinaryFloat32 — the same values as raw little-endian 32-bit floats, no framing, no delimiters. Lower overhead. Use it when you control both ends and are sending at a high rate.
Start with CsvText. It is readable in a packet capture, which makes the difference between debugging a format problem in a minute and debugging it in an afternoon.
Quaternion order
Each group of four values is a rotation, in the order set by Quaternion order:
| Setting | Order on the wire |
|---|---|
XYZW | x, y, z, w |
WXYZ | w, x, y, z |
Both conventions are common and a packet gives no clue which it holds. Send whichever your source produces and set this to match, rather than reordering in your sender — fewer places to get it wrong.
The channel map
Quaternion channels (stream order) decides which joint each group of four drives. Channel 0 is the first group after time, channel 1 the next, and so on.
The default is the full-body 17-segment layout, in this order:
| Ch | Segment | Ch | Segment |
|---|---|---|---|
| 0 | pelvis | 9 | left forearm |
| 1 | torso | 10 | left hand |
| 2 | head | 11 | right upper leg |
| 3 | right shoulder | 12 | right lower leg |
| 4 | right upper arm | 13 | right foot |
| 5 | right forearm | 14 | left upper leg |
| 6 | right hand | 15 | left lower leg |
| 7 | left shoulder | 16 | left foot |
| 8 | left upper arm |
Sending fewer sensors
Most setups do not instrument all 17 segments. Send all 17 slots anyway, and write a zero quaternion — 0,0,0,0 — into every slot you have no sensor for.
A zero quaternion is not a valid rotation, so LABO treats it as "no sensor here" and leaves that joint untracked. Everything else lands normally.
This is worth doing rather than trimming the channel map, for one reason: the segments you do have land on the joints they belong to. A trunk-and-right-arm rig that sends its six values in the first six slots drives the pelvis, torso, head, right shoulder, right upper arm and right forearm — head and shoulder are wrong, and everything after them is shifted. Zero-filling puts the forearm on the forearm.
It also means one channel map is correct for every rig you might use, so a study that adds leg sensors later changes nothing in Unity.
Rate
Send at whatever rate your source produces. LABO uses the most recent packet each frame, so sending faster than the frame rate costs bandwidth and gains nothing; sending much slower shows up as visibly stepped motion. Between 60 and 200 Hz is a reasonable range.
Every packet must carry a complete sample. LABO cannot assemble one instant from several packets, so a sender whose sensors each keep their own clock has to resample them onto a common timeline before sending.
A minimal sender
import socket, time
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
addr = ("127.0.0.1", 9750) # match Port in the UDP row
IDENTITY = (0.0, 0.0, 0.0, 1.0) # x, y, z, w
ZERO = (0.0, 0.0, 0.0, 0.0) # "no sensor on this segment"
t0 = time.perf_counter()
while True:
t = time.perf_counter() - t0
slots = [IDENTITY] + [ZERO] * 16 # pelvis only, for now
values = [f"{t:.5f}"] + [f"{v:.6f}" for q in slots for v in q]
sock.sendto(",".join(values).encode("ascii"), addr)
time.sleep(1 / 100) # 100 Hz
Run that with the UDP row selected and the row turns green. Replace IDENTITY with real rotations and you have a working integration.
For a complete sender — reading recordings from disk, matching sensors to slots, and resampling per-sensor clocks — read stream_imu.py in the bundled sample.