Examples / Camera Pose

Camera Pose

Run MediaPipe pose detection on live ESP32 camera video — green landmarks and a skeleton track a person moving in front of the lens.

AdvancedVisionp5.js
Screenshot / video of this example — coming soon

Camera Pose

Runs Google's MediaPipe Pose Landmarker on the live MJPEG video from an ESP32 camera — green dots track a person's body landmarks and white lines connect them into a skeleton, all in a p5.js canvas.

It's the camera-stream example with pose detection layered on top: instead of pointing the pose model at a webcam (createCapture(VIDEO)), it points it at the board's video stream.


Arduino
camera-stream.ino — the Arduino side
#define CAMERA_MODEL_WROVER_KIT
// #define CAMERA_MODEL_ESP_EYE
// #define CAMERA_MODEL_M5STACK_PSRAM
// #define CAMERA_MODEL_M5STACK_V2_PSRAM
// #define CAMERA_MODEL_M5STACK_WIDE
// #define CAMERA_MODEL_M5STACK_ESP32CAM
// #define CAMERA_MODEL_M5STACK_UNITCAM
// #define CAMERA_MODEL_M5STACK_CAMS3_UNIT
// #define CAMERA_MODEL_AI_THINKER
// #define CAMERA_MODEL_TTGO_T_JOURNAL
// #define CAMERA_MODEL_XIAO_ESP32S3
// #define CAMERA_MODEL_ESP32_CAM_BOARD
// #define CAMERA_MODEL_ESP32S3_CAM_LCD
// #define CAMERA_MODEL_ESP32S2_CAM_BOARD
// #define CAMERA_MODEL_ESP32S3_EYE
// #define CAMERA_MODEL_DFRobot_FireBeetle2_ESP32S3
// #define CAMERA_MODEL_DFRobot_Romeo_ESP32S3

#include <Pardalote.h>
#include <PardaloteCamera.h>

void setup() {
    Pardalote.begin();
}

void loop() {
    Pardalote.run();
}
JS
sketch.js — the browser side
const CAMERA_PORT = 82;

// Camera frame size — bigger is sharper but slower to stream and detect.
// Options: FRAMESIZE_QVGA 320×240 · FRAMESIZE_HVGA 480×320 · FRAMESIZE_VGA 640×480
//          · FRAMESIZE_SVGA 800×600 · FRAMESIZE_HD 1280×720 (needs PSRAM)
const FRAME_SIZE = FRAMESIZE_VGA;   // 640×480 — matches the canvas

// Flip the image left-to-right. A webcam selfie is usually mirrored.
// (Not called MIRROR — p5 already defines a MIRROR constant.)
const MIRROR_VIDEO = true;

// Pose model: 'lite' (fastest), 'full', or 'heavy' (most accurate, slowest).
const POSE_MODEL = 'lite';

// How many people to track at once.
const NUM_POSES = 1;

// Detections per second. Pose detection runs on the page's main thread, so
// capping it leaves time for drawing and for the Pardalote connection. The
// MJPEG stream rarely beats ~20 fps anyway.
const DETECT_FPS = 15;

// Each landmark carries a visibility score (0–1): how likely it is to be in
// frame and not hidden. Only landmarks (and bones) at least this visible are drawn.
const MIN_VISIBILITY = 0.5;

// MediaPipe Tasks Vision — pinned so a new release can't break the example.
const MP_VERSION = '0.10.14';
const MP_URL = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MP_VERSION}`;
const MODEL_URL = `https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_${POSE_MODEL}/float16/1/pose_landmarker_${POSE_MODEL}.task`;

let arduino;
let imgEl = null;                 // raw <img> holding the live MJPEG stream

// --- MediaPipe Pose Landmarker ------------------------------------------
let poseLandmarker = null;
let poseConnections = [];         // [{ start, end }] landmark index pairs
let modelError = null;
let lastDetect = 0;
let poses = [];                   // one array of 33 landmarks per person

function setup() {
    createCanvas(640, 480);

    // Load the model up front so it's warm by the time video arrives.
    loadPoseModel();

    arduino = new Arduino();
    arduino.add('cam', new Camera());
    setupConnection(arduino, { store: 'pardalote-camera-posenet' });

    // Start the camera once the board is ready (device state resets on reconnect).
    arduino.on('ready', () => {
        arduino.cam.setResolution(FRAME_SIZE);
        arduino.cam.attach(CAMERA_PORT);
    });
    arduino.cam.on('stream', ({ url }) => attachStream(url));
    arduino.on('disconnect', clearStream);
}

// import() works from a normal (non-module) script, so p5 global mode is kept.
async function loadPoseModel() {
    try {
        const { PoseLandmarker, FilesetResolver } = await import(MP_URL);
        const vision = await FilesetResolver.forVisionTasks(`${MP_URL}/wasm`);
        const options = (delegate) => ({
            baseOptions: { modelAssetPath: MODEL_URL, delegate },
            runningMode: 'VIDEO',      // track frame to frame — smoother than per-image
            numPoses: NUM_POSES,
        });
        try {
            poseLandmarker = await PoseLandmarker.createFromOptions(vision, options('GPU'));
        } catch (err) {
            console.warn('MediaPipe GPU delegate failed, falling back to CPU:', err);
            poseLandmarker = await PoseLandmarker.createFromOptions(vision, options('CPU'));
        }
        // Warm up on a blank frame. The very first detection sets up the GPU and
        // can freeze the page for several seconds — do it now, before the board
        // is connected, rather than on the first video frame (where the freeze
        // could look like a dead connection).
        const blank = document.createElement('canvas');
        blank.width = 64; blank.height = 48;
        poseLandmarker.detectForVideo(blank, performance.now());
        poseConnections = PoseLandmarker.POSE_CONNECTIONS;
    } catch (err) {
        console.error('Could not load MediaPipe Pose Landmarker:', err);
        modelError = err;
    }
}

// Build a fresh cross-origin <img> for the MJPEG stream. crossOrigin MUST be
// set BEFORE src, or the browser fetches without CORS and MediaPipe's pixel
// read throws a tainted-canvas SecurityError.
function attachStream(url) {
    clearStream();
    imgEl = new Image();
    imgEl.crossOrigin = 'anonymous';
    imgEl.src = url;
}
function clearStream() {
    if (imgEl) { imgEl.src = ''; imgEl = null; }
    poses = [];
}

// Run the model on the current stream frame, at most DETECT_FPS times a second.
// detectForVideo() is synchronous — results are ready as soon as it returns.
function detectPoses() {
    const now = performance.now();
    if (!poseLandmarker || now - lastDetect < 1000 / DETECT_FPS) return;
    lastDetect = now;
    try {
        poses = poseLandmarker.detectForVideo(imgEl, now).landmarks;
    } catch (err) {
        // e.g. a frame that hasn't finished decoding — just try again next time
        console.warn('Pose detection failed:', err);
    }
}

function draw() {
    background(0);

    if (imgEl && imgEl.naturalWidth > 0) {
        detectPoses();

        push();
        if (MIRROR_VIDEO) { translate(width, 0); scale(-1, 1); }
        drawingContext.drawImage(imgEl, 0, 0, width, height);   // stretch the frame to the canvas

        // Landmarks are 0–1 across the frame, so × width / height puts them on the canvas.
        for (let landmarks of poses) {
            noFill();
            stroke(255);
            strokeWeight(3);
            for (let { start, end } of poseConnections) {
                const a = landmarks[start], b = landmarks[end];
                // skip a bone unless both ends are visible, so it can't anchor to a jittery point
                if (a.visibility < MIN_VISIBILITY || b.visibility < MIN_VISIBILITY) continue;
                line(a.x * width, a.y * height, b.x * width, b.y * height);
            }

            fill(0, 255, 0);
            noStroke();
            for (let lm of landmarks) {
                if (lm.visibility < MIN_VISIBILITY) continue;
                circle(lm.x * width, lm.y * height, 10);
            }
        }
        pop();
    } else {
        // Waiting for stream — placeholder message (house cream card).
        fill('#F2E9D8'); noStroke();
        rect(0, 0, width, height);
        fill('#6d6a5f');
        textAlign(CENTER, CENTER); textSize(16);
        const msg = modelError         ? 'Pose model failed to load — see the browser console'
                  : !arduino.connected ? 'Connecting…'
                  : !poseLandmarker    ? 'Loading pose model…'
                  :                      'Starting camera…';
        text(msg, width / 2, height / 2);
        textAlign(LEFT, BASELINE);
    }

    // Connection status dot — top-right corner (teal = connected)
    noStroke();
    fill(arduino.connected ? '#3FA9A0' : '#D3542B');
    circle(width - 16, 16, 12);
}

What you need

The stream is served over HTTP straight from the board's IP, so this example needs a WiFi connection — USB carries only the control link, not video.


Files

File Purpose
index.html Loads p5.js, Pardalote, and the sketch
sketch.js Loads MediaPipe, connects, streams, runs pose detection, draws landmarks + skeleton
connect.js Board connection UI (WiFi / USB, remembered per browser)
style.css Pardalote example house style

Browser setup

Open index.html from a local web server (browsers block some connections from file:// pages — npx serve . works). In the Board row enter the board's IP and press Connect — the setting is remembered per browser. The model loads on its own; once video arrives, landmarks appear over it.

The settings live at the top of sketch.js:

JS
const FRAME_SIZE     = FRAMESIZE_VGA;   // 640×480 — matches the canvas
const MIRROR_VIDEO   = true;            // flip left-to-right, like a webcam selfie
const POSE_MODEL     = 'lite';          // 'lite' (fastest), 'full', or 'heavy' (most accurate)
const NUM_POSES      = 1;               // how many people to track
const DETECT_FPS     = 15;              // detections per second
const MIN_VISIBILITY = 0.5;             // hide landmarks the model can't really see

An outward-facing ESP32 camera usually shouldn't be mirrored — set MIRROR_VIDEO to false if movement looks backwards.


How it works

Loading MediaPipe

There's no <script> tag for MediaPipe. sketch.js loads the Tasks Vision library with import() — which works from an ordinary script, so p5's global mode keeps working — then builds a Pose Landmarker. It tries the GPU first and falls back to the CPU if the browser's GPU support fails:

JS
const { PoseLandmarker, FilesetResolver } = await import(MP_URL);
const vision = await FilesetResolver.forVisionTasks(`${MP_URL}/wasm`);
poseLandmarker = await PoseLandmarker.createFromOptions(vision, {
    baseOptions: { modelAssetPath: MODEL_URL, delegate: 'GPU' },
    runningMode: 'VIDEO',      // track frame to frame — smoother than per-image
    numPoses: NUM_POSES,
});

Straight after loading, the sketch runs one detection on a small blank canvas. The very first detection sets up the GPU and can freeze the page for several seconds; doing it at load time, before you press Connect, stops that freeze landing mid-stream, where it could look like a dropped board connection.

The library version is pinned (MP_VERSION) so a MediaPipe release can't break the example.

Reading the stream

The one real difference from a webcam is where the pixels come from:

JS
// A raw <img>, loaded cross-origin so MediaPipe may READ its pixels. crossOrigin
// MUST be set before src, or the pixel read throws a tainted-canvas error. This
// works because PardaloteCamera.h serves Access-Control-Allow-Origin: *.
imgEl = new Image();
imgEl.crossOrigin = 'anonymous';
imgEl.src = url;                    // the MJPEG stream URL, from cam.on('stream')

The stream isn't a <video>, so there's no event for "new frame". Instead draw() calls detectPoses(), which runs the model on whatever frame the <img> is showing, capped at DETECT_FPS:

JS
function detectPoses() {
    const now = performance.now();
    if (!poseLandmarker || now - lastDetect < 1000 / DETECT_FPS) return;
    lastDetect = now;
    poses = poseLandmarker.detectForVideo(imgEl, now).landmarks;
}

detectForVideo() is synchronous — the results are ready as soon as it returns. That also means it runs on the page's main thread, which is why it's capped: the rest of each second is left for drawing and for Pardalote's connection to the board.

Drawing the landmarks

Each person comes back as an array of 33 landmarks (nose, eyes, shoulders, elbows, wrists, hands, hips, knees, ankles, heels and toes — see MediaPipe's landmark map for which number is which). Each landmark has:

PoseLandmarker.POSE_CONNECTIONS lists the skeleton's bones as { start, end } landmark index pairs:

JS
for (let { start, end } of poseConnections) {
    const a = landmarks[start], b = landmarks[end];
    if (a.visibility < MIN_VISIBILITY || b.visibility < MIN_VISIBILITY) continue;
    line(a.x * width, a.y * height, b.x * width, b.y * height);
}

Because the positions are fractions of the frame, they don't depend on the stream's resolution — changing FRAME_SIZE needs no other edits.

The frame itself is drawn with the canvas's own drawingContext.drawImage() rather than p5's image(), because imgEl is a raw HTMLImageElement and p5's image() only accepts p5 image/element types.


Adjusting the stream

FRAME_SIZE accepts any of the FRAMESIZE_* constants — bigger is sharper but slower to stream and to run pose detection on. See camera-stream → Adjusting the stream for the full table and the resolution/PSRAM notes.

If detection lags, lower DETECT_FPS, use a smaller FRAME_SIZE, or stay on the 'lite' model. If tracking is too rough, try 'full'.


Troubleshooting

Video shows but no landmarks appear

"Pose model failed to load"

"MediaPipe GPU delegate failed, falling back to CPU" in the console

Landmarks are offset from the body

Stream itself won't appear / low frame rate / PSRAM errors


Next steps