Every change goes straight to core.setConfig() and is saved in this browser only. ◆ marks values that differ from the defaults.
Gestures are plain data: poses are hand shapes, movements are how the hand travels. Both take effect on the next frame and are saved with the config.
gesturecore-sound, an optional brick: gestures trigger short synthesized sounds, and a hand can play a sustained voice. The core works the same with this switched off. Browsers keep audio silent until you ask, so sound starts with the button.
gesturecore-head, an optional brick: it reads your face from the same camera — where your head points, nods and shakes, blinks and winks, raised brows, an open mouth, a smile. The hands work the same with it off. Switching it on downloads the face model (3.8 MB) and runs it on every frame.
Everything gesturecore ships with. All of it is plain data — edit or replace any of it in Gestures. A row lights up while that gesture is happening.
Test captures: a recorded hand the automated tests replay, so a change in the maths shows up
as a failing test instead of a surprise. Saving writes test/fixtures/<name>.json;
run npm test afterwards.
This does not add a gesture. To teach the bench a new hand shape so it fires events, use Gestures → Record a pose from your hand. Capture it here as well only if you want the tests to guard it.
A workbench for gesturecore, a small library that turns camera hand tracking into clean gesture events: pinches, poses, movements. The bench shows everything the library measures and decides, lets you tune every constant live, and lets you design new gestures without writing code.
pinch:start with the finger's name in the Events panel.motion swipeRight appears in the log.Every camera frame goes through the same one-way pipeline:
The outlined boxes are gesturecore itself. It has no idea there is a camera, a browser or a screen: it receives 21 points per hand and a time, and returns a list of events. That is what lets the same library drive a browser tool, a musical instrument, or run in tests with no camera at all. Everything else on this page (camera, drawing, panels, storage) is the bench, the first program using the library.
Gestures are geometry, not machine learning. MediaPipe does the hard part of finding the hand; gesturecore only measures angles and distances and applies thresholds and timers. Every decision is therefore explainable: the hand panels show the exact numbers each rule looks at.
Colour carries meaning here; it is never decoration. The same six colours mean the same thing on the video, in the bars, in the log and in these docs. Anything structural (frames, tabs, labels) stays neutral steel.
| Colour | Meaning | Where you see it |
|---|---|---|
| cyan | a live measurement | feature bars, smoothed skeleton, fps and timing readouts |
| amber | charging: a condition is met and a timer is running, nothing has fired yet | filling rings, dwell bars, the held pose, movement progress, a finger that is closed but not yet a pinch |
| green | active or fired: the gesture is real | engaged hand, pinching finger, fired pose and movement events, PASS |
| red | lost, failed, unintended | lost events, errors, the false-activation count, FAIL |
| white | a limit or value you set | threshold ticks on bars, slider handles, ◆ next to changed settings |
| grey | raw input, disabled, off | unsmoothed skeleton, disabled fingers, absent hands, pinch:move spam |
Left and right hands are told apart by their labels, never by colour, so colour can stay meaningful.
Two colours belong to the interface rather than to the data, so they can never be confused with a measurement:
The field is a set of slow waves that drift on their own and lean toward what your hands are doing:
| palm tilt | tilts the whole field |
| open hand / fist | wave height — a fist calms it flat |
| fingers extended | how tight the waves are |
| palm position | where the waves sit, and how fast they travel |
| which hand | Left pulls the colour violet, Right pulls it cyan |
| engaged / events | brightness, and a ripple on each pinch, pose or movement |
Every influence is smoothed heavily, so it reads as atmosphere, never as a readout — for numbers, use the hand panels. It repaints about sixteen times a second on a small canvas, stops when the tab is in the background, and stays still for anyone whose system asks for reduced motion.
You choose where it lives, in Source → Appearance: as the page background, as a panel of its own (Field, where it is drawn sharper and can be watched properly), as both, or as neither. Panels keep a dark scrim over it either way, so text stays readable whatever the waves do.
MediaPipe gives 21 points per hand. Tips are 4 8 12 16 20 (thumb → pinky), knuckles (MCP) 1 5 9 13 17, middle joints (PIP) 2 6 10 14 18.
Span is the wrist-to-middle-knuckle length. Every distance is divided by the span, so a pinch or a swipe measures the same whether your hand is near the camera or far away. Distances in these docs are therefore in hand-size units: 1.0 = one span.
| Feature | Range | What it is |
|---|---|---|
curl (×5) | 0 … 1 | How bent each finger is: the angle at its middle joint, 0 = straight, 1 = fully bent. The thumb uses joint 3 and is noisier. The angle→0…1 mapping is set by the finger straight / bent sliders. |
openness | 0 … 1 | 1 − the average curl of index…pinky. 0 = fist, 1 = all fingers straight. |
pinchRaws | 0 … ~1.5 | Thumb-tip to each fingertip distance, in spans. About 0.1 when touching. |
pinch | 0 … 1 | Thumb↔index distance rescaled between the closed and open sliders: 0 = touching, 1 = wide open. |
tilt | −180° … 180° | Palm rotation on screen: the direction from wrist to middle knuckle. 0 = fingers up, positive = leaning right. |
span | ~0.1 … 0.5 | Hand size on screen. Bigger = closer to the camera. |
centroid | 0 … 1 | Palm centre (wrist + four knuckles) as a fraction of the frame. Used by movements. |
MediaPipe's depth value z is noisy, so it is used only inside joint angles, never as a distance. Features are computed after smoothing.
Engagement is a safety catch. A hand in view is not trusted to do anything until it shows the engage pose (default: open palm, held 500 ms). Until then:
Once engaged, the hand stays engaged whatever it does, until it leaves the camera view (see Losing a hand). This is what stops typing, scratching your face or reaching for a mug from firing gestures.
On screen the outer ring fills amber while you hold the engage pose and turns green when engaged; the skeleton turns green too. Change the pose, or switch the catch off, in Tuning → engage pose.
For each enabled finger the library measures the thumb-tip distance in spans. With the default settings:
| Step | Rule |
|---|---|
| close | the nearest enabled finger comes below 0.20 (closed + hysteresis). Its bar turns amber and the ring appears. |
| settle | while the ring fills, the thumb may slide to a neighbouring finger; the nearest one is the candidate and the timer keeps running. |
| start | closed for 300 ms (dwellMs) and the hand is engaged → pinch:start with finger. That finger is now locked and turns green. |
| move | every following frame → pinch:move with the same finger and a 0…1 value. |
| end | that finger's distance goes above 0.30 (closed + 3×hysteresis), the hand is lost, or the finger is disabled → pinch:end. |
If the thumb opens again before the 300 ms are up, nothing fires and the timer resets. Enable or disable fingers in Tuning → pinch fingers.
A pose is a list of allowed curl ranges per finger. Fingers not listed are ignored.
{ "name": "fist", "fingers": {
"index": { "curl": [0.75, 1] }, "middle": { "curl": [0.75, 1] },
"ring": { "curl": [0.75, 1] }, "pinky": { "curl": [0.75, 1] } } }
minScore (default 0.8). Its name goes amber. Equal scores go to the more specific pose — the one constraining more fingers, then the one with narrower ranges — so a thumbs-up beats a fist, which says nothing about the thumb.pose event fires and the name turns green. It fires again only after the pose changes.Built in: fist, openPalm, point. Make your own in Gestures → Record a pose, which reads your real finger curls over 15 frames and saves ranges of curl ± tolerance. A larger tolerance is more forgiving but may overlap other poses; include the thumb only when it matters, such as a thumbs-up.
A movement describes how the palm centre travels. It fires once when satisfied, and only while engaged and not pinching, so a pinch-drag never doubles as a swipe.
| Field | Meaning |
|---|---|
axis | x sideways · y up/down · depth toward/away from the camera (the hand gets bigger or smaller) · tilt twisting the palm |
direction | 1 = right / down / toward camera / clockwise on screen; -1 = the opposite. Left and right are as you see them with mirror on. |
distance | how far one stroke must go: spans for x and y; relative size for depth (0.25 = 25 % bigger); radians for tilt (0.5 ≈ 29°) |
withinMs | the whole movement must fit in this time, so slow drifting never counts |
reversals | 0 = one stroke (swipe, push, twist). 3 = four strokes back and forth (a wave); direction is then ignored |
pose | optional: only movement made while holding this pose counts |
cooldownMs | optional: minimum time before the same movement can fire again (default 500) |
{ "name": "swipeUp", "axis": "y", "direction": -1, "distance": 1.5, "withinMs": 400 }
{ "name": "wave", "axis": "x", "reversals": 3, "distance": 0.5, "withinMs": 1500, "pose": "openPalm" }
{ "name": "push", "axis": "depth", "direction": 1, "distance": 0.3, "withinMs": 500 }
Built in: swipeLeft, swipeRight (1.5 hand-lengths within 400 ms) and wave. The ↝ rows in the hand panel show each movement's progress from 0 to 1 in amber; watch them while you move to see how close you are, then adjust distance or time.
If a hand disappears for more than 150 ms (lostAfterMs), the library closes everything in order: pinch:end if pinching, disengage if engaged, then lost, and forgets that hand. A shorter gap — a finger passing in front, a blurry frame — changes nothing, so a drag survives brief occlusion. Raise the value if tracking drops out often; lower it if a hand that has left the frame feels sticky.
Every panel is a dockable window (built with dockview):
| cam | resolution in use, or off |
| delegate | where MediaPipe runs: GPU (faster) or CPU. "CPU (GPU failed)" means the GPU could not be used. |
| fps | camera frames analysed per second |
| detect | time MediaPipe spends per frame |
| core | time gesturecore spends per frame, normally well under 1 ms |
| mirror | flip the image like a mirror, so moving your hand right moves it right on screen |
| swap L/R | swap only the Left/Right label. Independent of mirror; either one resets tracking. |
Keys, ignored while typing in a field: Space pause or resume detection · R reset tracking · F mark a false activation during a reliability session.
Newest first: time in seconds, hand, event. Activations are green, releases white, lost red. Consecutive pinch:move events are folded into the pinch:start line as "moves ×N"; tick each pinch:move to list them individually.
One panel per hand, dimmed when that hand is not visible. Badges show engaged, pinch (with finger) and the held pose — amber while held, green once its event has fired.
| pinch | thumb↔index openness, 0 touching … 1 open |
| thumb→i m r p | four bars: thumb distance to index, middle, ring, pinky. Ticks mark the close and release lines. A bar goes amber when it crosses the close line and green while that finger is pinched; disabled fingers are grey. The number is the nearest finger. |
| openness, curl ×5 | see Hand measurements |
| tilt | grows from the centre: right = leaning right |
| span, centroid | hand size; palm position |
| engage / pinch / pose dwell | the three timers as bars, amber filling to green |
| ≈ name | score of every pose; the held one is amber |
| ↝ name | progress of every movement, 1 = fires |
A control for every library setting; changes apply on the next frame. Reset to defaults, Copy config (to keep or share a tuning) and Import… (paste one back).
| Setting | Default | Effect |
|---|---|---|
| minCutoff | 1.0 Hz | smoothing when the hand is still. Lower = steadier but laggier. |
| beta | 0.007 | how much smoothing relaxes during fast movement. Higher = less lag. The default is very low; 5–20 is worth trying. |
| dCutoff | 1.0 Hz | smoothing of the speed estimate used by beta. Rarely needs changing. |
| pinch fingers | all | which fingers can pinch |
| closed | 0.15 | thumb distance counted as touching. Close line = closed + h, release line = closed + 3h. |
| open | 0.75 | distance counted as fully open; only affects the 0…1 pinch value |
| hysteresis h | 0.05 | gap between close and release lines. Bigger = less flicker, harder to release. |
| engage pose | openPalm | the pose that engages a hand, or none |
| dwellMs | 300 | hold time for pinch start and pose events |
| engage dwellMs | 500 | hold time for engaging |
| lostAfterMs | 150 | how long a hand may vanish before it is dropped |
| finger / thumb straight, bent | 0.25 / 2.4, 0.2 / 1.4 rad | joint angles counted as curl 0 and curl 1. If your straight fingers read 0.2, raise straight; if a tight fist reads 0.6, lower bent. |
| pose falloff | 0.15 | how fast a finger's score drops outside its range. Bigger = more forgiving poses. |
The catalogue: every gesture the core currently knows — the engage gate, each pose, each pinch finger and each movement — with how to perform it and the numbers it is judged by. A row reads now while that gesture is happening, so it doubles as a cheat sheet you can practise against. It lists whatever is in the config, so gestures you add appear here too.
Four ways in, and all of them end as the same plain data. A pose or movement whose name already exists is replaced, and Apply on the JSON boxes says exactly what is wrong if anything is.
Describing a movement in numbers is guesswork; showing it is not. Give it a name, choose how many takes, and perform the movement. From those takes the bench works out:
It then replays your takes against the movement it just wrote and loosens it until every take would have fired, so a learnt movement always recognises the demonstrations it came from. The result is shown before anything is added: per-take scores, the axis comparison, and a warning when the axis was not clear-cut — that usually means a bigger, cleaner movement is needed.
Nothing is trained and nothing leaves the machine. The fitted movement is ordinary JSON you can read, tune and share, exactly like one written by hand. The same fitting is part of the library (fitMotion, fitPose), so an app can offer "record your own gesture" without the bench.
The gesturecore-sound brick. It only listens to what the core produces, so the core behaves identically with it on or off. Browsers keep audio silent until you ask, so nothing sounds until you press Turn sound on.
on is the event type, and hand, name and finger narrow it.Every sound is synthesized on the spot; there are no audio files. The decisions — which sound, which note, how loud — are made by pure code that is tested without audio, and only a thin layer talks to the Web Audio API.
The gesturecore-head brick: a head and face reader. It takes MediaPipe's Face Landmarker points from the same camera frames and turns them into events the same way the core treats hands: measurements, then held states with a dwell and hysteresis, then movements. It is off until you switch it on, because the face model is a download of its own and costs time on every frame.
expression:start and expression:end with how long it lasted.nod (down and back up) and shake (side to side), found by the core's own stroke detector running on the head angles.Everything — the settings, the expressions and the movements — is one JSON object in the panel, and the defaults are first guesses to be tuned against real faces. Like the core, the reader is pure code tested without a camera, on synthetic faces turned by exactly known angles.
The definition-of-done measurement: fewer than 2 unintended activations per 10 minutes. Start a session, work normally for ten minutes, and press F whenever a pinch or movement fires that you did not mean. Finished sessions are listed underneath. This is a measurement of the core's defaults, not a feature.
A fixture is a hand the automated tests replay, so a change in the maths shows up as a failing test rather than a surprise. It is not a picture and not a gesture definition — for the gestures themselves see Moves.
One snapshot is a weak test. Hand tracking is at its best square-on to the camera and degrades on angled and distant views, which is exactly where a gesture quietly stops being recognised. So a fixture holds several views of the same shape — front, turned left and right, tilted up and down, near and far — and the tests require every view to read as the same pose. Views you skip are simply not tested; each one you add tightens the net.
Press Capture every view and follow the prompts, or capture single views with their own buttons. Each view averages several frames. A view that disagrees with the others is flagged in amber before you save it — that disagreement is the finding, and usually means the pose's curl ranges are too tight rather than that the capture is bad. Save to test/fixtures writes the file; then run npm test.
The tests also check the same thing synthetically: each fixture is moved, resized, rotated and mirrored, and must still read as the same pose. That covers what maths can cover; real captures cover what it cannot, namely the tracker's own behaviour at awkward angles.
Camera device and resolution (lower resolution can raise fps), GPU/CPU, and MediaPipe's own confidence thresholds: detection for finding a new hand, presence for deciding a tracked hand is still there, tracking for trusting frame-to-frame tracking. Raising them reduces phantom hands but drops real ones more often. The overlay toggles hide the raw skeleton, the smoothed skeleton or the image.
| Symptom | Try |
|---|---|
| Skeleton jitters when still | lower minCutoff |
| Skeleton trails behind fast movement | raise beta (try 5, then 20) |
| Pinch feels slow to start | raise beta; lower dwellMs to about 200 |
| Pinch does not trigger although fingers touch | watch the thumb→ bars: if they never cross the tick, raise closed |
| Accidental pinches | lower closed, raise dwellMs, disable fingers you do not use, keep engagement on |
| Pinch drops during a drag | raise hysteresis; raise lostAfterMs if the hand flickers out |
| Wrong pose, or flickering between poses | check the curl bars; adjust finger straight/bent, narrow the pose ranges, or raise its minScore |
| Pose never recognised | raise pose falloff, widen the ranges, or re-record with more tolerance |
| Swipe does not fire | watch its ↝ bar: lower distance or raise withinMs; make sure you are engaged and not pinching |
| Swipe fires by accident | raise distance, lower withinMs, or require a pose |
| Left and right swipes reversed | turn mirror on |
Change one thing at a time and watch the numbers. When a tuning feels right, Copy config and keep it: those values are what should become the library defaults.
import { GestureCore } from 'gesturecore';
const core = new GestureCore({ aspect: 640 / 480, engage: { pose: 'openPalm', dwellMs: 500 } });
// every frame: hands from MediaPipe, time in milliseconds
for (const e of core.update(hands, performance.now())) {
if (e.type === 'pinch:start') console.log(e.hand, 'pinched', e.finger);
if (e.type === 'motion' && e.name === 'swipeLeft') history.back();
}
core.getFeatures('Right'); // latest measurements, or null
core.getHandState('Right'); // engaged, timers, pose scores, movement progress
core.setConfig({ dwellMs: 200 }); // live change, same as the sliders
| Event | Extra fields |
|---|---|
engage / disengage | – |
pinch:start / pinch:end | finger |
pinch:move | finger, value 0…1 |
pose | name |
motion | name |
lost | – |
Every event carries type, hand ('Left' | 'Right') and t. Within one frame, hands come Left then Right. Every pinch:start gets exactly one pinch:end, and every engage one disengage. The config is plain JSON — exactly what Copy config gives you. More in the project README.
| "Camera permission was denied" | allow the camera from the icon in the address bar, then press Start camera again |
| Black video, or camera busy | another app may hold the camera; close it, or pick a different device in Source |
| Low fps | Source → resolution 320×240; close other tabs; check that delegate says GPU |
| Hand labelled the wrong side | tick swap L/R |
| Nothing engages | Tuning → engage pose: if it says "(missing!)" that pose was deleted; pick another, or none |
| Hands flicker in and out | better lighting, plainer background; lower the detection/presence confidence slightly; raise lostAfterMs |
| Could not start: model error | the model file is missing: run npm run fetch-model, then reload |
| Everything feels off after experimenting | Tuning → Reset to defaults; Gestures → Reset; R resets tracking; Panels → Reset layout |
test/fixtures/.src/ the library (no browser APIs allowed), test/ the automated tests, bench/ this page, README.md developer notes.npm run bench starts this page, npm test runs the tests, npm run fetch-model downloads the hand model.