This is the current Gesture Wall rig: one Orbbec Gemini 335 depth
camera in the room's far corner watches both projected walls — wall A
(2.3 m) and wall B (2.5 m), meeting at a 90° corner — so the pipeline runs on
a single shared frame: joint auto-calibration, one camera serves both walls,
and seam handoff between walls comes for free. It supersedes the two-Kinect
rig (KINECT.md, which remains fully supported for cameras with
kind: "kinect_v2").
Everything geometric is unchanged from the depth path KINECT.md describes —
eye→hand ray, wall-plane intersection, roaming invariance. Only the frame
source changed: gesturewall/orbbec.py (OrbbecSource) speaks the exact same
(color_bgr, depth_m, intrinsics) contract as the Kinect source, selected by
kind: "gemini_335" through the gesturewall/framesource.py factory. The
calibrated room lives in room.json: wall planes for A and B, one camera
(cam0, identity extrinsic — its frame is the room frame) serving ["A","B"],
and fusion.cross_camera: true.
- An Orbbec Gemini 335 (this rig's serial:
CP0E8530002Y). Depth FOV is 90°×65°; the color camera is ~86° horizontal — and color is the binding constraint, because pose tracking runs on the color image. - A real USB 3 path into the Mac. The camera will enumerate on USB 2 and
then starve mid-stream —
OrbbecSourceprints a warning when it detects a USB 2 connection. Use a known-good USB 3 port and cable. - Two projectors (one per wall). Auto-calibration uses them as its light source, so no chessboards or tape measures are needed.
Quick visibility check once it is plugged in:
system_profiler SPUSBDataType | grep -i -B2 -A8 orbbecPython 3.10+ (tested on 3.13, Apple Silicon). From gesture-wall/:
python3 -m venv .venv
.venv/bin/pip install -r requirements.txtrequirements.txt includes the Orbbec SDK bindings. Mind the name split: the
PyPI package is pyorbbecsdk2 (the v2 SDK), but the import name stays
pyorbbecsdk. Sanity check (no camera or sudo needed — importing never
touches the device):
.venv/bin/python -c "import pyorbbecsdk; print('sdk ok')"On macOS, opening the camera (USB/UVC access) needs elevated permissions.
An un-sudo'd process dies with uvc_open error -3 the moment it touches
the device. So every camera-touching command — probe, preview, autocal, the
live server — must run under sudo -E (-E preserves your environment,
including the GESTUREWALL_ORBBEC_* knobs below).
Probe the hardware end-to-end (resolution, fps, center depth, intrinsics):
sudo -E .venv/bin/python -m gesturewall.orbbec --serial CP0E8530002YThe camera goes flush in the room's far corner — the corner diagonally opposite the wall seam — roughly 2.5 m along each wall from the seam:
- Height: 2.0–2.2 m (above heads, so bodies don't occlude each other).
- Pitch: ~10° down.
- Aim: straight at the wall seam (the A/B corner).
From that corner the two walls together span 83.5°, and the color camera has 86° to give — a margin of only ~2.5°. Every 10 cm the camera sits away from the corner costs ~4° of that margin, so "roughly near the corner" does not work: mount it flush, and verify that both far wall edges are visible in the preview (next section) before calibrating. If an edge is cut off, that wall's outer tiles are simply invisible to the system.
Stream the camera's color view to a browser and adjust until both walls (and both far edges) are in frame:
sudo -E .venv/bin/python -m gesturewall.preview --kind gemini_335 \
--serial CP0E8530002Y --port 8802
# then open http://localhost:8802/A thirds grid + centre crosshair are drawn to help leveling; put the crosshair on the wall seam. Only one process can hold the camera at a time — stop the gesture server or autocal before starting the preview (and vice versa).
Calibration is fully automatic: gesturewall.autocal flashes magenta discs at
known wall coordinates through the projectors, finds them in the camera's
color+depth, and writes the fitted wall planes back into the config. One joint
run calibrates both walls from the single camera. From gesture-wall/:
sudo -E .venv/bin/python -m gesturewall.autocal --config room.json \
--width A=2.3 --width B=2.5 --port 8801--width WALL=METRES pins each fitted plane to the measured physical wall
width — measure once with a tape measure, then reuse forever.
Then:
-
Open
http://localhost:8801/autocal.html?wall=Afullscreen on wall A's projector, and...?wall=Bfullscreen on wall B's projector. -
Start it, stay out of the camera's view for ~90 seconds:
curl -X POST http://localhost:8801/calib/start curl http://localhost:8801/calib/status # progress / result
On success the wall planes are written into room.json (guarded by sanity
gates on wall width, height, and the angle between the planes — a bad run
writes nothing). From the repo root, ./run-room.sh --calibrate wraps this
whole flow.
When to recalibrate: after moving anything — the camera, either projector, or the projected image size/keystone — and prefer calibrating a warm camera (give it a few minutes of streaming first; depth drifts slightly as the module warms up).
From the repo root, the room script starts everything (Vibersyn + the gesture server + the wall windows):
./run-room.sh --gestureOr by hand, from gesture-wall/ (the server auto-downloads its MediaPipe pose
model into models/ on first run, relative to the cwd — so run it from here):
sudo -E .venv/bin/python -m gesturewall.server --config room.json
# overrides: --ws-port 8770 --http-port 8000 --fps 30 --num-poses 4Raise a hand above the shoulder to engage, aim, dwell ~0.8 s to select — identical interaction, WS protocol, and wall clients as the Kinect depth path.
Live-proven on this rig, and the single biggest quality lever. The two halves of the pipeline see light very differently:
- Depth is immune to room lighting — the Gemini carries its own IR emitter; a pitch-black room measures depth perfectly.
- Pose is not — MediaPipe runs on the color image, and a dark room lit only by the projectors starves the landmarks. In live testing, turning ambient light on made pointing much better.
So: keep some ambient light on the people (not washing out the projection). Two opt-in env levers can additionally bias the camera's auto-exposure toward brighter people (best-effort — unsupported firmwares just warn):
GESTUREWALL_ORBBEC_BRIGHTNESS=<int> # AE target brightness
GESTUREWALL_ORBBEC_BACKLIGHT=<0-6> # backlight compensationIf you change either, re-run autocal — the color response changes, and the marker detector diffs color frames.
Orbbec's official G335 guidance for hand tracking is the "Hand" depth preset + 1280x800 native depth + hardware frame sync. Enabled together on this rig, they made pointing measurably WORSE (wall B autocal marker fill dropped from 9/9 to 7/9 — the Hand preset trades depth fill for edge sharpness, the wrong tradeoff at this room's 2.5–3.6 m ranges). The defaults are therefore the proven-good device behavior, and each knob is opt-in for individual A/B runs:
GESTUREWALL_ORBBEC_PRESET=Hand # named depth preset (empty = device default)
GESTUREWALL_ORBBEC_DEPTH=1280x800 # explicit native depth mode
GESTUREWALL_ORBBEC_SYNC=1 # hardware color/depth frame syncSet them in the environment of the camera process (this is why the sudo
requirement is sudo -E). Test one at a time, and re-run autocal after any
change that alters the depth or color response.
uvc_openerror -3 / "enumeration failed" mentioning permissions — you are not running under sudo. Re-run the command withsudo -E(§2).- "camera on USB2.x, not USB 3" warning — streams will start and then starve. Move to a real USB 3 port / better cable; avoid cheap hubs.
- Frames stop mid-session —
OrbbecSourceauto-recovers: after 5 s of consecutive empty waits it logs the pipeline status (which says whether the problem sits in the SDK, driver, firmware, or hardware), closes the device, and transparently re-enumerates on the next read. If it recovers repeatedly, reseat the USB cable (driver/hardware issue) or power-cycle the camera (firmware issue) per the logged status. - Blank preview / dead autocal page — another process is holding the camera (only one can). Stop the gesture server first.
- Firmware — this unit runs 1.4.60. Orbbec ships 1.8.10; updating is a documented future maintenance step, not yet performed or validated on this rig — don't update in the middle of an install, and expect to re-check the preset/sync behavior (§8) afterwards.
The Orbbec source is covered headless — tests plant a fake pyorbbecsdk in
sys.modules before start() runs, so no SDK or hardware is needed:
.venv/bin/python -m pytest -q # whole suite (must stay green)
.venv/bin/python -m pytest -q tests/test_orbbec.py # source contract, decode, stall
.venv/bin/python -m pytest -q tests/test_framesource.py # kind dispatch
.venv/bin/python -m pytest -q tests/test_autocal.py # marker detection, plane fits