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

MediaPipe Hands for robotics — and the 16° angle trap in normalized landmarks

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 4: This Article
Twenty-one points per frame, on a laptop CPU, from a flat RGB image. That is what MediaPipe hands to a robot. It is easy to treat as a black box — until you compute angles from it and discover the box stretched your coordinate space.

Where MediaPipe sits
#

flowchart TB
    CAM["USB webcam
640×480 @ 30 fps · YUYV"] -->|"BGR frame"| RGB["cv2.cvtColor
BGR → RGB"] RGB --> MP["MediaPipe Hands
palm detector + landmark model"] MP -->|"21 × (x, y, z)"| ANG["Triplet angles
Part 5"] ANG --> AVG["Average per finger
Part 6"] AVG --> NORM["Flexion 0..1
Parts 7–8"] NORM -->|"/hand/target_flexions"| TWIN["MuJoCo twin"]

The vision layer knows nothing about MuJoCo. Its entire output is five numbers between 0.0 (open) and 1.0 (closed).

Two networks, not one
#

MediaPipe Hands is a cascade:

  1. BlazePalm, a single-shot detector, finds a palm bounding box in the full frame. Palms, not hands: a palm is close to a rigid square; a hand with moving fingers is not.
  2. A landmark model crops that region and regresses 21 keypoints, a hand-presence score and handedness.

In video mode (static_image_mode=False) the detector barely runs. Landmarks from frame t define the crop for frame t+1, and the detector wakes only when tracking confidence drops. That shortcut is why this pipeline holds 30 fps on a CPU.

Setting Value Effect
static_image_mode False Detector runs only when tracking is lost
max_num_hands 1 A second hand in view is ignored
min_detection_confidence 0.5 Palm-detector threshold
min_tracking_confidence 0.5 Below it, the next frame re-detects
Version pin. The code uses the legacy mp.solutions.hands API: mediapipe==0.10.14 in the container, 0.10.11 in the Python 3.10 standalone venv. Newer releases removed it in favour of the Tasks API (HandLandmarker). Don’t unpin without migrating.

The 21 landmarks
#

MediaPipe hand landmark map: 0 wrist, 1–4 thumb CMC to tip, 5–8 index, 9–12 middle, 13–16 ring, 17–20 pinky
Landmark 0 is the wrist; each finger has four points from knuckle to tip.
Finger Landmarks, base → tip Joints between them
Thumb 1 (CMC), 2 (MCP), 3 (IP), 4 (tip) CMC, MCP, IP
Index 5, 6, 7, 8 MCP, PIP, DIP
Middle 9, 10, 11, 12 MCP, PIP, DIP
Ring 13, 14, 15, 16 MCP, PIP, DIP
Pinky 17, 18, 19, 20 MCP, PIP, DIP

What it looks like live
#

Each frame: the tracker window with draw_landmarks output, the twin, and the tendon forces the resulting flexions produced. Notice the fist — MediaPipe still resolves all five fingers with the hand foreshortened toward the camera.

The trap: what the coordinates actually are
#

Each landmark in results.multi_hand_landmarks has x, y and z:

  • x and y are normalized by image width and height separatelyx = pixel_x / 640, y = pixel_y / 480.
  • z is relative depth with the wrist as origin, roughly on the scale of x; smaller is closer. It is inferred from one RGB image, not measured.

The code builds vectors from (pt.x, pt.y, pt.z) and measures angles between them. Dividing x and y by different numbers stretches the space, so the angle you measure depends on how the finger is oriented in the frame.

We checked it numerically — a true 90° bend drawn in a 640×480 frame:

A 16° error from orientation alone. The empirical calibration constants (Part 7) absorb most of it for a hand held upright — which is why the pipeline works — but tilt your hand and the thresholds drift.

The fix: world landmarks
#

MediaPipe also returns results.multi_hand_world_landmarks: the same 21 points in metres, in a hand-centred 3D frame with no image aspect ratio involved. Angles between those vectors don’t care how the hand is oriented or how far it is from the camera.

# today: image-normalized, aspect-ratio dependent
lm = np.array([(pt.x, pt.y, pt.z) for pt in results.multi_hand_landmarks[0].landmark])

# production-grade: metric and orientation-invariant — recalibrate after switching
lm = np.array([(pt.x, pt.y, pt.z) for pt in results.multi_hand_world_landmarks[0].landmark])

It is a one-line change plus a recalibration, and the first item on the production roadmap.

Frame handling, line by line
#

frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)   # OpenCV captures BGR; the models expect RGB
frame_rgb.flags.writeable = False                     # lets MediaPipe use the buffer without copying
results = self.hands.process(frame_rgb)
frame_rgb.flags.writeable = True
  • BGR → RGB is mandatory. BGR still “works” but degrades detection.
  • writeable = False is MediaPipe’s documented hint to avoid a full-frame copy.
  • No hand → every flexion is 0.0, so the twin opens when you leave the frame — a deliberate fail-open default that a robot holding an object would want to change.

What it costs
#

On the development laptop (Intel Comet Lake, 12 threads), 640×480 frames, mean of 150 frames:

Stage Time per frame
cap.read() — blocks until the next camera frame ~10 ms
hands.process() — MediaPipe ~19 ms
cv2.imshow + waitKey(1) ~4 ms
Loop rate ~30 Hz, camera-limited

Identical in the container and on the host. Why it gets three to four times slower when the MuJoCo viewer is open is Part 19.

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

Related

Calibrating hand tracking to your own hand — with real data from a live session

The shipped thresholds were measured on one hand with one webcam. Here is how to measure yours — and what a live recording revealed about how far off “good enough” can be while the twin still looks perfect. Symptoms that call for calibration # What you see in the published flexions Cause Change A fist, but flexion stays below 1.0 your fist angle is above the curled limit raise *_CURLED_ANGLE to your fist reading Flexion hits 1.0 with the hand half closed curled limit too high lower *_CURLED_ANGLE Flexion above 0 with a relaxed open hand your open angle is below the straight limit lower *_STRAIGHT_ANGLE to your open reading The thumb flickers thumb window too narrow for your jitter widen it (Part 8) Calibrate against the numbers, not the render. With the current force-control tuning, the simulated finger snaps shut past flexion ≈ 0.51 (Part 11), so most calibration errors are invisible in the viewer. Watch ros2 topic echo /hand/target_flexions. Real data: what a live session produced # MuJoCo’s viewer has a Control panel showing the live force of every motor. Since the force is \(F = 50 - 100 \cdot \text{flexion}\), every recorded frame gives back the exact flexion the tracker published: flexion = (50 − F) / 100.

Finger joint angles from three hand landmarks: the dot-product geometry

Every knuckle angle in this project comes from three landmarks and one dot product. No learning, no lookup table — just the definition of the angle between two vectors, plus two guards that keep a single glitchy frame from sending NaN into a motor command. Three points make an angle # An angle needs a vertex and two rays. A knuckle is the vertex; the two bones meeting there are the rays: a base point, where the previous bone starts, the vertex — the knuckle being measured, an end point, where the next bone ends. flowchart LR P1(("p1 base")) -- "v1 = p1 − p2" --- P2(("p2 vertex knuckle")) P2 -- "v2 = p3 − p2" --- P3(("p3 end")) The triplet table # self.finger_triplets = { "thumb": [(0, 1, 2), (1, 2, 3), (2, 3, 4)], "index": [(0, 5, 6), (5, 6, 7), (6, 7, 8)], "middle": [(0, 9, 10), (9, 10, 11), (10, 11, 12)], "ring": [(0, 13, 14), (13, 14, 15), (14, 15, 16)], "pinky": [(0, 17, 18), (17, 18, 19), (18, 19, 20)] } Landmark 0 — the wrist — starts every finger’s first triplet. Finger Triplet Vertex Joint measured Index (0, 5, 6) 5 MCP — joins finger to palm Index (5, 6, 7) 6 PIP — middle knuckle Index (6, 7, 8) 7 DIP — fingertip knuckle Thumb (0, 1, 2) 1 CMC — saddle joint at the wrist Thumb (1, 2, 3) 2 MCP Thumb (2, 3, 4) 3 IP Why every first triplet starts at 0. The palm has no landmark of its own, so the wrist → knuckle line stands in for the metacarpal bone. It isn’t exactly collinear with a straight finger — ring and pinky metacarpals fan outward — so a relaxed straight finger rarely measures a full \(\pi\). That’s part of why the calibrated “straight” threshold is 3.10 rad rather than 3.14.

Normalizing finger curl: from radians to a 0–1 flexion

Radians belong to the camera. Newtons belong to the robot. The number that crosses between them is a plain percentage — and producing it takes one inverted formula and one clip. The formula # RAW_STRAIGHT_ANGLE = 3.10 # ~177.6° — open finger RAW_CURLED_ANGLE = 1.60 # ~91.7° — fully curled finger flexion = (straight_limit - avg_angle) / (straight_limit - curled_limit) flexions[finger_name] = float(np.clip(flexion, 0.0, 1.0)) $$\text{flexion} = \operatorname{clip}\!\left(\frac{\theta_\text{straight} - \bar\theta}{\theta_\text{straight} - \theta_\text{curled}},\; 0,\; 1\right)$$ The three pieces # Denominator — the range. \(3.10 - 1.60 = 1.50\) rad of travel. It sets the scale.

Why the thumb needs its own thresholds in hand tracking

Squeeze your thumb across your palm as hard as you can and, by the finger thresholds, it is only half closed. The thumb isn’t a finger with a shorter bone — it’s a different joint. Thumb curled, fingers open. Only pull_thumb goes negative (−19.5 N, flexion 0.69); every other motor pushes open. Hinges versus a saddle # Index to pinky are chains of hinge joints. A fist rolls them into a tight spiral, each knuckle approaching 90°. The thumb hangs from the carpometacarpal (CMC) saddle joint at the wrist. It sweeps across the palm — opposition — instead of simply curling. Much of “closing the thumb” is rotation of the whole thumb, not bending at its knuckles. What MediaPipe measures because of it # Interior angles come from 3D landmark positions, so the sweep only partly shows up as knuckle bend: Open Fully closed Travel Finger — mean of MCP/PIP/DIP ≈ 3.10 rad ≈ 1.60 rad 1.50 rad Thumb — mean of CMC/MCP/IP ≈ 2.90 rad ≈ 2.30 rad 0.60 rad A fully closed thumb stops near 2.30 rad (~132°). Even its open angle sits below a finger’s — a relaxed thumb is never in line with the wrist.

How MediaPipe sees a hand — and exactly where it fails

Twenty-one points, thirty frames a second, on a CPU, from a flat RGB image with no depth information. This is how that is possible — and the four failure modes you will meet the first time you rely on it. Every vision-driven robotics project has a moment where the camera stops being a camera and starts being a sensor. For this one, that moment is MediaPipe Hands: a webcam frame goes in, and 21 numbered points in space come out. It is easy to treat that as a black box. It is also a mistake, because the box has a specific shape, and its failure modes follow directly from how it was built. It is two networks, not one # The single most useful thing to know about MediaPipe Hands is that it is a cascade: a detector and a regressor, with completely different jobs. flowchart TB A["📷 Full frame e.g. 640 × 480"] --> B["Stage 1 — BlazePalm SSD detector"] B --> C["Oriented palm crop 256 × 256"] C --> D["Stage 2 — Landmark regressor MobileNetV2-style encoder"] D --> E["63 floats 21 landmarks × (x, y, z)"] D --> F["Presence score"] D --> G["Handedness left / right"] F -->|"confidence ≥ 0.5"| C F -->|"confidence < 0.5"| B The regressor’s output, drawn back onto the frame: 21 points and the connections between them. Stage 1 — BlazePalm detects palms, never fingers # This is the design decision the whole system rests on.