Architecture¶
The project is four independent pieces connected by two narrow interfaces (a serial line, and a pub/sub message bus for the simulation). Each piece can be developed and tested on its own.
flowchart TD
subgraph PC["PC: robohand conda env"]
direction TB
A([Webcam]) --> B[MediaPipe HandLandmarker]
B --> C[Angle math + EMA smoothing]
C --> D["Serial line\nthumb,index,middle,ring,pinky"]
C --> E["CSV curl values 0-1\nover a pipe"]
end
subgraph Ard["Arduino: planned, not yet built"]
direction TB
F[hand_controller.ino] --> G[5x Servo.write]
end
subgraph Sim["Docker container: robotics_gazebo_sim"]
direction TB
H[gz_hand_bridge.py] --> I["Gazebo Transport\n/cmd_pos topics"]
I --> J[Simulated hand model]
end
D -.-> F
E --> H
G -.-> K(["Real tendons pull,\nfingers curl"])
classDef active fill:#26a69a,stroke:#00695c,color:#fff
classDef planned fill:#eceff1,stroke:#90a4ae,stroke-dasharray:4 3,color:#546e7a
class A,B,C,D,E,H,I,J active
class F,G,K planned
Why it's split this way¶
The vision code doesn't know about servos, and the firmware doesn't know about MediaPipe. The interface between them is a plain serial line carrying 5 numbers, that's it. This means:
- You can test the vision pipeline with zero hardware (
--no-serial). - You can test the firmware/servos with a serial terminal, typing angles by hand, no webcam needed.
- The simulation is a third consumer of the same tracked-angle data, not a special case bolted onto the vision code. See why the sim bridge is a separate process.
The four pieces¶
| Piece | Where | Runs in |
|---|---|---|
| Vision (hand tracking + angle math) | vision/ |
robohand conda env, on the host |
| Firmware (servo control) | firmware/hand_controller/ |
Arduino |
| Simulation bridge | sim/bridge/gz_hand_bridge.py |
Inside the robotics-gazebo Docker container |
| Simulation world/model | sim/worlds/, sim/models/ |
Gazebo, inside the same container |
Data flow, step by step¶
- Webcam frame → landmarks. MediaPipe's
HandLandmarkerreturns 21 (x, y, z) points per detected hand: wrist, and 4 joints per finger. See Vision Pipeline. - Landmarks → angle per finger. For each finger, the angle at the middle joint (wrist→MCP→tip for four fingers, base→joint→tip for the thumb) is computed with the law of cosines. 180° = fully straight, smaller = more curled.
- Angle → smoothed angle. Raw landmark positions jitter slightly frame to frame even when your hand is perfectly still. An exponential moving average (EMA) filters that out before anything is sent downstream.
- Angle → command. Two consumers read the smoothed angle differently:
- For the real hand: mapped to a servo angle (0-180) and sent as a comma-separated line over serial, once per frame.
- For the simulation: converted to a 0.0-1.0 "curl fraction" and streamed to the Gazebo bridge process as CSV.
- Command → motion. The Arduino parses the serial line and calls
Servo.write()per finger. The Gazebo bridge publishes each curl value to that finger's/cmd_postopic, which aJointPositionControllerplugin in the sim world picks up.
Why two different value ranges for the same data?¶
Servos take 0-180 (degrees); Gazebo's joint controllers in this world are wired for a 0.0-1.0 curl
fraction to keep the sim math independent of any particular servo's physical range. Both are
derived from the same smoothed angle in vision/hand_tracker.py: curl_fraction() and
angle_to_servo() are two small pure functions off the same input, not two separate tracking
pipelines.