Skip to main content
  1. Projects/
  2. ROS 2 MediaPipe Robotic Hand — A Real-Time Teleoperation Digital Twin/

A webcam, some vector geometry, and a hand that moves

Mulham Fetna
Author
Mulham Fetna
Renaissance Engineer
Table of Contents
ROS 2 MediaPipe Robotic Hand - This article is part of a series.
Part 1: This Article
Everything in this series in one read: how a $20 webcam ends up driving a 15-DOF CAD model in real time, why every step is deliberately explicit rather than learned, and what broke along the way.

You hold your hand up to a laptop camera. On the other half of the screen, a robotic hand — designed in CAD, never manufactured — closes its fingers at the same moment yours do.

There is no glove, no marker, no depth sensor. Just an RGB webcam, two small neural networks, about forty lines of vector geometry, and a middleware stack that thinks it is talking to a real robot.

All of it is open source under AGPL-3.0 and archived with a DOI: 10.5281/zenodo.22658556.

flowchart LR
    A["📷 Webcam
/dev/video0"] --> B["BlazePalm
palm detector"] B --> C["Landmark regressor
21 × (x, y, z)"] C --> D["Dot-product geometry
15 interior angles"] D --> E["Normalize → flexion
0.0 straight · 1.0 curled"] E --> F["Lerp onto the URDF's
mechanical limits"] F --> G["/joint_states"] G --> H["robot_state_publisher
→ /tf"] H --> I["🖥️ RViz digital twin"]

The rule that shaped the build
#

There is an easier version of this project. Collect a few thousand frames of a hand next to the corresponding CAD poses, train a network to map one to the other, and let gradient descent work out the relationship.

I did not build that, on purpose. Every number in this pipeline is derivable by hand.

That constraint costs accuracy — a learned mapping would handle the thumb’s compound rotation far better than linear interpolation does. What it buys is diagnosis. When the ring finger moves wrongly, there is exactly one arithmetic path from three camera coordinates to a radian value in the URDF, and you can walk it. In a learned system, the same symptom is a shrug and a request for more data.

That matters here because this model is meant to eventually drive physical servos. A twin you cannot debug is a twin you cannot trust with hardware.

Stage 1 — 21 points from a flat image
#

MediaPipe Hands is not one network but two, cascaded.

BlazePalm scans the full frame looking only for the rigid bounding box of the palm — never fingers. Fingers articulate, occlude each other, and vary wildly in shape; palms are stubbornly rectangular. Detecting the easy thing and cropping to it is what makes the pipeline fast enough for a CPU.

The landmark regressor then ignores the frame entirely and sees only that crop, emitting 63 continuous floats: 21 landmarks × \(x, y, z\).

The clever part is that stage 1 almost never runs. Once a hand is found, the next frame reuses the previous bounding box with a small margin and goes straight to the regressor. The detector only wakes when confidence drops — a fast movement, an occlusion, a hand leaving frame. That temporal shortcut is the whole reason this runs on a laptop.

The \(z\) axis is worth being honest about: a webcam has no depth sensor, and MediaPipe fakes it. The network was trained on synthetic 3D hands, anchors \(z=0\) at the wrist, and infers relative depth from apparent scale and shading. It is relative, not metric — which turns out to be sufficient, because the kinematics that follow only ever measure angles between vectors, and angles are invariant to the overall scale of the coordinate system.

Part 2 goes deeper →

Stage 2 — from points to angles
#

Here is the hinge of the entire project, and it is just trigonometry.

To measure a joint, take three adjacent landmarks: the joint itself as the vertex, plus its two neighbours. For the index finger’s PIP joint that is landmarks 5, 6 and 7. Build two vectors radiating out from the vertex along the bones:

$$\vec{v}_1 = P_1 - P_2 \qquad \vec{v}_2 = P_3 - P_2$$

and recover the interior angle between them from the dot product:

$$\theta = \arccos\left(\frac{\vec{v}_1 \cdot \vec{v}_2}{\lVert\vec{v}_1\rVert \, \lVert\vec{v}_2\rVert}\right)$$

Fifteen triplets, fifteen angles, no state, no learning. A straight finger reads about 3.10 rad (≈177°); a curled one about 1.60 rad (≈90°).

Two numerical traps live in that one line, and both bit during development. If MediaPipe emits two identical landmark coordinates, a bone length is zero and the division produces NaN. And floating-point error routinely pushes the cosine to 1.0000000002, which is outside \(\arccos\)’s domain and raises. The guards are unglamorous and non-optional:

if norm1 < 1e-6 or norm2 < 1e-6:
    angles.append(0.0)
else:
    cosang = np.clip(np.dot(v1, v2) / (norm1 * norm2), -1.0, 1.0)
    angles.append(float(np.arccos(cosang)))

Stage 3 — from human angles to mechanical ones
#

A raw angle is a biometric measurement. A URDF joint has mechanical limits that came out of CAD. Bridging them takes two steps.

Normalize the raw angle into a flexion ratio between fully open and fully curled:

$$\text{flexion} = \frac{\text{RAW\_STRAIGHT} - \theta}{\text{RAW\_STRAIGHT} - \text{RAW\_CURLED}}$$

Interpolate that ratio onto the joint’s actual range:

$$\theta_{\text{urdf}} = \theta_{\text{open}} + \text{flexion} \times (\theta_{\text{closed}} - \theta_{\text{open}})$$

RAW_STRAIGHT_ANGLE = 3.10 and RAW_CURLED_ANGLE = 1.60 are the calibration window — empirical constants, not derived ones. They define which slice of human motion gets stretched across the mechanism’s full travel, and they are the first thing to touch when the whole hand under- or over-flexes.

The per-joint limits come from the URDF itself, parsed at startup. A fifteen-row table binds each computed angle to a named joint and records which end of its range is the open hand:

JOINT_MAPPING = [
    ('thumb_mcp',   0, 'lower'),
    ('index_mcp',   3, 'upper'),
    ('middle_pip',  7, 'lower'),
    ...
]

Note the open end disagrees between rows: thumb_mcp opens at its lower limit, index_mcp at its upper. That is not sloppiness — it reflects how each mate was constructed in CAD, and it is the only per-joint fact the table still stores. The angles themselves are read from the URDF.

Part 3 goes deeper →

Stage 4 — publishing into a robot
#

The tracker publishes two topics per frame, and the split is deliberate:

Topic Type Contents Purpose
/hand/joint_angles hand_msgs/JointAngles 15 raw radians Telemetry — the vision layer in isolation
/joint_states sensor_msgs/JointState 15 named, mapped joints Drives the twin

That redundancy pays for itself the first time something looks wrong. Compare the two streams and the fault localizes immediately: bad raw angles mean a vision problem, good raw angles with bad mapped ones mean a table problem.

One deliberate omission is worth calling out. Standard ROS 2 URDF demos run a joint_state_publisher that invents joint positions from GUI sliders. This project deletes it. The tracker is the joint state publisher. Leaving both running would put two publishers on one topic, and the model would flicker between your hand and whatever the sliders last held.

Part 4 goes deeper →

Stage 5 — where the model came from
#

The hand was designed in Onshape — the assembly is public — and exported with onshape-to-robot, which reads mate names, mate limits and material densities straight out of the CAD. Done properly, that means joint names and inertia tensors are generated rather than hand-written.

Done improperly, every shortcut taken in CAD becomes a bug in ROS. This export produced a good one — real inertias from 1.86 g at the fingertips to 61 g at the palm, every revolute axis correctly on local Z — and three defects that are documented rather than hidden.

The most memorable: duplicating a finger sub-assembly in Onshape copies its mates and their names, so the ring finger arrived carrying the pinky’s twinky_mcp. Two joints with one name; the exporter dropped one; the ring finger had no base knuckle.

And yes — twinky. A legacy name for the pinky, still in the CAD, therefore in the URDF, therefore in the Python mapping, because JointState matches joints by exact string.

Part 5 goes deeper →

Stage 6 — making it run anywhere
#

ROS 2 Jazzy is hard-locked to Ubuntu 24.04, which is a good reason to containerize. But robotics containers are a strange exercise: you isolate the process, then spend your time punching holes back through the isolation for the webcam, the GPU, the display server and the network.

Four services, all on network_mode: host because DDS discovery uses UDP multicast and a Docker bridge network kills it; /tmp/.X11-unix mounted so RViz has a portal to your monitor; /dev/video0 and /dev/dri passed through for the camera and hardware rendering; source directories bind-mounted so a code change needs a restart, not a three-minute rebuild.

The full running stack: RViz twin, MediaPipe landmark overlay, and the tracker and sniffer logs streaming side by side
All of it running at once: the twin, the tracker’s landmark overlay, and both topics streaming.

Part 6 goes deeper →

What it does not do
#

Four honest limits, each a documented next step rather than a hidden flaw:

flowchart TB
    A["✅ Working today
vision · kinematics · TF · RViz twin"] --> B["⬜ No smoothing
landmark jitter passes straight through"] A --> C["⬜ No physics
Gazebo spawns the model,
nothing drives it"] A --> D["⬜ No actuation
no transmissions, no controllers, no servos"] A --> E["⬜ Forward kinematics only
per joint, no IK, no coupling"]

There was also, until recently, a live defect worth recounting. Fourteen of the fifteen mapping rows transcribed their joint’s limits exactly. ring_mcp carried 0.000 / -1.571 against a URDF limit of 0.39671 / -1.17409 — a leftover from before that mate gained a real limit in CAD. At full curl the node commanded roughly 23° past the joint’s mechanical stop.

Nothing errored. robot_state_publisher does not enforce URDF limits; it applies whatever transform it is handed. So RViz showed a ring knuckle bending slightly further than the mechanism physically could, silently — and it would have become a hard failure the moment this drove a physics engine or a real servo.

The row is fixed — but the class of bug is the part worth keeping: a contract duplicated across two files, with no mechanism to detect divergence.

So the duplication went away. The limits are now parsed from the URDF when the node starts, and the Python table holds only which end of each joint’s range is the open hand — the one fact a <limit> element cannot express. Editing the CAD no longer requires editing Python, and a joint renamed by a re-export raises at startup rather than silently freezing a finger.

The same shape still sits in the duplicated hand_msgs package, where a field added to one copy and not the other yields a subscriber that silently never fires. Worth knowing where your second copies are.

What it was actually for
#

This is a first ROS 2 project, not a novel result, and the pitch should say so. Markerless hand teleoperation is well-trodden ground.

What makes it worth writing up is the integration. Computer vision, hand-derived kinematics, a CAD-to-URDF pipeline with real material properties, an industry-standard middleware, and a reproducible containerized environment — connected end to end, by one person, with the failures documented rather than cropped out of the demo video.

The gap between “I can train a model” and “I can make a model, a mechanism and a middleware agree with each other in real time” is most of the job in robotics. This is a small, complete instance of that gap being closed.


The code, all six documentation chapters and the DOI are on GitHub.

Mulham Fetna
Author
Mulham Fetna
Renaissance Engineer
ROS 2 MediaPipe Robotic Hand - This article is part of a series.
Part 1: This Article

Related

Containerizing ROS 2 without losing the hardware

Web developers containerize to isolate processes. Roboticists containerize and then spend the rest of the day punching holes back through the isolation — for the camera, the GPU, the display server and the network. ROS 2 Jazzy is hard-locked to Ubuntu 24.04. That single fact is reason enough to containerize: without it, adopting a ROS distribution means adopting an operating system version, on every machine that will ever run the code. But a ROS container is a strange artifact. A web service container wants isolation — that is the product. A robotics container needs a webcam, a GPU, a display server and multicast networking, all of which live outside it. You end up building a box and then carefully cutting four holes in it. Here is every hole in this project’s compose file and what it is for. Cold start to live tracking. Note hand_msgs being compiled separately inside two different containers — that duplication is deliberate and load-bearing. flowchart TB subgraph HOST["🖥️ Host — Ubuntu / Kubuntu"] X11["X11 socket /tmp/.X11-unix"] CAM["/dev/video0"] GPU["/dev/dri"] NET["host network UDP multicast"] end subgraph C["Active containers · ROS_DOMAIN_ID=42"] HT["hand_tracker"] RV["ros_rviz"] TS["topic_sniffer"] end GZ["gazebo_sim (commented out)"]:::parked classDef parked stroke-dasharray: 5 5,opacity:0.55 CAM --> HT GPU --> HT GPU --> RV X11 --> HT X11 --> RV NET <--> HT NET <--> RV NET <--> TS Hole 1 — the network wall has to come down # network_mode: "host" ipc: host pid: host This is the one that breaks stacks silently, so it is worth understanding rather than copying.

From camera coordinates to mechanical radians

Twenty-one points in a camera’s coordinate system on one side. A CAD assembly with hard mechanical stops on the other. This is the arithmetic that makes them agree — and the one row where it currently does not. The vision layer gives you 21 points floating in a normalized coordinate space that has no physical units and a faked depth axis. The mechanical layer gives you fifteen revolute joints, each with a lower and upper bound in radians that came out of a CAD mate. Nothing connects them. Building that connection is the actual work of this project, and it happens in about forty lines of Python. flowchart LR A["21 landmarks (x, y, z) normalized"] --> B["Triplet selection 15 × (p₁, p₂, p₃)"] B --> C["Two vectors per joint v₁ = p₁ − p₂ · v₂ = p₃ − p₂"] C --> D["Dot product → arccos θ in radians"] D --> E["Normalize flexion ∈ [0, 1]"] E --> F["Lerp onto URDF limits θ_urdf"] F --> G["JointState names + positions"] Step 1 — pick three points # To measure a joint you need the joint itself and the two bones meeting at it. In landmark terms: the vertex, plus its two neighbours.

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.

Why ROS 2 earns its complexity — and how this graph is wired

Every mechatronics engineer hits the wall where ROS 2 feels like an enormous tax just to move a few servos. Here is when that instinct is right, when it stops being right, and what this project’s graph actually looks like. Writing raw sockets on an ESP32 is cleaner on day one. It is genuinely simpler, genuinely faster to get moving, and for a single microcontroller driving a handful of servos it is often the correct engineering decision. ROS 2 is not a plug-and-play convenience layer. It is distributed middleware built to solve problems you do not have yet — time-synchronizing asynchronous nodes, standardizing message types across C++ and Python, managing coordinate transform trees that nest six levels deep. Adopting it before you have those problems is pure overhead. The question is when you cross over. For this project, the crossing point was concrete: the moment a second consumer needed the same hand data. One script drawing on a frame is trivial. One script producing angles, another rendering a kinematic tree, a third logging telemetry, all needing the same data at the same instant without knowing about each other — that is when the framework starts paying rent.

From an Onshape assembly to a robot ROS 2 can reason about

Bridging a modern parametric CAD platform and a fifteen-year-old XML standard is where most roboticists lose days. The exporter translates exactly what it sees — so every shortcut taken in CAD becomes a bug in ROS. → Open the assembly on Onshape — it is public, so everything below is checkable against the source. The assembly: four fingers on blue linkages, the thumb on an orange one, all mounted to a single palm block. onshape-to-robot is a compiler. Assembly in, robot description out. It reads mate names, mate limits and material densities directly from the CAD document and writes them into URDF as joint names, joint limits and inertia tensors. That is a genuinely good deal — done properly, the physical properties of your robot are generated rather than hand-typed, and they stay correct when the mechanism changes. Done improperly, you spend your evenings editing XML by hand and discovering that your ring finger has no knuckle. flowchart LR A["Onshape assembly mates · limits · materials"] --> B["onshape-to-robot API pull"] B --> C["robot.urdf links · joints · inertials"] B --> D["assets/*.stl visual + collision meshes"] C --> E["robot_state_publisher"] C --> F["JOINT_MAPPING limits transcribed by hand"] D --> G["RViz / Gazebo"] Five rules that move work back into CAD # Each of these exists because its absence cost real time on this project.