Skip to main content
  1. Projects/
  2. Tendon-Driven Robotic Hand — A Vision-Teleoperated MuJoCo Digital Twin/

Run the tendon-driven hand digital twin in 10 minutes

Mulham Fetna
Author
Mulham Fetna
Renaissance Engineer
Table of Contents
ROS 2 Tendon-Driven Hand MuJoCo Twin - This article is part of a series.
Part 1: This Article
Clone, run one setup script, run one Compose command — and a webcam window and a MuJoCo viewer open side by side, with a simulated hand that closes when you close yours. Here are four ways in, from the full stack down to the bare model.
What a working setup looks like: MediaPipe tracker window and the MuJoCo twin moving together
The goal of this article: both windows open, and the twin follows your hand.

What you need
#

Requirement Check with Notes
Linux desktop, X11 or XWayland echo $DISPLAY:0 Wayland sessions work through XWayland
Docker Engine + Compose v2 docker compose version Built with Docker 29.8 / Compose 5.5
A webcam ls /dev/video0 Close any other app using it
GPU device nodes ls /dev/dri Intel/AMD out of the box; NVIDIA needs the container toolkit (Part 18)
~5 GB of disk images: vision 2.9 GB, twin 1.7 GB

No ROS installation is needed on the host — ROS 2 Jazzy lives inside the containers.

Four ways to run it
#

git clone https://github.com/mulhamfetna/ros2-tendon-driven-hand-mujoco-digital-twin-vision-teleoperation.git
cd ros2-tendon-driven-hand-mujoco-digital-twin-vision-teleoperation

./setup_host.sh              # X11 access for the containers + camera/GPU checks (once per login)
docker compose up --build    # builds both images, starts vision_tracker and mujoco_twin

Stop with Ctrl+C, then docker compose down to release the camera.

No Docker, no ROS — the reference implementation in one process. Python 3.10 is the tested version.

python3.10 -m venv venv
source venv/bin/activate
pip install "mediapipe==0.10.11" "mujoco==3.13.0" opencv-python numpy
python standalone/main.py        # ESC in the camera window quits

If your shell sources ROS 2 (/opt/ros/*/setup.bash), its PYTHONPATH can shadow the venv — run env -u PYTHONPATH venv/bin/python standalone/main.py instead.

Develop the simulation side without a webcam by publishing flexions by hand:

docker compose up mujoco_twin
# in a second terminal:
docker compose exec mujoco_twin bash -c 'source /opt/ros/jazzy/setup.bash && \
  ros2 topic pub -r 10 /hand/target_flexions sensor_msgs/msg/JointState \
  "{name: [thumb, index, middle, ring, pinky], position: [0.0, 1.0, 0.0, 0.0, 0.0]}"'

Only the index finger curls.

Explore the model interactively — no ROS, no camera:

env -u PYTHONPATH venv/bin/python -m mujoco.viewer --mjcf=mujoco_twin/model/scene.xml

Open the Control panel and drag a pull_* slider. Notice how little force it takes to close a finger completely — Part 11 explains why.

What success looks like
#

With the full stack, in order:

  1. setup_host.sh prints [Camera] /dev/video0 found. and [GPU] /dev/dri found.
  2. The build ends with Image …-vision_tracker Built and Image …-mujoco_twin Built (about five minutes the first time; seconds afterwards).
  3. Two windows open: “MediaPipe Hand Tracker” and the MuJoCo viewer.
  4. The log prints MuJoCo twin listening on /hand/target_flexions.
  5. Make a fist — the twin closes. Open your hand — it opens.
$ docker compose up --build
 Container mujoco_twin Started
 Container vision_tracker Started
mujoco_twin     | [INFO] [1789562507.994533012] [mujoco_twin_node]: MuJoCo twin listening on /hand/target_flexions
vision_tracker  | QFontDatabase: Cannot find font directory /opt/venv/lib/python3.12/site-packages/cv2/qt/fonts.

The QFontDatabase line is harmless noise from OpenCV’s bundled Qt.

The twin, open The twin, fist Camera-free: index only
MuJoCo tendon hand with all fingers open
MuJoCo tendon hand closed into a fist
Only the index finger curled by publishing flexion 1.0 for index

Edit, restart, repeat
#

The repository is bind-mounted into both containers rather than copied into the images. After editing any Python file or MuJoCo XML:

docker compose restart mujoco_twin      # or vision_tracker

Rebuild (docker compose up --build) only when a Dockerfile or a pinned dependency changes. Part 16 explains the layout.

Windows don’t open, the camera won’t start, or the twin ignores your hand? Every error met while building this is in Part 3 — Troubleshooting and FAQ.

Where to go next
#

Mulham Fetna
Author
Mulham Fetna
Renaissance Engineer
ROS 2 Tendon-Driven Hand MuJoCo Twin - This article is part of a series.
Part 1: This Article

Related

Troubleshooting and FAQ: MuJoCo, MediaPipe and ROS 2 in Docker

Every entry here was hit, or deliberately checked, while building this project. Error messages are quoted exactly so a search for the message lands on the fix. Camera and windows # RuntimeError: Failed to open camera at index 0 # Something else holds the webcam — the standalone script, a previous container, a browser tab. Run docker compose down and close video apps. A camera opens in one process at a time. The camera is another node. ls /dev/video*, then map that device and set CAMERA_INDEX (many webcams expose /dev/video0 for frames and /dev/video1 for metadata — use the first). The device isn’t mapped. Check with docker compose config | grep video. Windows don’t open — cannot connect to X server, could not connect to display # Run ./setup_host.sh (it runs xhost +local:root). The permission resets when you log out. Make sure echo $DISPLAY on the host prints :0, or export DISPLAY before docker compose up. On Wayland, confirm XWayland is running: ls /tmp/.X11-unix/ should list X0. Black, blank or garbled OpenCV window # QT_X11_NO_MITSHM=1 must reach the container. It’s in the shared Compose environment — if a service defines its own environment:, it must merge the shared anchor with <<: *ros-env rather than replace it (Part 16).

Docker Compose architecture for ROS 2: dependency-only images and mounted code

Both images hold dependencies and nothing else. The code and the robot model are mounted from your checkout at runtime — so an edit is a five-second restart, not a five-minute rebuild. The big picture # flowchart TB subgraph HOST["🐧 Linux host"] CAMDEV["/dev/video0"] GPU["/dev/dri · Intel iGPU"] X11["/tmp/.X11-unix XWayland :0"] SHM["/dev/shm Fast DDS segments"] NET["host network UDP multicast · domain 42"] REPO["repository checkout"] subgraph VT["🐳 vision_tracker · 2.9 GB image"] VN["vision_tracker_node.py mediapipe 0.10.14 · OpenCV"] end subgraph MT["🐳 mujoco_twin · 1.7 GB image"] MN["mujoco_twin_node.py mujoco 3.13.0 · GLFW"] end end CAMDEV --> VN GPU --> VN GPU --> MN X11 <--> VN X11 <--> MN VN <--> SHM <--> MN VN <--> NET <--> MN REPO -. "bind mount .:/workspace:ro" .-> VN REPO -. "bind mount .:/workspace:ro" .-> MN Repository layout # . ├── docker-compose.yml # both services, shared namespaces ├── setup_host.sh # xhost + device checks, once per login ├── vision_tracker/ │ ├── Dockerfile # ros:jazzy + mediapipe==0.10.14 │ ├── .dockerignore # src/ is mounted, so keep it out of the build context │ └── src/vision_tracker_node.py ├── mujoco_twin/ │ ├── Dockerfile # ros:jazzy + mujoco==3.13.0 │ ├── .dockerignore # src/ and model/ are mounted │ ├── src/mujoco_twin_node.py │ └── model/ # scene.xml → robot.xml (+ tendons.xml), assets/, config.json ├── standalone/main.py # the same pipeline, one process └── docs/ Each service folder is its own build context: editing the vision Dockerfile never invalidates the twin’s image cache, and neither build uploads the 13 MB of meshes it doesn’t need.

GUI, webcam and GPU passthrough for ROS 2 and MuJoCo containers

Containers are headless by design. This project needs two windows, a webcam and a GPU — so the Compose file spends most of its lines punching carefully chosen holes back through the isolation. Two containers, two windows, one X display: the Qt/OpenCV tracker (vision_tracker) and the GLFW MuJoCo viewer (mujoco_twin), recorded on Wayland through XWayland. Windows: pass the X11 socket through # environment: DISPLAY: ${DISPLAY:-:0} QT_X11_NO_MITSHM: 1 volumes: - /tmp/.X11-unix:/tmp/.X11-unix:rw Piece What it does /tmp/.X11-unix mount the X server listens on a Unix socket here (X0 for :0); mounting it gives the container a line to it DISPLAY tells X clients (GLFW, Qt) which display to use; defaults to :0 QT_X11_NO_MITSHM=1 stops Qt — OpenCV’s imshow backend — from using MIT-SHM, which fails across containers and gives blank or garbled windows xhost +local:root (in setup_host.sh) the X server refuses untrusted clients; this admits local root, which is who the containers run as On Wayland # Wayland sessions (KDE Plasma, GNOME) still run XWayland on :0, and both windows open through it — the recording above was made exactly that way. Running MuJoCo natively on a Wayland host, GLFW may warn Wayland: The platform does not provide the window position; it’s harmless.

How a webcam moves a simulated tendon-driven hand

One vision container turns webcam frames into five numbers. One simulation container turns those numbers into tendon forces. Everything else in this series is detail inside one of those two boxes — or the pipe between them. Left to right, three layers in one frame: vision (landmarks), simulation (the twin), actuation (live motor forces from MuJoCo’s Control panel). End to end # flowchart LR subgraph VISION["🐳 vision_tracker container"] direction TB A["Webcam frame 640×480 BGR"] --> B["MediaPipe Hands 21 landmarks"] B --> C["3 knuckle angles / finger dot product"] C --> D["mean → 1 curl angle (underactuation)"] D --> E["normalize + clip flexion 0..1"] end subgraph TWIN["🐳 mujoco_twin container"] direction TB F["lerp +50 N … −50 N"] --> G["data.ctrl on pull_{finger} motor"] G --> H["spatial tendon through 6 sites"] H --> I["3 passive hinge joints curl"] I --> J["MuJoCo viewer"] end E -- "ROS 2 · /hand/target_flexions sensor_msgs/JointState" --> F Stage What comes out Deep dive MediaPipe Hands 21 (x, y, z) landmarks per frame Part 4 Triplet angles 3 interior angles per finger, in radians Part 5 Averaging 1 curl angle per finger Part 6 Normalization flexion 0..1 (the thumb has its own window) Parts 7–8 ROS 2 topic JointState: names are fingers, positions are flexions Part 15 Lerp force in newtons per tendon Part 10 Tendon physics joint angles Part 11 Why the pipe carries flexions, not angles or forces # The contract between the containers is five unitless numbers: 0.0 is an open finger, 1.0 is a closed one. That choice is the architecture.

ROS 2 across Docker containers: shared network, discovery and Fast DDS shared memory

“The topic shows up in ros2 topic list, but echo prints nothing” — the classic ROS-in-Docker symptom. Here’s why it happens, how two containers in this project share a network and a block of memory instead, and what that costs. How ROS 2 nodes find each other # ROS 2 has no master. Nodes discover each other through DDS — in Jazzy, eProsima Fast DDS by default — using the SPDP protocol: Each participant announces itself over UDP multicast (239.255.0.1) on ports derived from the domain ID \(d\): discovery multicast on \(7400 + 250d\), unicast on \(7410 + 250d + 2p\) for participant \(p\). For domain 42: UDP 17900 and 17910+. Peers exchange their topic endpoints; matching publishers and subscribers connect. Data flows over the best transport both support — shared memory when they share a host and /dev/shm, UDP otherwise. Why Docker’s default network breaks it # Compose attaches services to a bridge network — a private NATed subnet. Multicast isn’t reliably routed across it, and machines on your LAN can’t reach container IPs at all.