A TouchDesigner component that merges the point clouds of several depth cameras into one coordinate space. It works out how the cameras sit relative to each other using Open3D, then lines the clouds up.
Version 0.4.0. Built and tested on TouchDesigner 2025.33070.
Works with:
- Kinect Azure
- Orbbec
- ZED
- any pair of TOPs you supply yourself, one point cloud and one colour. Recorded clouds, other sensors and synthetic data all work this way.
Cameras arrive as TOPs, because that is what their TouchDesigner operators give. Inside the component they become points. What comes out is a POP.
0.4.0 is a breaking change. The install is the part that changed most, so read the next section carefully.
Version numbers jumped straight from 0.0.3 to 0.4.0. There was no 0.2.0 or 0.3.0 release.
Renamed parameters:
| 0.0.3 | 0.4.0 |
|---|---|
Gather kinects |
Gather devices |
Use player for calibration |
Use mask for calibration |
Specify pair x / Specify pair y |
Specify pair, two plain integer fields |
Current pair, Number of pairs, As intermediary |
gone. Use Reference device and the calibration table |
New: Device type, Python exe, Check worker, Voxel size, Refine voxel, Max range,
Use coloured ICP, RANSAC seed, Preset matrix DAT, Rebuild chain, Reset calibration, and
the Last ... readouts.
The walkthrough video shows the 0.0.3 interface. The idea is the same. The names are not.
Open DemoTDXDepthCamMerger.toe. It fakes three depth cameras looking at one scene from different
places, coloured red, green and blue, starting misaligned.
Set Python exe as described below, pulse Gather devices, calibrate pair 1 and 2, then pair 2 and 3. Watch the clouds come together.
Put TDXDepthCamMerger.tox into your project.
This is the step people get stuck on, so here is the whole story.
Open3D must not go inside TouchDesigner. The old 0.0.3 instructions told you to copy it into
TouchDesigner/bin/Lib/site-packages, or to pip install it into TouchDesigner's own Python. On
current builds that kills TouchDesigner the moment the component loads it. Not an error you can
catch. The whole application disappears. Open3D brings its own copies of libraries TouchDesigner
has already loaded, and the two cannot share a process.
So this component never imports Open3D. It runs the maths in a separate Python, outside TouchDesigner. It writes the clouds to a temp folder, runs that Python as a normal program, and reads back a 4x4 matrix. TouchDesigner itself only ever uses numpy.
Two good things come out of that. Your TouchDesigner install stays clean, and the component does not care which Python version TouchDesigner ships.
What you need is any Python 3.11 or newer with Open3D installed, as long as it is not TouchDesigner's own. Install it once and forget it.
With conda, which is the least trouble:
conda create -n td python=3.11
conda activate td
pip install open3d numpy
With plain Python and a virtual environment:
python -m venv td-open3d
td-open3d\Scripts\pip install open3d numpy
Tested against Open3D 0.19.0. Nearby versions should be fine.
On the Setup page:
-
Set Python exe to that interpreter's
python.exe. Not a folder, notpython.bat, the actual executable. Some examples:C:/Users/you/anaconda3/envs/td/python.exe C:/Users/you/td-open3d/Scripts/python.exeTo find it, activate the environment in a terminal and run:
python -c "import sys; print(sys.executable)" -
Pulse Check worker. This runs that Python once and asks it what it has. It is quick and changes nothing.
-
Read the three fields underneath:
Field What you want Worker status OKOpen3D version a version, for example 0.19.0Python version 3.11or newer, for example3.11.13
If Worker status does not say OK, nothing else in the component will work. Fix it here
before going on. The field tells you what went wrong:
| Worker status | What it means | Fix |
|---|---|---|
FAILED: Python exe is not set... |
The parameter is empty | Set Python exe |
FAILED: Python exe does not exist: ... |
Wrong path, or the environment moved or was deleted | Check the path. Use the sys.executable trick above |
FAILED: worker failed: ModuleNotFoundError: No module named 'open3d' |
The Python runs, but has no Open3D. Usually the system Python instead of your environment | pip install open3d into that environment, or point at the right python.exe |
FAILED: worker timed out after 120s |
The Python started but never answered | Try running it in a terminal. Antivirus and network drives are the usual causes |
You only do this once per machine. The component remembers the path in your project.
If you share a project, or move it to another machine, Python exe will point somewhere that
does not exist. Set it again and pulse Check worker. The shipped .tox comes with the field
blank on purpose, so it never carries someone else's path.
The component does not make the camera operators. It reads the ones you make.
-
Put one camera TOP per physical camera in a single base COMP. Name them after the device type with the number on the end:
kinectazure1,kinectazure2, ororbbec1,orbbec2, orzed1,zed2. The names matter. EachDevice<n>looks for the TOP whose number matches. -
Pin each TOP to one camera: Sensor on the Kinect Azure TOP, Device on the Orbbec TOP, Camera on the ZED TOP. Never two TOPs on the same camera.
-
Set Inputs op to that base, then pulse Gather devices. Number of devices should match what you plugged in. If it is empty or short, wait and pulse again. An Orbbec can take 30 seconds to show up after being plugged in.
The component builds the extra select TOPs each device needs. You do not have to.
Set Depth Align Mode on the Orbbec TOP to Hardware or Software. The colour and depth
cameras sit in slightly different places, so without this the colours land on the wrong points.
The Kinect has the same switch on its select TOP and the component sets that one for you. The
Orbbec select has no such parameter, so this one is yours.
For a Femto Mega or Femto Bolt, use the Kinect Azure TOP with Hardware Type set to
Orbbec Compatible, and set Device type here to Kinect Azure. You get body tracking that
way, which gives you the player index and therefore masking. Do not mix Orbbec TOPs and Kinect
Azure operators in one project. TouchDesigner warns that this is unstable.
Not yet tested against physical Orbbec hardware. Please open an issue if it misbehaves.
Set Reference Frame on the ZED TOP to Camera, not World. In world mode the cloud moves
with the camera's own tracking, which fights the calibration you are measuring.
Use mask for calibration is greyed out for ZED. The ZED mask holds body IDs, needs body
tracking running through a ZED CHOP, and the threshold here was tuned for the Kinect player index.
Ask for it if you want it. In the meantime Custom TOPs lets you feed any mask you like.
Not yet tested against physical ZED hardware either.
-
Setup page: pick Device type, set Inputs op, pulse Gather devices.
-
Calibrate page: set Specify pair to two camera numbers. The first is the target and does not move. The second is the source and gets moved onto it. IDs shows which physical cameras those numbers mean.
-
Pulse Calibrate.
-
Repeat for each pair, chaining outward: 1 and 2, then 2 and 3, then 3 and 4. Every result is stored, and the component composes the chain so everything lands in the frame of Reference device.
-
Read the merged cloud from
out_pop.
Point each camera at its neighbour, not always at camera 1. Cameras far apart barely see the same things. Chaining through neighbours is much more accurate.
Other buttons: Refine runs ICP again on an existing calibration and never starts a fresh search. Reset calibration puts every device back to identity. Rebuild chain recomposes the transforms without re-registering, which is what you want after changing Reference device.
Refine only reaches a few centimetres. At the default Refine voxel of 0.01 it pulls a camera back about 0.08 m and no further. If one has been knocked further than that, pulse Calibrate instead. Refine will not rescue it, and it will not tell you it failed to.
You get Last status, Last fitness, Last RMSE, Last correspondences and Last overlap.
Read Last overlap first. It is how much of what the target camera sees the source camera also sees. It decides whether a pair can be calibrated at all:
- 0.55 and above: 86 to 100% of pairs came out correct in testing, to a few millimetres.
- Below 0.55: only 27%. The failures are not small errors, they are metres out.
If it is low, move the cameras so they share more of the scene. No parameter fixes this.
Last status says whether the solver settled, not whether the answer is right.
OK: it converged and the numbers look sane.WARN: marginal. Look at the cloud before trusting it.FAIL: the result is noise.
A WARN usually means the four runs disagreed. Every Calibrate runs the rough search four
times over and compares the answers. That search is random, so it only disagrees with itself when
the scene is ambiguous. When that happens you get WARN even if the fitness looks good.
Treat it as an early warning rather than a complaint about the answer in front of you. It is saying this pair is unstable, so the next press of Calibrate may well land somewhere wrong. More overlap, or something less symmetric in view, is the fix.
Setting RANSAC seed to 0 or more switches this check off. A seeded run comes out identical every time, so four of them always agree and the agreement tells you nothing.
An OK can still be wrong. Two clouds that both contain a floor can be lined up wrongly and still
score well. Always look at the merged cloud in the viewport. That is what confirms a
calibration.
Overlap is measured after the fact, so a wrong answer reports the overlap of that wrong answer.
Every pair's numbers are also kept in the calibrationData table, so you can see which link in a
chain is the weak one.
| Parameter | What it does |
|---|---|
| Device type | Kinect Azure, Orbbec, ZED or Custom TOPs. Changing it rebuilds every device's sources |
| Inputs op | The base COMP holding your camera TOPs. Every device type uses it |
| Gather devices | Find the cameras and build a Device<n> for each |
| Number of devices, Devices | Read-only. What was found |
| Python exe | The Python that runs the maths. Must have Open3D |
| Check worker | Test that Python and report back |
| Worker status | OK, or what went wrong |
| Open3D version, Python version | Read-only. What that Python reported |
| Parameter | Default | What it does |
|---|---|---|
| Reference device | 1 | Whose frame everything ends up in |
| Specify pair | 1, 2 | Target and source. The target does not move |
| IDs | Read-only. The physical cameras behind those numbers | |
| Calibrate | Run the current mode on the current pair | |
| Refine | ICP on top of the existing calibration | |
| Rebuild chain | Recompose without re-registering | |
| Reset calibration | Forget everything | |
| Last status, fitness, RMSE, correspondences | Read-only. How the last run went | |
| Last overlap | Read-only. Shared view, 0 to 1. Below 0.55 is unreliable |
The defaults are the ones I use.
| Parameter | Default | What it does |
|---|---|---|
| Mode | Global + ICP refine | Global + ICP refine does both in one go. Global registration only stops after the rough match. From table DAT uses a 4x4 you made elsewhere |
| Preset matrix DAT | A 4x4 table DAT, for From table DAT |
|
| Voxel size (m) | 0.05 | Detail of the rough match. Bigger is faster and coarser |
| Refine voxel (m) | 0.01 | Detail of the refine. 0 means full resolution |
| Max range (m) | 0 | Drop points further away than this. 0 keeps everything. Setting this below your working distance will ruin a calibration |
| Use coloured ICP | off | Use colour as well as shape. Needs colour on both clouds, and only helps when the texture does not repeat |
| Use mask for calibration | off | Calibrate on the masked region only |
| RANSAC seed | -1 | -1 uses every core, and the same scene can give slightly different results each run. Set 0 or more for a repeatable answer, at the cost of running on one thread |
Use mask for calibration helps when the cameras see a lot of static background. It needs a mask
to exist. Kinect Azure has one, the player index. Orbbec and ZED do not, so the toggle is greyed
out. With Custom TOPs it turns on as soon as one row supplies a mask.
One output, out_pop. A POP holding every camera's cloud merged into one point set, in the
reference camera's frame, with two attributes:
P, the positionColor, the colour that camera saw there
Only real points are in it. Pixels the sensor reported as invalid are dropped inside each device, before the merge, so the count is honest and every colour sits on its own point.
Drop it into a geometry COMP, feed it to instancing, or keep going with POPs.
Coming from 0.2.0: this replaces the out_points and out_colors TOP outputs. Use a
POP to TOP if you need the old shape.
Set Device type to Custom TOPs and fill in the customSources table inside the component,
one row per camera:
| name | pointcloud | color | mask |
|---|---|---|---|
| cam1 | /project1/clouds/cloud1 |
/project1/clouds/color1 |
|
| cam2 | /project1/clouds/cloud2 |
/project1/clouds/color2 |
Then pulse Gather devices and calibrate as usual. The colour TOP does not have to match the
point cloud's resolution, it is sampled per point. Leave mask empty if you have none.
This is also how you add a camera the component does not know yet. If your sensor has a TOP that
outputs a point cloud, add a row to the deviceTypes table. No code needed.
Cameras need real overlap, seen from broadly similar angles. More than about 90 degrees apart, or facing each other, does not give the matcher enough in common.
Get around it by chaining: calibrate 1 to 2 and 2 to 3, and camera 3 lands in camera 1's frame even though 1 and 3 never saw the same thing.
A large flat floor with little else cannot be calibrated reliably. A floor looks the same after turning or sliding it, so a wrong answer fits just as well as the right one. Give the cameras something with shape to look at.
This is a one-off calibration step, not something that runs every frame. Expect seconds.
| Path | What |
|---|---|
src/ |
the Python files that become DATs in the component |
tests/ |
suites that need no TouchDesigner |
tools/ |
build and test scripts that run inside TouchDesigner |
src/worker.py runs outside TouchDesigner and works on its own:
python src/worker.py --probe
python src/worker.py job.json
The job format is at the top of that file. The matrix it returns satisfies x_target = M @ x_source
with column vectors.
Tests:
python tests/test_pipeline.py # numpy only
python tests/test_worker.py path/to/python.exe # drives the worker as a subprocess
test_pipeline.py checks that the TouchDesigner side never imports Open3D, since that is the thing
that crashes the process.
Follow me on Instagram or Twitter.
To support the work: Patreon
Problems and requests: issue tracker
Best, Darien
Darien Brito, 2026. MIT licensed, see LICENSE.md.