From webcam hand tracking to control signals for a simulated robot arm.
Jim Seelan · 3-hour session · a kickoff, five technical modules + wrap-up
?view=scroll to the URL?print-pdf, then File → Print → Save as PDF| Part | Content | When to read |
|---|---|---|
| 1 — Concept foundations | 11 concepts with plain-language explanations, analogies, references, self-check | Before or after the session |
| 2 — The lab | Every module: scripts, exercises, diagrams, speaker notes | During and after |
| 3 — After today | 9 capstone projects across laptop and hardware tiers | After the session |
| 4 — Quick reference | Key numbers, all demo scripts, common issues, fallback behaviours | Keep open during the session |
Eleven concepts that underpin everything in the lab. Each has a plain-language explanation, an analogy, and references to go deeper. Reading on your own? Start here before Part 2. In a live session? Your facilitator will tell you where to begin — usually Part 2 directly.
You give it an image, it returns a label. The world does not change because of what it said.
Examples: image classifier, chatbot, recommendation engine.
The agent acts → the world changes → those changes become the next input. A continuous conversation with the physical world.
Examples: robot arm, self-driving car, drone.
Reading a book is static — the book doesn't change based on what you understood. Having a conversation is dynamic — what you say changes what the other person says next. Physical AI systems are always in a conversation with the physical world.
cap.read() # returns numpy array, shape (480, 640, 3)
# 480 rows × 640 columns × 3 (B, G, R values)
# 640 × 480 × 3 colour-channel values
For this exercise, we’ll use hand landmarks and joint angles instead of feeding the raw image into the controller. The perception layer compresses this down to 3 or 4 joint angle values.
Camera frame (480×640×3 ints)
→ MediaPipe model
→ 21 landmark (x, y, z) coordinates
→ dot-product angle formula
→ 3–4 joint angles in degrees
→ numpy array shape (4,) float32 ← the state vector
Training from scratch: millions of images, weeks of GPU, deep CV expertise.
We use Google's model (hand_landmarker.task — 7.5 MB) and run inference on it.
The model was trained elsewhere. We just use it.
Landmark at x=0.5, y=0.3 means: halfway across the frame, 30% down from top. Always in [0.0, 1.0] regardless of camera resolution. Code is resolution-independent.
detect_async() → model calls your on_result callback when done.
Main loop keeps reading camera while model processes the previous frame in a background thread.
queue.Queue(maxsize=1) safely passes results between threads.
Neural networks require fixed-size inputs. The state vector must always have the same number of elements. CartPole: always (4,). Reacher: always (11,). Non-negotiable.
Include the measurements the agent needs for this task, and keep the observation format consistent. CartPole: cart position, cart velocity, pole angle, pole angular velocity. Not the pixel render. Not the time elapsed. Just those 4 numbers.
| Context | Shape | Contents |
|---|---|---|
| M1 exercise result | (4,) float32 | Index, middle, ring, thumb MCP (knuckle) angles |
| CartPole observation | (4,) float32 | cart pos, cart vel, pole angle, pole vel |
| Reacher observation | (11,) float64 | Joint cos/sin, target pos, velocities, fingertip pos + dist |
| M4 action | (2,) float32 | Joint 0 torque, joint 1 torque |
A standardised interface — the same three calls — for hundreds of different environments. Whether you're training on CartPole or a humanoid robot, your RL code stays the same. Only the environment changes.
obs, info = env.reset()
obs, reward, done, trunc, info = env.step(action)
env.close()
render_mode="rgb_array", each env.render() call returns
the simulation frame as a numpy array (height × width × 3 RGB) instead of opening a display window.
The FastAPI server in api/server.py uses this: render → encode to JPEG →
stream over HTTP to your browser. No display hardware, GPU, or desktop required.
| Symbol | Name | CartPole example |
|---|---|---|
| S | State space | All possible (cart_pos, cart_vel, pole_angle, pole_vel) combinations |
| A | Action space | {0, 1} — push left or push right |
| R(s,a) | Reward function | +1 if pole upright; episode ends if it falls or pole tilts beyond 12° or cart moves beyond 2.4 m |
| T(s′|s,a) | Transition function | Physics of the system — deterministic for CartPole |
| γ | Discount factor | 0.99 — near rewards worth slightly more than far rewards |
For CartPole: knowing current (position, velocity, angle, angular_velocity) is sufficient to predict the next state. No history needed. This is what makes the problem mathematically tractable.
Naive policy gradient methods: update too aggressively → policy "collapses" → unrecoverable. PPO's fix: a clipped objective function that constrains how much the policy can change in one update. "Proximal" = stay close to where you were.
n_steps timesteps, collect (s, a, r, s′)| Parameter | What it controls | Default |
|---|---|---|
n_steps | Timesteps collected per rollout before an update | 2048 |
batch_size | Mini-batch size for each gradient update | 64 |
clip_range | Max policy change per update (the "proximal" constraint) | 0.2 |
gamma | Discount factor γ — how much future rewards are worth | 0.99 |
Your hand moves slightly
→ camera captures new frame
→ MediaPipe detects landmark 8 at (x=0.6, y=0.4)
→ scale_landmark_to_action → action = [0.2, -0.2]
→ env.step([0.2, -0.2]) → Reacher arm moves
→ env.render() → new frame sent to browser
→ you see the arm respond
→ your hand adjusts
→ the person sees the result and adjusts their hand
The update rate and end-to-end latency depend on the laptop, camera, rendering path and browser. Treat any frame-rate or latency figure as an estimate unless it has been measured on the setup being used. Here, the person closes the feedback loop by watching the simulated arm and adjusting their hand.
Gemini is a vision-language model (VLM) that accepts images and text. It can return an interpretation or structured response based on the image and prompt, but its outputs can be wrong and need checking.
In this lab: camera image + prompt → proposed JSON response (not executed by Reacher)
| System | Organisation | What it does |
|---|---|---|
| RT-2 | Google DeepMind | VLA on real robot hardware |
| OpenVLA | Stanford / Berkeley | Open-source VLA model |
| π0 | Physical Intelligence | Dexterous manipulation VLA |
| Our M5 | This lab | Camera → Gemini → proposed JSON response (not executed) |
2x − 1 do, and why is it the right formula for Module 4?render_mode="rgb_array" do, and why does the lab hub need it?terminated and truncated in env.step()?A kickoff, five technical modules, and a wrap-up. Each technical module introduces a concept or tool used in the lab. In Module 4, your hand controls a simulated robot arm in real time. That's the point.
| Module | Duration | What you build |
|---|---|---|
| M0 — Kickoff | 15 min | Draw the pipeline. Print your first state vector. |
| M1 — Perception | 30 min | Webcam → 21 landmarks → 4 joint angles → state vector |
| M2 — Simulation | 20 min | Step environments, explore Reacher's observation space |
| M3 — RL | 30 min | Run PPO training on CartPole and inspect the reward curve; learning varies by run. |
| M4 — Perception to action | 40 min | Use hand tracking to control a simulated robot arm. |
| M5 — Foundation Models | 25 min | Gemini returns a proposed structured response from a camera image |
| M6 — Wrap-up | 15 min | Map everything to real-world research tools |
We're not building from scratch. We're wiring together pre-trained pieces. The glue code is what you're writing today.
modules/00_kickoff/overview.ipynb # kernel: Physical AI Lab — run all cells
import numpy as np
print(np.array([145.2, 163.8, 171.4, 158.0], dtype=np.float32))
# [145.2 163.8 171.4 158.0]
# That printed line is a state vector.
Try it yourself first. Then refer back to the pipeline diagram (slide 2) and name which module handles each stage.
A webcam frame contains many pixel values. MediaPipe’s hand-landmark model returns 21 landmarks; selected landmarks can then be used to calculate joint angles. The angle formula compresses that to 4 joint angles. That's your state vector.
python modules/01_perception/01_webcam_basics.py
# Prints frame shape: (480, 640, 3)
python modules/01_perception/02_hand_tracking.py
# 21 green dots appear. Hold your hand up.
python modules/01_perception/03_joint_angles.py
# Prints [145.2, 163.8, 171.4] once per second
# "That is a state vector."
mediapipe.tasks.vision.HandLandmarker.
The older mp.solutions.hands still appears in most tutorials but is deprecated.
Key: coordinates already normalised to [0,1].
fallback_hand_demo.mp4 instead of live input. This keeps the walkthrough moving, but the landmarks no longer represent the current camera scene.
03_joint_angles.py, add landmarks 13→14 to produce a 4-element state vector.
Allow ~10 min. Ask 1–2 volunteers to share output.
import queue
import mediapipe as mp
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision as mp_vision
result_queue = queue.Queue(maxsize=1) # holds the latest result only
def on_result(result, image, timestamp):
# Called by MediaPipe's background thread when a frame is processed
# We put it in the queue — the main loop picks it up
if result_queue.full():
result_queue.get_nowait() # drop the stale result
result_queue.put_nowait(result)
# Build the landmarker once — reused for every frame
options = mp_vision.HandLandmarkerOptions(
base_options=mp_python.BaseOptions(model_asset_path="assets/hand_landmarker.task"),
running_mode=mp_vision.RunningMode.LIVE_STREAM,
result_callback=on_result # ← our function gets called here
)
landmarker = mp_vision.HandLandmarker.create_from_options(options)
cap = cv2.VideoCapture(0) # open webcam
while True:
ret, frame = cap.read() # BGR frame from camera
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB,
data=cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
landmarker.detect_async(mp_image, timestamp_ms) # non-blocking — returns immediately
# Main loop continues here — doesn't wait for MediaPipe
if not result_queue.empty():
result = result_queue.get_nowait()
lm = result.hand_landmarks[0] # list of 21 landmarks
x = lm[8].x # index fingertip x, in [0, 1]
# → compute angles, build state vector, etc.
queue.Queue(maxsize=1) bridges the two threads safely — it always holds the latest result,
never more than one, so you never act on a stale frame.
CartPole: 4 observations, 2 discrete actions. Reacher: 11 observations, 2 continuous torques. Those torques are what your hand controls in 20 minutes.
python modules/02_simulation/01_gym_intro.py
# Observation space: Box(-4.8, 4.8, (4,), float32)
# Action space: Discrete(2)
python modules/02_simulation/02_mujoco_reacher.py
# Observation space: Box(-inf, inf, (11,), float64)
# Action space: Box(-1.0, 1.0, (2,), float32)
observation_space.low and observation_space.high for Reacher.
Understand the bounds before you try to send actions. ~8 min.
python modules/02_simulation/03_pretrained_agent.py before the lab starts.
It loads a pre-trained policy and runs HalfCheetah (a MuJoCo humanoid) for 5 seconds.
When participants ask "what does a million training steps look like?" — that video is the answer.
Don't run this live; it takes 30 seconds to load and derails the pace.
import gymnasium as gym
import numpy as np
env = gym.make("CartPole-v1") # create the environment
obs, info = env.reset() # reset to start state, get first observation
# obs is a numpy array: [cart_pos, cart_vel, pole_angle, pole_vel]
print(obs.shape) # → (4,)
for step in range(200): # run for up to 200 timesteps
action = env.action_space.sample() # random action: 0 (left) or 1 (right)
obs, reward, terminated, truncated, info = env.step(action)
# obs : new state after the action
# reward : +1.0 if the pole is still upright
# terminated : True if pole fell or cart went out of bounds
# truncated : True if we hit the max episode length (500 steps for CartPole)
if terminated or truncated:
obs, info = env.reset() # start a new episode
env.close()
python modules/03_rl/02_train_cartpole.py
# ~2–3 min. Keep matplotlib window visible.
# Walk the MDP cycle diagram while it trains.
# STOP TALKING when the curve climbs — let everyone watch.
total_timesteps and re-run. ~10 min. Watch the curve shape change.
import gymnasium as gym
from stable_baselines3 import PPO
from stable_baselines3.common.callbacks import BaseCallback
env = gym.make("CartPole-v1")
# Create the PPO agent
# "MlpPolicy" = a Multi-Layer Perceptron network (2 hidden layers of 64 neurons)
# The network takes the 4-element CartPole state and outputs action probabilities
model = PPO(
"MlpPolicy",
env,
verbose=1, # print training progress every n_steps
n_steps=2048, # collect this many timesteps before each update
batch_size=64, # mini-batch size for gradient updates
gamma=0.99, # discount factor
)
# .learn() runs the entire training loop:
# 1. Run policy for n_steps, collect (obs, action, reward, next_obs) tuples
# 2. Compute advantage (GAE) for each action
# 3. Update network weights using the clipped objective
# 4. Repeat until total_timesteps reached
model.learn(total_timesteps=50_000)
model.save("models/ppo-CartPole-trained") # save for later
total_timesteps=10_000 — curve barely climbs. Pole falls after ~50 steps.total_timesteps=50_000 — default. Curve climbs around 20–30k. Pole holds for 500 steps.total_timesteps=200_000 — converges faster and more reliably. Diminishing returns after ~80k.python modules/04_perception_to_action/01_hand_to_reacher.py
# Two windows: camera + Reacher sim.
# Move your hand slowly.
# x position → joint 0 torque. y position → joint 1 torque.
action = 2 * norm_x − 1
That's the entire "intelligence" of the Module 4 controller.
Landmark x in [0, 1] → torque in [−1, +1].
ActionSliceWrapper is provided.
It wraps Reacher's 2-element action space so you can pass a 3-element array and it slices the first two
elements for the actual env.step() call — your extra channel controls a visual indicator
without breaking the environment interface.
import queue, cv2, gymnasium as gym
# (MediaPipe setup identical to M1 — same async pattern)
result_queue = queue.Queue(maxsize=1)
def on_result(result, image, timestamp):
if result_queue.full(): result_queue.get_nowait()
result_queue.put_nowait(result)
# ── MediaPipe setup (same 4 lines as M1 — see "M1 — How it works") ───────────
options = mp_vision.HandLandmarkerOptions(
base_options=mp_python.BaseOptions(model_asset_path="assets/hand_landmarker.task"),
running_mode=mp_vision.RunningMode.LIVE_STREAM,
result_callback=on_result)
landmarker = mp_vision.HandLandmarker.create_from_options(options) # async
env = gym.make("Reacher-v5", render_mode="rgb_array")
obs, _ = env.reset()
cap = cv2.VideoCapture(0)
# ── The closed loop ─────────────────────────────────────────
while True:
ret, frame = cap.read()
landmarker.detect_async(mp_image_from(frame), timestamp_ms) # non-blocking
if not result_queue.empty():
result = result_queue.get_nowait()
lm = result.hand_landmarks[0]
# The one line that closes the loop:
action = [2 * lm[8].x - 1, # landmark 8 x → joint 0 torque
2 * lm[8].y - 1] # landmark 8 y → joint 1 torque
obs, reward, terminated, truncated, _ = env.step(action)
# env.step() advances the simulation by one timestep
# The robot arm moves. env.render() will show the new frame.
if terminated or truncated:
obs, _ = env.reset()
# Display both windows, handle quit key...
env.step(action) changes the Reacher simulation. The new frame is rendered and shown to you; you adjust your hand in response, producing a new landmark position and torque command. The person supplies the feedback in this exercise — the script does not independently measure the arm state and correct its own action.
python modules/05_foundation_models/01_gemini_vision.py
# Captures one frame, sends "Describe this scene." ~2 sec.
python modules/05_foundation_models/02_gemini_robot_brain.py
# Returns a JSON-style dict with an action, observation, reason and next step
MY_PROMPT. Different prompts → different responses.
How you phrase the question dramatically changes reliability of the output.
cached_responses.json lets the exercise continue when the API is unavailable, but a cached response is not a safety-oriented fallback controller. Real systems need failure handling appropriate to their hardware and application.
# This mirrors the current demo's action vocabulary and response fields.
# It is a teaching prompt, not a dependable assistive-robot policy.
SYSTEM_PROMPT = (
"You are ARIA, an assistive robot in a workspace. "
"Observe the image and respond ONLY in JSON with: "
'{"action":"APPROACH|WAIT|ALERT|RETREAT","confidence":0.0,'
'"observation":"...","reason":"...","next_step":"..."}'
)
# Exercise: change one instruction or action definition at a time.
# Compare the returned JSON fields and discuss where the model may be wrong.
# Do not treat the confidence field as calibrated certainty or use this
# prompt to make real decisions about a person's wellbeing.
| Module | Pipeline stage | Real-world equivalent |
|---|---|---|
| M1 — Perception | Webcam → State Vector | Industrial vision systems; MediaPipe in production robot demos |
| M2 — Simulation | State → Action → World | MuJoCo used in Boston Dynamics, DeepMind, academic labs |
| M3 — RL | Training the policy | PPO in real locomotion controllers and game-playing agents |
| M4 — Human-in-the-loop teleoperation | Hand tracking to simulated joint torques | Can support demonstration collection when actions and observations are recorded |
| M5 — Foundation Models | Image → Gemini → proposed JSON action | A simplified prompt-based example, distinct from trained VLA systems such as RT-2, OpenVLA and π0 |
Nine project ideas that explore feedback between perception and action. Choose one that fits your available hardware and experience, then start by getting the perception step working.
| # | Project | Level | Extends |
|---|---|---|---|
| 1 | Adaptive Posture Coach — Webcam detects shoulder/neck landmarks. Agent adapts alert timing from your correction history — not a fixed threshold. | Beginner | M1 · M3 |
| 2 | Sim-to-Sim Transfer — Train PPO on CartPole, deploy into modified env (heavier pole, wind noise). Measure degradation. Apply domain randomisation. | Intermediate | M2 · M3 |
| 3 | Behaviour Cloning Agent — Use hand control (M4) to play a Pygame environment. Record (obs, action) pairs. Train an MLP clone. Observe covariate shift.
Pygame is a simple Python game library — easier than MuJoCo for custom control tasks. Covariate shift: the cloned agent encounters states it never saw during your demonstrations and fails unpredictably. Record where the system fails and use those cases to understand its limitations. |
Intermediate | M1 · M4 · M5 |
| 4 | Sign Language Recogniser + Corrective Loop — Real-time letter recognition from landmarks. Model confidence feeds back to prompt the user to re-sign ambiguous letters. | Beginner–Int | M1 · M5 |
| 5 | PID Tuner via RL — Simulated pendulum or water level. Use RL to learn PID gain parameters as the action space. Compare learned vs hand-tuned. | Intermediate | M2 · M3 |
| 6 | Multi-Step VLA Planner — Give Gemini a verbal instruction. It decomposes into sub-goals. Each executed via the M4 pipeline. Chain 3+ steps. | Intermediate | M4 · M5 |
Hardware cost is an estimate and depends on what you already own; check the bill of materials before buying. Projects 7–8 are starting points for experimenting with sensors and actuators on real hardware.
| # | Project | Level |
|---|---|---|
| 7 |
Gaze-Tracked Servo Turret — Pi Camera + MediaPipe detects face or hand.
Two SG90 servos pan/tilt to track. Apply the M4 mapping (2x−1) directly to servo angle.
Servo moves → camera points somewhere new → perception updates → next command. Physically broken without the loop. |
Intermediate |
| 8 |
Imitation Learning Arm — 2-servo arm on Pi. Record (landmark state, servo angle) demonstrations. Train small MLP to replicate. Deploy. Document where it drifts.
Covariate shift is physically observable — the arm drifts into states it never saw. The failure mode is the educational content. |
Int–Advanced |
| 9 |
RL Balancer — Train PPO on MuJoCo pendulum. Deploy onto physical pendulum with Pi + servo + encoder. Measure the sim-to-real gap quantitatively. 4–6 week project.
This project introduces control and sim-to-real challenges studied in robotics research. |
Advanced |
Key numbers, demo scripts, exercises at a glance, common issues. Keep this open during the session.
# Key landmarks
lm 0 = wrist
lm 4 = thumb tip (M4 exercise)
lm 8 = index tip (M4 control)
lm 13 = ring MCP (M1 exercise)
lm 14 = ring PIP (M1 exercise)
# State shapes
M1 exercise : (4,) float32
CartPole obs : (4,) float32
Reacher obs : (11,) float64
M4 action : (2,) float32
# Training
50k steps ≈ 2–3 min on laptop
CartPole learning varies by run; inspect the reward curve rather than expecting a fixed step count
# CartPole terminal conditions
|pole angle| > 12° → episode ends
|cart pos| > 2.4m → episode ends
max reward = 500 (500 steps)
# Reacher action space
torque ∈ [−1, +1] for each joint
# M4 mapping
action = 2 * landmark_x − 1
# MDP components
S = state space
A = action space
R = reward function
T = transition function
γ = discount factor (0.99)
# Start the hub
.venv\Scripts\activate
python api/server.py
# → http://localhost:8000
# Verify install
python verify_install.py
# All 14 should be PASS or WARN
# M1 — Perception
python modules/01_perception/01_webcam_basics.py
python modules/01_perception/02_hand_tracking.py
python modules/01_perception/03_joint_angles.py
# M2 — Simulation
python modules/02_simulation/01_gym_intro.py
python modules/02_simulation/02_mujoco_reacher.py
# M3 — RL
python modules/03_rl/02_train_cartpole.py
# M4 — Loop closes
python modules/04_perception_to_action/01_hand_to_reacher.py
# M5 — Foundation Models
python modules/05_foundation_models/01_gemini_vision.py
python modules/05_foundation_models/02_gemini_robot_brain.py
# Wrap-up / concepts map
# Open in VS Code or: jupyter notebook modules/06_wrapup/concepts_map.ipynb
| Module | Exercise | Time |
|---|---|---|
| M0 | Print a state vector: np.array([145.2, 163.8, 171.4, 158.0], dtype=np.float32) | 3 min |
| M1 | Add ring finger angle (landmarks 13→14) to make a 4-element vector | 10 min |
| M2 | Print observation_space.low and observation_space.high for Reacher | 8 min |
| M3 | Change total_timesteps and re-run. Watch the curve shape change. | 10 min |
| M4 | Add landmark 4 (thumb tip) as a 3rd torque channel via ActionSliceWrapper | 15 min |
| M5 | Write your own MY_PROMPT. Observe how phrasing changes output reliability. | 10 min |
| Symptom | Fix |
|---|---|
| MediaPipe window black | assets/hand_landmarker.task missing — re-run setup.bat |
| MuJoCo window doesn't open | Normal — render fallback active. Check terminal for rgb_array message. |
| Reward curve never climbs | Check the training log and let the run finish. Learning curves vary with seed, settings and machine. |
| Port 8000 in use | netstat -ano | findstr :8000 → taskkill /PID [n] /F |
| Hub page blank | Chrome or Edge — not Firefox. |
| Gemini 429 rate limit | Cache fallback activates automatically. Continue. |
| ARM64 / mujoco fails | WSL2: wsl --install (restart) → run setup.sh inside Ubuntu |
| [WARN] Camera unavailable | A prerecorded video may be used instead of live input; perception results no longer reflect the current camera scene. |
| What fails | What happens automatically |
|---|---|
| Webcam unavailable | Uses a prerecorded demo video instead of live camera input; perception is no longer live |
| OpenGL / display | make_env tries "human" → "rgb_array" → None |
| Gemini key missing | Random entry from cached_responses.json |
| Gemini API call fails | Any exception → cache fallback |
| No internet during session | Core exercises use bundled assets; Gemini calls are unavailable and the exercise uses cached responses where configured |
You’ve worked through several building blocks of a Physical AI system: perception, simulation, reinforcement learning and a model-based controller. Each is a small example of a much larger field.