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

What limits the frame rate: MediaPipe, Docker and a MuJoCo viewer on one laptop

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 19: This Article
The single-script version felt smoother than the containers. The obvious suspect was Docker. The measurements say otherwise — and point at something more interesting: two programs sharing one integrated GPU.

Setup
#

Laptop with an Intel Comet Lake CPU (12 threads) and Intel UHD Graphics; Linux on Wayland with XWayland; USB webcam at 640×480, 30 fps, YUYV. Each row: mean of 150 frames after a 10-frame warm-up, no hand in view. Measured 2026-09-16.

The result
#

Condition Camera read MediaPipe imshow Loop
Vision alone, container 10.0 ms 19.0 ms 4.5 ms 29.8 Hz
Vision alone, host venv (MediaPipe 0.10.11) 10.6 ms 19.2 ms 3.9 ms 29.7 Hz
Container vision + 1-core CPU burner 8.2 ms 23.5 ms 3.9 ms 28.1 Hz
Host vision + plain MuJoCo viewer on host (no Docker, no ROS) 1.6 ms 54.9 ms 5.9 ms 16.0 Hz
Container vision + mujoco_twin capped at 30 Hz 1.7 ms 63.7 ms 8.1 ms 13.6 Hz
Container vision + mujoco_twin as shipped 1.9 ms 85.6 ms 10.2 ms 10.2 Hz

End to end, ros2 topic hz /hand/target_flexions read 30.0 Hz with only vision_tracker running and 10.7 Hz once mujoco_twin started.

The camera-read time drops when the loop slows, because a frame is already waiting in the driver buffer.

What it rules out
#

Hypothesis Test Verdict
Docker overhead same benchmark, container vs host venv ✘ — 19.0 vs 19.2 ms
MediaPipe version (0.10.14 vs 0.10.11) same comparison
ROS 2 / DDS a plain viewer with no ROS still slows MediaPipe ✘ as the cause
General CPU load busy loop on one core ✘ — +4 ms
The twin’s unthrottled loop cap it at 30 Hz ✘ — still 64 ms
Any concurrently rendering MuJoCo viewer all three viewer rows 3–4.5× slower, every time

The leading hypothesis: one iGPU, two OpenGL programs
#

  • MediaPipe’s own log shows it creating an EGL context on the Intel iGPU at startup — even for the CPU graph.
  • The MuJoCo viewer renders on the same iGPU through GLFW and XWayland.
  • CPU load alone doesn’t reproduce the slowdown; a concurrent viewer does, even at 30 Hz and without Docker.
  • The standalone script runs both in one process and syncs the viewer only once per camera frame.
Not confirmed. The investigation stopped here because the pipeline worked well enough. Treat GPU contention as the best current explanation, not a finding.

Next experiments, one variable at a time:

  1. Start the vision container without /dev/dri (and without privileged, which exposes it anyway) so MediaPipe can’t open its EGL context on the iGPU. Does the viewer still slow it?
  2. Run mujoco_twin headless with MUJOCO_GL=egl and no window. If MediaPipe recovers, rendering is the trigger.
  3. Render MuJoCo on a different GPU — discrete or NVIDIA — and repeat.
  4. Instrument standalone/main.py the same way for a like-for-like single-process baseline.

Cheap wins regardless of the cause
#

Change Expected effect
Throttle viewer.sync() to ~60 Hz while physics keeps stepping at 500 Hz fewer render submissions on the shared GPU, less CPU
Drop or decimate cv2.imshow in headless deployments saves 4–10 ms per frame
Capture at 320×240 MediaPipe crops the hand anyway
Grab frames on a thread; process only the newest bounded latency when inference is slower than the camera

Reproduce it
#

import time, cv2, mediapipe as mp
cap = cv2.VideoCapture(0)
hands = mp.solutions.hands.Hands(static_image_mode=False, max_num_hands=1,
                                 min_detection_confidence=0.5, min_tracking_confidence=0.5)
for _ in range(10): cap.read()                                   # warm-up
N = 150; tr = tp = ts = 0.0; t0 = time.perf_counter()
for i in range(N):
    a = time.perf_counter(); ok, f = cap.read(); b = time.perf_counter()
    hands.process(cv2.cvtColor(f, cv2.COLOR_BGR2RGB)); c = time.perf_counter()
    cv2.imshow("bench", f); cv2.waitKey(1); d = time.perf_counter()
    tr += b - a; tp += c - b; ts += d - c
T = time.perf_counter() - t0
print(f"loop {N/T:.1f} Hz | read {1000*tr/N:.1f} ms | mediapipe {1000*tp/N:.1f} ms | imshow {1000*ts/N:.1f} ms")
env -u PYTHONPATH venv/bin/python bench.py                                    # host
docker compose run --rm --no-deps -v "$PWD":/bench:ro --entrypoint bash \
  vision_tracker -c 'python3 /bench/bench.py'                                 # container
docker compose up -d mujoco_twin                                              # then repeat with the twin running

Stop the full stack first — a camera opens in one process at a time.

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

Related

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.

Run the tendon-driven hand digital twin in 10 minutes

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. 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 # Docker Compose (full stack) Single Python script Twin only, no camera Model viewer 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.

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).

Underactuation: why three knuckle angles become one tendon command

avg_angle = np.mean(angles) looks like noise filtering. It isn’t. It is a mechanical decision: a human finger has three joints you can move separately, and this robot finger has one string. Not smoothing — compression # “Averaging” in a sensor pipeline usually means averaging over time to reduce noise. Nothing here keeps history between frames. The mean is taken across space — over the three joints of one finger in a single frame — to solve a problem called underactuation. The 3-to-1 problem # Human finger Robot finger Joints 3 (MCP, PIP, DIP) 3 hinges (*_mcp, *_pip, *_dip) Independent actuators many muscles; joints move semi-independently 1 flexor tendon, 1 motor Degrees of freedom you can command ~3 1 A system with fewer actuators than joints is underactuated. A single tendon threads all three joints of each robot finger, so the only command is “pull this string with force F” — and the joints share that pull according to routing geometry and dynamics. One string, six via-points, three joints. Pull it and all three knuckles move together. So the vision layer must compress three human measurements into one robot command.