autoplay
theme

Building 3D Zone Detection with OAK-D Cameras in TouchDesigner

December 26, 2025

Installations often need to know when a person enters a 3D region, not a 2D box. I built zone detection and a wireframe overlay for OAK-D cameras with TouchDesigner's Script CHOP. These are the traps along the way.

Understanding the OAK-D Coordinate System

The first challenge was understanding how OAK-D reports 3D positions. Unlike simple 2D tracking, the OAK-D uses an IMU-stabilized world coordinate system:

  • X axis: Horizontal, +X = right (millimeters)
  • Y axis: Vertical, +Y = up, -Y = down (millimeters)
  • Z axis: Depth, +Z = away from camera (millimeters)

Camera mounting orientation only affects the video feed, not the 3D coordinates. The IMU ensures coordinates remain world-referenced regardless of how the camera is physically mounted. This means your zone definitions stay consistent even if you rotate or flip the camera.

# Example detection from OAK-D# Person at x=95mm, y=-802mm, z=2778mm# → 2.78m from camera, slightly right, below optical axis (standing)

The Script CHOP Parameter Gotcha

TouchDesigner's Script CHOP is powerful for custom data processing, but there's a subtle API detail that cost me debugging time. When creating custom parameters, the appendX() functions return a ParGroup, not a Parameter directly:

# ❌ WRONG - This will fail silently or errorp = page.appendMenu('Preset', label='Camera Preset')p.menuNames = ['option_a', 'option_b']  # ParGroup doesn't have menuNames!# ✅ CORRECT - Access the first parameter in the ParGroupp = page.appendMenu('Preset', label='Camera Preset')p[0].menuNames = ['option_a', 'option_b']p[0].menuLabels = ['Option A', 'Option B']p[0].default = 'option_a'

This pattern applies to all parameter types: appendInt, appendFloat, appendMenu, appendToggle, and operator references like appendDAT, appendCHOP, appendTOP.

Calculating Camera Intrinsics from FOV

Rather than hardcoding camera intrinsics or requiring manual calibration, I built a preset system that calculates intrinsics from the camera's field of view specifications:

OAK_CAMERA_PRESETS = {    'oak_d_pro_af': {'name': 'OAK-D Pro (Auto-Focus)', 'hfov': 66, 'vfov': 54},    'oak_d_pro_ff': {'name': 'OAK-D Pro (Fixed-Focus)', 'hfov': 69, 'vfov': 55},    'oak_d_sr':     {'name': 'OAK-D SR (Short Range)', 'hfov': 80, 'vfov': 55},}def calculate_intrinsics(preset_key, width, height):    preset = OAK_CAMERA_PRESETS[preset_key]    hfov_rad = math.radians(preset['hfov'])    vfov_rad = math.radians(preset['vfov'])        fx = (width / 2.0) / math.tan(hfov_rad / 2.0)    fy = (height / 2.0) / math.tan(vfov_rad / 2.0)    cx, cy = width / 2.0, height / 2.0        return {'fx': fx, 'fy': fy, 'cx': cx, 'cy': cy}

This approach lets users simply select their camera model and resolution from dropdowns, with the math handled automatically.

3D to 2D Projection: The Y-Axis Trap

Projecting 3D zone boundaries onto the 2D camera feed required careful attention to coordinate system conventions. Image space has Y+ pointing down, while the OAK-D world space has Y+ pointing up:

def project_to_screen(point3d, camera):    x, y, z = point3d    if z <= 0:        return None  # Behind camera        # Project to pixel coordinates    # Note: Y is inverted between world and image space    px = (x * camera['fx'] / z) + camera['cx']    py = camera['cy'] - (y * camera['fy'] / z)  # Subtract, not add!        # Normalize to TouchDesigner's -0.5 to 0.5 range    nx = px / camera['width'] - 0.5    ny = 0.5 - py / camera['height']        return (nx, ny)

Getting this wrong results in mirrored wireframes that don't align with detected people.

CHOP to GLSL: Channel Naming Matters

Passing data from Script CHOP to GLSL via CHOP To TOP revealed another gotcha: channels are mapped to RGBA in alphabetical order. Using descriptive names like x1, y1, x2, y2 can result in unexpected channel ordering.

# ❌ Alphabetical order: a, b, x1, x2, y1, y2 — not what we want!scriptOp.appendChan('x1')scriptOp.appendChan('y1')scriptOp.appendChan('x2')scriptOp.appendChan('y2')# ✅ Use r,g,b,a to guarantee RGBA mappingrChan = scriptOp.appendChan('r')   # → R channel (x1)gChan = scriptOp.appendChan('g')   # → G channel (y1)bChan = scriptOp.appendChan('b')   # → B channel (x2)aChan = scriptOp.appendChan('a')   # → A channel (y2)

In the GLSL shader, sample with proper texel addressing:

// CHOP To TOP: width = numSamples, height = 1int numLines = textureSize(sTD2DInputs[1], 0).x;float texX = (float(i) + 0.5) / float(numLines);vec4 lineData = texture(sTD2DInputs[1], vec2(texX, 0.5));vec2 p1 = lineData.rg;  // x1, y1vec2 p2 = lineData.ba;  // x2, y2

GLSL Uniform Defaults

GLSL uniforms default to zero, which can cause invisible rendering if you forget to set them in TouchDesigner:

uniform float uLineWidth;  // Defaults to 0 = invisible lines!uniform float uOpacity;void main() {    // Always provide fallback defaults    float lineWidth = uLineWidth > 0.0 ? uLineWidth : 2.0;    float opacity = uOpacity > 0.0 ? uOpacity : 0.8;    // ...}

Texture-Based Digit Rendering

For zone labels, I initially considered SDF-based procedural digit rendering. However, a texture atlas approach proved simpler and more flexible:

// Digit texture: 50x500 PNG with 0-9 stacked verticallyfloat sampleDigit(vec2 localUV, int digit) {    digit = clamp(digit, 0, 9);    float digitHeight = 0.1;  // Each digit = 1/10th of texture        vec2 texUV;    texUV.x = localUV.x;    texUV.y = float(digit) * digitHeight + localUV.y * digitHeight;        return texture(sTD2DInputs[4], texUV).r;}

Architecture Overview

The final system uses three Script CHOP instances feeding into a single GLSL TOP:

┌─────────────┐│ Zones Table │ (DAT: name, zone, xmin, xmax, ymin, ymax, zmin, zmax)└──────┬──────┘       │       ├────────────────┬────────────────┐       ▼                ▼                ▼┌─────────────┐  ┌─────────────┐  ┌─────────────┐│ Zone Detect │  │ Zone Lines  │  │ Zone Labels ││ Script CHOP │  │ Script CHOP │  │ Script CHOP ││ (triggers)  │  │ (wireframe) │  │ (numbers)   │└──────┬──────┘  └──────┬──────┘  └──────┬──────┘       │                │                │       ▼                ▼                ▼  To Logic       CHOP To TOP      CHOP To TOP                       │                │                       └───────┬────────┘                               ▼                        ┌─────────────┐                        │  GLSL TOP   │ ← Camera + Digits Texture                        └─────────────┘

The complete code handles variable zone counts, camera presets with automatic intrinsics calculation, and real-time active zone highlighting.