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

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

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 7: This Article
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.

Numerator — the distance travelled away from straight. At \(\bar\theta = 2.35\): \(3.10 - 2.35 = 0.75\) rad.

Division — the percentage. \(0.75 / 1.50 = 0.5\): half the range used.

Why “backwards”
#

Textbook min-max normalization is \((x - \min)/(\max - \min)\). This one subtracts from the maximum, because the interior angle (Part 5) runs opposite to the output we want:

Finger Interior angle Wanted flexion
Open large (3.10) 0.0
Closed small (1.60) 1.0

Check the extremes:

$$\frac{3.10 - 3.10}{1.50} = 0.0 \qquad \frac{3.10 - 2.35}{1.50} = 0.5 \qquad \frac{3.10 - 1.60}{1.50} = 1.0$$

Why clip
#

Real hands overshoot the window: hyperextended fingers exceed 3.10 rad, a hard fist dips under 1.60. Unclipped, flexion leaves \([0, 1]\) and the force mapping would command beyond the actuator’s range. MuJoCo would clamp it anyway (ctrlrange), but clipping at the source keeps the topic honest for every other subscriber.

A side effect you get for free: the flat regions of the chart are small dead zones at both ends, which hide jitter when the hand is already fully open or closed.

Where 3.10 and 1.60 come from
#

They are measured, not derived — read off a live system with one person’s open hand and fist in front of one webcam. They silently absorb:

  • that person’s hand anatomy,
  • MediaPipe’s model bias (a straight finger rarely reads exactly \(\pi\)),
  • the aspect-ratio skew of normalized coordinates (Part 4),
  • the orientation the hand is usually held at.

Change any of those and the window drifts. A recorded live session shows exactly how far: that operator’s fist only reached flexion 0.67–0.76Part 9.

Normalize here, interpolate there
#

Normalization and linear interpolation are inverses, and the pipeline puts one on each side of the network:

Step Operation In → out Runs in
Vision normalize radians → 0..1 vision_tracker
Actuation lerp 0..1 → newtons mujoco_twin

Sending 0..1 over ROS 2 is what keeps the sides independent: recalibrate vision without touching the simulator, retune the simulator (or swap in real servos) without touching vision. Part 10 is the other half.

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

Related

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.

From finger flexion to tendon force: linear interpolation onto a MuJoCo motor

On the far side of the ROS 2 topic, five flexions arrive and five tendon motors wait. One line of linear interpolation connects them — plus a name lookup that can fail silently, and a string that is allowed to push. The formula # $$F(t) = F_\text{open} + t\,(F_\text{closed} - F_\text{open}) = 50 + t\,(-50 - 50) = 50 - 100\,t$$FORCE_OPEN = 50.0 FORCE_CLOSED = -50.0 def _lerp(self, start_val, end_val, t): return start_val + t * (end_val - start_val) def apply_flexions(self, flexions): for finger, flexion_amount in flexions.items(): target_force = self._lerp(FORCE_OPEN, FORCE_CLOSED, flexion_amount) self.data.ctrl[self.motors[finger]] = target_force Three parts of one line # With \(t = 0.75\), a finger 75% closed:

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

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

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.

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.