Azure Kinects calibration
HOST.onstartup.toe
Host: Intel NUC, TouchDesigner
Layer: sensing (back end)
In: 3 × Azure Kinect
Out: fused skeletons (OSC), camera images (NDI)
The Kinect host is the computer that the three Azure Kinect sensors are connected to. It runs a TouchDesigner project that reads each sensor, calibrates its data into the room's coordinate frame, decides which bodies seen by which sensors belong to the same person, fuses those views into one skeleton per person, and publishes the result to the lab network. Users never open it. The Azure Kinect sensors and Azure Kinect cameras components on the workstation are its clients. Keeping sensing on its own machine means the processing for depth, body tracking and video encoding does not compete with rendering on the main workstation. It also means every project in the lab receives the same calibrated data.
Role in the setup
The host turns three independent sensors into one room-wide tracking system. It does three things:
- Calibration: each sensor's data is moved from the sensor's own viewpoint into the shared room frame (metres; X = width, Y = height, Z = depth; Z = 0 at the centre wall).
- Decision making and fusion: every frame, it decides which detections are the same person, which person they are, and which sensor to trust for each joint. Each person appears once, with a stable number (
p1…p8). - Distribution: every joint of every person goes out over OSC, together with presence flags and a heartbeat. Camera images go out as NDI video streams.
The host deliberately makes no choice on behalf of the user. It always sends everything. Which person and which joints a project uses is decided on the client side (see Azure Kinect sensors), so several projects or users can use the same stream at the same time.
Technical specification
Signal chain
Kinect left ─► Kinect Azure CHOP ─► transformxyz_Y2 (pose left) ─┐
Kinect right ─► Kinect Azure CHOP ─► transformxyz_Y19 (pose right) ─┼─► fusion (Script CHOP) ─► OSC Out ─► lab network
Kinect rear ─► Kinect Azure CHOP ─► transformxyz_Y13 (pose rear) ─┘
There is no selection, renaming or smoothing between these nodes. All of the decision making happens in one place: the Script CHOP fusion inside Sensor_Azure_Multiple_HOST, with its code in the DAT fusion_callbacks. Smoothing is done on the client, not here, so the host adds no delay.
Sensors
Each Kinect Azure CHOP runs Microsoft's body tracking for its own sensor. Relevant settings:
- World Space Positions on. Joint positions in metres, relative to that sensor.
- Bone Confidence on. Adds a
:confidencechannel per joint (see Fusion per joint). - Max Players. The number of bodies one sensor tracks. Each extra body costs GPU time on the NUC.
Per tracked body, a sensor outputs p{k}/id, and for each of its 32 joints p{k}/{joint}:tx/ty/tz/confidence. The k here is the sensor's own body number. It means nothing outside that sensor (see Decision making).
Calibration
The sensors hang on drop rods below the pipe grid, 3.03 m above the floor. All three face the centre wall and tilt down by about 45°.
| Sensor | X | Y | Z | Rotate (Rx, Ry, Rz) |
|---|---|---|---|---|
| Left | −1.25 | 3.03 | 4.40 | 45°, 180°, 0° |
| Right | 1.25 | 3.03 | 4.40 | 43.5°, 180°, 0° |
| Rear centre | 0 | 3.03 | 6.31 | 45.87°, 180°, 0° |
Each pose is stored in a Transform XYZ CHOP per sensor (transform order Scale Rotate Translate, rotate order Rx Ry Rz). The Transform CHOP turns that sensor's skeleton data from sensor coordinates into room coordinates. Calibration therefore comes down to these six values per sensor. If a sensor is moved, only its own Transform CHOP needs updating. The poses are also shown in the Lab model.
Calibration is what makes the decision making possible: only after this step can a position measured by one sensor be compared with a position measured by another. Any remaining calibration error shows up as the same person being placed in slightly different spots by different sensors. In a test walk (25 September 2026) that difference was 0.1–0.5 m, with the largest differences always involving the left sensor (about 0.3 m in X and 0.1 m in Y).
Decision making
The problem
Microsoft's body tracking runs separately on each sensor, and each sensor numbers the bodies it sees on its own. "Body 1" on the left sensor and "body 1" on the right sensor are unrelated labels. The same person usually has a different number on each sensor, and that number changes when they leave a sensor's view and come back.
An earlier version of the host worked around this by picking one sensor's data per moment ("switch phases" based on margins). That broke as soon as more than one person was in the room, or someone walked from one sensor's field of view into another's.
The host therefore ignores the sensors' own body numbers and decides identities itself, based only on where bodies are in the room. Every frame, fusion answers four questions:
- Which detections are real?
- Which detections belong to a person who is already known?
- Which of the remaining detections are new people, and are they real?
- For each joint of each person, which sensors should be trusted?
The answers are kept between frames as a list of tracks. A track is one person: their number (or none yet), their last fused pose, how many frames in a row they were seen, and how many frames they have been missed.
Step 1: valid detections
From each of the three inputs, every body slot is read. A body counts as a detection only if:
- its
idis greater than 0 (the Kinect reports 0 for an empty slot), and - it has a
pelvisjoint.
Positions can't be used to recognise an empty slot, because the calibration transform turns the sensor's zeros into the sensor's own position in the room.
The pelvis is used as the reference point for all comparisons in steps 2 and 3. It is the root of the skeleton, the most stable joint, and the one the Kinect rates with the highest confidence.
Step 2: known people first (track-first matching)
Every detection is first compared with every existing track, using the distance between the detection's pelvis and the track's pelvis from the previous frame. All pairs are sorted from closest to farthest and handed out in that order (a greedy nearest-neighbour assignment):
- A pair is accepted if the distance is under MATCH_DIST = 0.75 m. That is far more than anyone moves in one frame, and less than the usual distance between two people.
- One body per sensor per track. A sensor can't contribute two bodies to the same person.
- One track per body. Once a detection is used, it's gone.
- On equal distance, a confirmed person wins over a candidate that has no number yet.
Then a second pass catches sensors that disagree more because of calibration error: a detection that is still unused may join a confirmed person within MERGE_DIST = 1.20 m, as long as that person has no body from that sensor yet. Without this pass, a badly calibrated sensor would keep creating a second, ghost person next to the real one.
Why track-first. The first version did it the other way around: it grouped the sensors' bodies into people first, and compared those groups with the previous frame afterwards. That caused numbers to jump (p1 → p2 → p3) every time a sensor picked someone up or lost them. When a sensor joined, the combined position shifted, or a sensor that briefly disagreed formed its own group and "stole" the number. In the current order, a sensor joining or leaving only adds a body to an existing person or takes one away. It can never create a new number for someone who is already tracked.
Step 3: new people, with confirmation
Only detections that matched no track can start a new person. They are grouped first: a detection joins the nearest group whose average pelvis is within MERGE_DIST, provided that group has no body from that sensor yet. Each group becomes a candidate: a track without a number.
A candidate gets a number only after it has been seen in CONFIRM_FRAMES = 5 frames. A candidate missed for more than TENTATIVE_DROP = 2 frames is discarded. This filters out one-frame glitches, such as a coat on a chair briefly recognised as a body, so they never take a number or reach the clients.
Which number. A confirmed candidate gets the lowest free number from 1 to MAX_SLOTS = 8. The first person in an empty room is always p1. That way a client set to Person 1 always gets someone as long as anyone is in the room. When a person leaves, the others keep their numbers: nobody is renumbered, and the free number goes to the next person who enters.
Step 4: keeping and releasing people
A confirmed person who isn't matched in a frame keeps their last fused pose and their number. They are released only after DROP_FRAMES = 15 frames in a row without a match (about 0.5 s at the host's 30 fps). A short occlusion, such as walking behind another person, therefore doesn't cost anyone their number.
Step 5: fusion per joint
For each person, every joint is fused from the bodies attached to that person in this frame. The Kinect rates every joint: 0 = none (out of range), 1 = low (occluded, predicted), 2 = medium. The decision per joint:
- Find the best confidence level any of the sensors reports for this joint.
- Use only the sensors at that level, and ignore the rest.
- Take their weighted average (weights: level 2 = 1.0, level 1 = 0.3).
If every sensor rates a joint 0, it is still averaged across all sensors rather than frozen. This matters for hands: the Kinect very often rates hand, hand tip and thumb as 0, even when the hand is clearly visible. An earlier version kept the last known position for such joints, and hands froze in place (in one test 2.5 m behind the body).
Output
Only confirmed people are sent, in number order:
| Channel | Content |
|---|---|
p{N}/{joint}:tx, :ty, :tz | every joint of every confirmed person, metres, room frame |
p1/present … p8/present | 1 while that number belongs to someone, otherwise 0. Always sent, also as 0. |
persons | how many people are seen in this frame |
heartbeat | a counter that changes every frame while the host runs |
The presence flags and the heartbeat exist because of how OSC behaves on the receiving side: a channel that stops arriving keeps showing its last value forever. Without them, a client can't tell a person who left from one standing still, or a crashed host from a quiet room.
Timing at a glance
| Event | Takes |
|---|---|
| Someone enters the empty room | about 5 frames (0.17 s) until p1 appears |
| A second sensor picks up a tracked person | immediately, same number |
| A person is briefly hidden | keeps number and last pose for up to 15 frames (0.5 s) |
| A person leaves | number released after 0.5 s, present goes to 0 |
| A one-frame false detection | discarded, never gets a number |
Worked example
Two people, A and B, in an empty room:
- A walks in on the left. The left sensor sees one body. No tracks exist, so it becomes a candidate. After 5 frames it becomes
p1. - A walks to the middle. The rear sensor now also sees A, 0.3 m from where the left sensor places A. In step 2 that body is within 0.75 m of
p1, so it simply joinsp1. A's pelvis is now fused from two sensors. - B enters on the right, 3 m from A. The right sensor's body is far from
p1(no match), so it becomes a candidate and, 5 frames later,p2. - A leaves. After 15 frames without a match,
p1is released andp1/presentgoes to 0. B staysp2. - C enters. C gets the lowest free number:
p1.
Tuning and diagnosis
All constants are at the top of fusion_callbacks:
| Constant | Default | Raise it when… | Lower it when… |
|---|---|---|---|
| MATCH_DIST | 0.75 m | fast movement makes people get new numbers | two people close together swap numbers |
| MERGE_DIST | 1.20 m | one person shows up twice | two people standing close are merged; possible once the spread stays under 0.25 m (then about 0.6 m) |
| CONFIRM_FRAMES | 5 | glitches still take numbers | new people appear too late |
| DROP_FRAMES | 15 | people lose their number behind others | people who left linger too long |
| MAX_SLOTS | 8 | — | — |
With DEBUG = True, the Textport prints once per second how many bodies each sensor sees and every track as (number, frames missed), where 0 means a candidate without a number. For every person seen by more than one sensor, it also prints where each sensor puts their pelvis and the spread between them: a direct measurement of the calibration. Under about 0.3 m is good. Regularly above 0.5 m means a Transform needs re-measuring; the line shows which sensor differs.
Why this design
- Identities come from positions in the room, not from any single sensor. That makes the method independent of the hardware: the same code works with Azure Kinects or with a successor such as an Orbbec sensor (see Decision making: sensor succession).
- Three overlapping views mean fewer dropouts when one sensor's view is blocked, and confidence-based fusion takes each joint from the sensors that see it best.
- Track-first matching keeps numbers stable while people walk between sensor views.
- The host never filters for a user. Everything is sent; clients choose. One stream serves any number of projects.
Network interface
All traffic stays on the lab's local network. Commands from the workstation are sent as broadcasts, so the components don't need to know the host's IP address.
| Direction | Protocol / port | Content |
|---|---|---|
| host → workstation | OSC, UDP 1030 | every joint of every person (p{N}/{joint}:tx/ty/tz), p{N}/present, persons, heartbeat |
| host → workstation | OSC, UDP 1031 | tracking frame rate |
| host → workstation | NDI | one video stream per sensor, named after the host and the sensor: <host> (AK1), (AK2), (AK3) |
| workstation → host | OSC broadcast, UDP 1051 | Configure (re-initialises the sensors), sent from Azure Kinect sensors |
| workstation → host | OSC broadcast, UDP 27 | Image, the stream type chosen in Azure Kinect cameras |
What the client does with this stream (choosing a person and joints, detecting stale data, smoothing, sharing one receiver between copies) is described in Azure Kinect sensors.
Decision making: sensor succession
The Azure Kinect DK went out of production in 2023, and an Orbbec sensor is on the lab's purchase planning for 2026. The options discussed at the workshop meeting of 24 September 2026:
| Option | Assessment |
|---|---|
| Orbbec Femto Bolt (×3) with a sync hub | The lower-risk choice for skeleton tracking. It works the way the Azure Kinect does, so the host and its fusion stay as they are. |
| Active USB-C cables (e.g. Newnex FireNEX-uLINK-C2) | Carry USB 3.2 Gen 1 data and bus power up to 30 m, which is enough for the ceiling-grid mounting. Some users report the host not recognising a camera until it is restarted or moved to another port, so the exact cables should be tested before buying three. |
| PCIe USB card with one controller per port | Separate USB ports on a PC can share one host controller inside, which limits how many cameras run at once. A card with a controller per port has been verified with four Femto Bolts. |
| Orbbec Femto Mega (Ethernet) | Only worth considering after testing Ethernet body tracking in TouchDesigner with one borrowed unit. |
Working recommendation: three Femto Bolts, a sync hub, three tested active USB-C cables and a PCIe USB card with a separate controller per port, on a Windows host. Because the fusion works on room coordinates, swapping the sensors changes nothing for the clients or for projects built on them.
Limitations and open questions
- Calibration is static. The poses are fixed values in the Transform CHOPs. If a sensor is bumped or moved, its pose has to be re-measured, or the fused skeletons drift apart. The left sensor currently differs most from the other two (see Calibration).
- Left/right swaps. Body tracking assumes the person faces the sensor. Seen from behind, or when someone pulls up their shoulders so the neck "disappears", a sensor can return a mirrored skeleton with left and right exchanged. A correction based on the shoulder axis and face-joint confidence was tried and reverted in September 2026 because it didn't improve real results.
- Skin, hair and clothing. The depth camera measures reflected infrared light. Dark hair, long hair (which also hides neck and shoulders), darker skin and dark clothing return less of it, so skeletons are less reliable for those people. This is a limitation of the sensor type and needs testing with sensor placement and settings.
- Close contact. People closer together than the merge distance can be merged into one person. When they separate, one of them gets a new number.
- Numbers are tracks, not individuals. Someone who leaves and comes back may get a different number. Content should not assume that a particular visitor keeps a particular number for a whole session.
- Hands. Hand joints are rated uncertain by the Kinect most of the time, so they are noisier than torso and head. Wrists are a steadier alternative.
Relations
- → Azure Kinect sensors (OSC skeletons)
- → Azure Kinect cameras (NDI images)
- ↔ Lab model (sensor poses)
- README: HOST.onstartup-README
- Overview: Lab Tools
- Hardware: Inventory (sensors, host computer and network)