Skip to main content

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:

SettingOrder on the wire
XYZWx, y, z, w
WXYZw, 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:

ChSegmentChSegment
0pelvis9left forearm
1torso10left hand
2head11right upper leg
3right shoulder12right lower leg
4right upper arm13right foot
5right forearm14left upper leg
6right hand15left lower leg
7left shoulder16left foot
8left 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.