Vision Pipeline¶
Code: vision/hand_tracker.py
What it does, for non-programmers¶
The webcam sees a picture. A machine-learning model (Google's MediaPipe) has been trained on huge numbers of hand photos and can point out where your knuckles and fingertips are in that picture, 21 points per hand, updated ~30 times a second. From those points, some geometry figures out how bent each finger is, and that bend gets turned into a number sent to the hand.
The MediaPipe model¶
This project uses MediaPipe's Tasks API (HandLandmarker), not the older mp.solutions.hands
API you'll see in a lot of older tutorials. mp.solutions.hands was removed entirely in the
mediapipe version this project pins. The Tasks API needs an explicit model file
(vision/models/hand_landmarker.task) rather than something bundled in the pip package; see
Getting Started for how to fetch it.
HandLandmarker returns 21 landmarks per detected hand. Landmark indices follow MediaPipe's
standard hand topology: index 0 is the wrist, 4 is the thumb tip, 8 the index tip, and so on.
Turning landmarks into a finger angle¶
For each finger, three landmarks are picked (roughly "knuckle," "middle joint," and "tip"), and the angle at the middle joint is computed:
FINGER_JOINTS = {
"thumb": (2, 3, 4),
"index": (5, 6, 8),
"middle": (9, 10, 12),
"ring": (13, 14, 16),
"pinky": (17, 18, 20),
}
The math is the law of cosines applied to the two line segments meeting at that middle joint (wrist→joint and joint→tip, or thumb-base→joint→tip for the thumb, since the thumb's geometry is different from the other four fingers). The result is an angle in degrees:
- ~180° = finger fully straight (the two segments point in opposite directions from the joint).
- Smaller angle = more curled (the tip has swung around toward the wrist).
This is a genuine angle measurement, not a lookup table or a trained classifier. It works for any hand size or shape because it's pure geometry on the tracked points.
Smoothing¶
Raw landmark positions are noisy: hold your hand perfectly still and the reported angle still wobbles a degree or two, frame to frame. That wobble would make the servos twitch. It's damped with an exponential moving average:
smoothed_angles[f] = smoothing * new_angle[f] + (1 - smoothing) * smoothed_angles[f]
--smoothing (default 0.3) controls the weight on each new reading:
- Lower (e.g.
0.1) → smoother motion, but more lag between your real hand and the robot hand. - Higher (e.g.
0.7) → snappier response, but more jitter gets through.
The smoothing state is reset whenever the hand tracker loses your hand (goes out of frame, bad lighting, etc.). Otherwise, on reacquiring your hand, it would blend the new (correct) pose with a stale old one and lag noticeably before catching up.
From angle to output value¶
Two small pure functions turn a smoothed angle into what gets sent downstream:
def curl_fraction(angle, straight=170, curled=40):
"""0.0 = fully curled, 1.0 = fully straight."""
...
def angle_to_servo(angle, servo_open=180, servo_closed=0):
"""Map a measured joint angle to a servo command (0-180)."""
...
curl_fraction clamps the raw angle into the [curled, straight] degree range you'd actually see
from a human hand, then normalizes it to 0.0-1.0. angle_to_servo reuses that fraction to produce
a 0-180 servo command. The Gazebo simulation consumes the curl_fraction value
directly (1.0 - curl_fraction, since the sim's convention is 0=straight/1=curled, the opposite
of the servo's "fraction toward open"); the Arduino consumes the servo command.
Output¶
Every frame, if a hand is detected:
- A line like
120,45,50,48,140\n(servo angles for thumb,index,middle,ring,pinky) is written to the serial port, if connected. - A line of comma-separated curl fractions (0.0-1.0) is streamed to the Gazebo bridge, if
--gazebois set.
Both are derived from the same smoothed-angle dict. See Architecture for why that matters.