@map-gesture-controls/core 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -5
- package/dist/GestureController.d.ts +2 -0
- package/dist/GestureController.js +16 -2
- package/dist/GestureStateMachine.d.ts +3 -0
- package/dist/GestureStateMachine.js +68 -11
- package/dist/WebcamOverlay.d.ts +6 -2
- package/dist/WebcamOverlay.js +40 -4
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +6 -0
- package/dist/gestureClassifier.d.ts +18 -3
- package/dist/gestureClassifier.js +73 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +162 -118
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/dist/GestureStateMachine.test.d.ts +0 -1
- package/dist/GestureStateMachine.test.js +0 -232
- package/dist/gestureClassifier.test.d.ts +0 -1
- package/dist/gestureClassifier.test.js +0 -98
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
## What it does
|
|
17
17
|
|
|
18
18
|
- Detects hands and classifies gestures at 30+ fps using MediaPipe Hand Landmarker
|
|
19
|
-
- Recognizes **fist** (left = pan, right = zoom, both = rotate)
|
|
19
|
+
- Recognizes **fist** and **pinch** as interchangeable triggers (left = pan, right = zoom, both = rotate)
|
|
20
20
|
- Manages gesture transitions with dwell timers and grace periods to avoid flickering
|
|
21
21
|
- Provides a configurable webcam overlay with corner/full/hidden display modes
|
|
22
22
|
- Ships fully typed TypeScript declarations
|
|
@@ -59,7 +59,8 @@ controller.start();
|
|
|
59
59
|
| `GestureController` | Class | Opens the webcam, runs MediaPipe detection, and emits gesture frames |
|
|
60
60
|
| `GestureStateMachine` | Class | Manages gesture state transitions with dwell and grace timers |
|
|
61
61
|
| `WebcamOverlay` | Class | Renders a configurable camera preview overlay |
|
|
62
|
-
| `classifyGesture` | Function |
|
|
62
|
+
| `classifyGesture` | Function | Stateless classifier: returns `fist`, `pinch`, `openPalm`, or `none` for a set of landmarks |
|
|
63
|
+
| `createHandClassifier` | Function | Returns a stateful per-hand classifier with pinch hysteresis (use this instead of `classifyGesture` in custom pipelines) |
|
|
63
64
|
| `getHandSize` | Function | Computes the bounding size of a hand from its landmarks |
|
|
64
65
|
| `getTwoHandDistance` | Function | Measures the distance between two detected hands |
|
|
65
66
|
| `DEFAULT_WEBCAM_CONFIG` | Constant | Default webcam overlay settings |
|
|
@@ -69,13 +70,21 @@ Full TypeScript types are exported for `GestureMode`, `GestureFrame`, `DetectedH
|
|
|
69
70
|
|
|
70
71
|
## Gesture recognition
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
Both **fist** and **pinch** trigger the same map actions, users can use whichever is more comfortable.
|
|
74
|
+
|
|
75
|
+
| Gesture | Detection rule | Map action |
|
|
73
76
|
| --- | --- | --- |
|
|
74
77
|
| **Left fist** | Left hand, 3+ fingers curled | Pan / drag |
|
|
75
|
-
| **
|
|
76
|
-
| **
|
|
78
|
+
| **Left pinch** | Left hand, thumb and index tip within 25% of hand size (exits at 35%) | Pan / drag |
|
|
79
|
+
| **Right fist** | Right hand, 3+ fingers curled | Zoom (move up = in, down = out) |
|
|
80
|
+
| **Right pinch** | Right hand, thumb and index tip within 25% of hand size (exits at 35%) | Zoom (move up = in, down = out) |
|
|
81
|
+
| **Both hands active** | Both hands fist or pinch (mixed is fine) | Rotate map |
|
|
77
82
|
| **Idle** | Anything else | No action |
|
|
78
83
|
|
|
84
|
+
> **Reset (OpenLayers only):** `@map-gesture-controls/ol` adds a reset gesture on top of the above. Bring both hands together in a prayer/namaste pose (wrists close, neither hand making a fist or pinch) and hold for 1 second. A progress bar fills in the webcam overlay; when it completes, pan, zoom, and rotation all snap back to their initial values.
|
|
85
|
+
|
|
86
|
+
Pinch detection uses hysteresis: the gesture is entered at 25% of hand size and held until fingers open beyond 35%. This prevents flickering when fingers hover near the threshold during a held pinch.
|
|
87
|
+
|
|
79
88
|
Gestures are confirmed after a configurable dwell period (default 80 ms) and held through a grace period (default 150 ms) to prevent flickering when tracking briefly drops.
|
|
80
89
|
|
|
81
90
|
## Use cases
|
|
@@ -15,6 +15,8 @@ export declare class GestureController {
|
|
|
15
15
|
private onFrame;
|
|
16
16
|
private tuning;
|
|
17
17
|
private lastVideoTime;
|
|
18
|
+
private leftClassifier;
|
|
19
|
+
private rightClassifier;
|
|
18
20
|
constructor(tuning: TuningConfig, onFrame: FrameCallback);
|
|
19
21
|
/**
|
|
20
22
|
* Initialise MediaPipe and request webcam access.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FilesetResolver } from '@mediapipe/tasks-vision';
|
|
2
|
-
import {
|
|
2
|
+
import { createHandClassifier } from './gestureClassifier.js';
|
|
3
3
|
import { MEDIAPIPE_WASM_URL } from './constants.js';
|
|
4
4
|
/**
|
|
5
5
|
* GestureController
|
|
@@ -57,6 +57,19 @@ export class GestureController {
|
|
|
57
57
|
writable: true,
|
|
58
58
|
value: -1
|
|
59
59
|
});
|
|
60
|
+
// One stateful classifier per hand label; persists pinch hysteresis across frames.
|
|
61
|
+
Object.defineProperty(this, "leftClassifier", {
|
|
62
|
+
enumerable: true,
|
|
63
|
+
configurable: true,
|
|
64
|
+
writable: true,
|
|
65
|
+
value: createHandClassifier()
|
|
66
|
+
});
|
|
67
|
+
Object.defineProperty(this, "rightClassifier", {
|
|
68
|
+
enumerable: true,
|
|
69
|
+
configurable: true,
|
|
70
|
+
writable: true,
|
|
71
|
+
value: createHandClassifier()
|
|
72
|
+
});
|
|
60
73
|
this.tuning = tuning;
|
|
61
74
|
this.onFrame = onFrame;
|
|
62
75
|
}
|
|
@@ -146,7 +159,8 @@ export class GestureController {
|
|
|
146
159
|
const rawLabel = handednessArr?.[0]?.categoryName;
|
|
147
160
|
const label = rawLabel === 'Left' ? 'Left' : 'Right';
|
|
148
161
|
const score = handednessArr?.[0]?.score ?? 0;
|
|
149
|
-
const
|
|
162
|
+
const classify = label === 'Left' ? this.leftClassifier : this.rightClassifier;
|
|
163
|
+
const gesture = classify(landmarks);
|
|
150
164
|
return { handedness: label, score, landmarks, gesture };
|
|
151
165
|
});
|
|
152
166
|
const leftHand = hands.find((h) => h.handedness === 'Left') ?? null;
|
|
@@ -33,6 +33,9 @@ export declare class GestureStateMachine {
|
|
|
33
33
|
private prevZoomDist;
|
|
34
34
|
private rotateSmoother;
|
|
35
35
|
private prevRotateAngle;
|
|
36
|
+
private leftActiveFrames;
|
|
37
|
+
private rightActiveFrames;
|
|
38
|
+
private static readonly ESCALATION_FRAMES;
|
|
36
39
|
constructor(tuning: TuningConfig);
|
|
37
40
|
getMode(): GestureMode;
|
|
38
41
|
update(frame: GestureFrame): StateMachineOutput;
|
|
@@ -142,6 +142,22 @@ export class GestureStateMachine {
|
|
|
142
142
|
writable: true,
|
|
143
143
|
value: null
|
|
144
144
|
});
|
|
145
|
+
// Tracks how many consecutive frames each hand has been active.
|
|
146
|
+
// Used to require the secondary hand to be stable before escalating
|
|
147
|
+
// the mode (e.g. pan → rotate), preventing a single noisy frame from
|
|
148
|
+
// interrupting an ongoing single-hand gesture.
|
|
149
|
+
Object.defineProperty(this, "leftActiveFrames", {
|
|
150
|
+
enumerable: true,
|
|
151
|
+
configurable: true,
|
|
152
|
+
writable: true,
|
|
153
|
+
value: 0
|
|
154
|
+
});
|
|
155
|
+
Object.defineProperty(this, "rightActiveFrames", {
|
|
156
|
+
enumerable: true,
|
|
157
|
+
configurable: true,
|
|
158
|
+
writable: true,
|
|
159
|
+
value: 0
|
|
160
|
+
});
|
|
145
161
|
this.panSmoother = new EMAPoint(tuning.smoothingAlpha);
|
|
146
162
|
this.zoomSmoother = new EMAScalar(tuning.smoothingAlpha);
|
|
147
163
|
this.rotateSmoother = new EMAScalar(tuning.smoothingAlpha);
|
|
@@ -154,17 +170,35 @@ export class GestureStateMachine {
|
|
|
154
170
|
const { actionDwellMs, releaseGraceMs } = this.tuning;
|
|
155
171
|
const { leftHand, rightHand } = frame;
|
|
156
172
|
// ── Determine desired mode for this frame ─────────────────────────────────
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
173
|
+
// Both fist and pinch trigger the same modes, users can choose either.
|
|
174
|
+
const isActive = (hand) => hand !== null && (hand.gesture === 'fist' || hand.gesture === 'pinch');
|
|
175
|
+
const rightActive = isActive(rightHand);
|
|
176
|
+
const leftActive = isActive(leftHand);
|
|
177
|
+
// Track consecutive active frames per hand. Used to guard against a single
|
|
178
|
+
// noisy frame from the secondary hand escalating (or interrupting) an
|
|
179
|
+
// ongoing single-hand gesture. Counts reset to 0 the moment a hand drops.
|
|
180
|
+
this.leftActiveFrames = leftActive ? this.leftActiveFrames + 1 : 0;
|
|
181
|
+
this.rightActiveFrames = rightActive ? this.rightActiveFrames + 1 : 0;
|
|
182
|
+
// Escalation to 'rotating' requires both hands to have been active for at
|
|
183
|
+
// least ESCALATION_FRAMES consecutive frames. This prevents one brief noisy
|
|
184
|
+
// frame on the secondary hand from interrupting an ongoing pan or zoom.
|
|
185
|
+
const bothStable = this.leftActiveFrames >= GestureStateMachine.ESCALATION_FRAMES &&
|
|
186
|
+
this.rightActiveFrames >= GestureStateMachine.ESCALATION_FRAMES;
|
|
187
|
+
// Both stable → rotate; right only → zoom; left only → pan.
|
|
188
|
+
// When both hands are active but not yet stable, we preserve the current
|
|
189
|
+
// single-hand mode (panning/zooming) so the secondary hand must be held
|
|
190
|
+
// for ESCALATION_FRAMES before rotating kicks in. This prevents a single
|
|
191
|
+
// noisy frame from the secondary hand from interrupting an ongoing gesture.
|
|
192
|
+
const bothActiveButUnstable = leftActive && rightActive && !bothStable;
|
|
193
|
+
const desired = bothStable
|
|
162
194
|
? 'rotating'
|
|
163
|
-
:
|
|
164
|
-
?
|
|
165
|
-
:
|
|
166
|
-
? '
|
|
167
|
-
:
|
|
195
|
+
: bothActiveButUnstable && (this.mode === 'panning' || this.mode === 'zooming')
|
|
196
|
+
? this.mode // hold current mode until secondary hand is confirmed stable
|
|
197
|
+
: rightActive
|
|
198
|
+
? 'zooming'
|
|
199
|
+
: leftActive
|
|
200
|
+
? 'panning'
|
|
201
|
+
: 'idle';
|
|
168
202
|
// ── idle ──────────────────────────────────────────────────────────────────
|
|
169
203
|
if (this.mode === 'idle') {
|
|
170
204
|
if (desired !== 'idle') {
|
|
@@ -182,6 +216,11 @@ export class GestureStateMachine {
|
|
|
182
216
|
}
|
|
183
217
|
// ── panning ───────────────────────────────────────────────────────────────
|
|
184
218
|
if (this.mode === 'panning') {
|
|
219
|
+
// Escalate to rotating once the right hand becomes stably active too.
|
|
220
|
+
if (bothStable) {
|
|
221
|
+
this.transitionTo('rotating');
|
|
222
|
+
return this.buildOutput(null, null, null);
|
|
223
|
+
}
|
|
185
224
|
if (desired !== 'panning') {
|
|
186
225
|
if (this.releaseTimer === null) {
|
|
187
226
|
this.releaseTimer = now;
|
|
@@ -192,7 +231,7 @@ export class GestureStateMachine {
|
|
|
192
231
|
return this.buildOutput(null, null, null);
|
|
193
232
|
}
|
|
194
233
|
this.releaseTimer = null;
|
|
195
|
-
const fistHand = leftHand
|
|
234
|
+
const fistHand = isActive(leftHand) ? leftHand : null;
|
|
196
235
|
if (!fistHand) {
|
|
197
236
|
this.transitionTo('idle');
|
|
198
237
|
return this.buildOutput(null, null, null);
|
|
@@ -213,6 +252,11 @@ export class GestureStateMachine {
|
|
|
213
252
|
}
|
|
214
253
|
// ── zooming ───────────────────────────────────────────────────────────────
|
|
215
254
|
if (this.mode === 'zooming') {
|
|
255
|
+
// Escalate to rotating once the left hand becomes stably active too.
|
|
256
|
+
if (bothStable) {
|
|
257
|
+
this.transitionTo('rotating');
|
|
258
|
+
return this.buildOutput(null, null, null);
|
|
259
|
+
}
|
|
216
260
|
if (desired !== 'zooming') {
|
|
217
261
|
if (this.releaseTimer === null) {
|
|
218
262
|
this.releaseTimer = now;
|
|
@@ -283,6 +327,13 @@ export class GestureStateMachine {
|
|
|
283
327
|
this.mode = next;
|
|
284
328
|
this.releaseTimer = null;
|
|
285
329
|
this.actionDwell = null;
|
|
330
|
+
// Reset both counters so escalation requires a fresh stable run in the new mode.
|
|
331
|
+
// Exception: keep the dominant hand's counter when entering a single-hand mode so
|
|
332
|
+
// it does not need to re-accumulate from zero if the hand was already stable.
|
|
333
|
+
if (next !== 'panning' && next !== 'rotating')
|
|
334
|
+
this.leftActiveFrames = 0;
|
|
335
|
+
if (next !== 'zooming' && next !== 'rotating')
|
|
336
|
+
this.rightActiveFrames = 0;
|
|
286
337
|
if (next !== 'panning') {
|
|
287
338
|
this.panSmoother.reset();
|
|
288
339
|
this.prevPanPos = null;
|
|
@@ -303,3 +354,9 @@ export class GestureStateMachine {
|
|
|
303
354
|
this.transitionTo('idle');
|
|
304
355
|
}
|
|
305
356
|
}
|
|
357
|
+
Object.defineProperty(GestureStateMachine, "ESCALATION_FRAMES", {
|
|
358
|
+
enumerable: true,
|
|
359
|
+
configurable: true,
|
|
360
|
+
writable: true,
|
|
361
|
+
value: 3
|
|
362
|
+
});
|
package/dist/WebcamOverlay.d.ts
CHANGED
|
@@ -14,15 +14,19 @@ export declare class WebcamOverlay {
|
|
|
14
14
|
private canvas;
|
|
15
15
|
private ctx;
|
|
16
16
|
private badge;
|
|
17
|
+
private resetBar;
|
|
18
|
+
private resetFill;
|
|
17
19
|
private config;
|
|
20
|
+
private lastMode;
|
|
18
21
|
constructor(config: WebcamConfig);
|
|
19
22
|
/** Attach video element produced by GestureController */
|
|
20
23
|
attachVideo(video: HTMLVideoElement): void;
|
|
21
24
|
/** Mount the overlay into the given parent (usually document.body or map container) */
|
|
22
25
|
mount(parent: HTMLElement): void;
|
|
23
26
|
unmount(): void;
|
|
24
|
-
/** Called each frame with the latest gesture frame and
|
|
25
|
-
render(frame: GestureFrame | null, mode: GestureMode): void;
|
|
27
|
+
/** Called each frame with the latest gesture frame, mode, and optional reset progress (0 to 1). */
|
|
28
|
+
render(frame: GestureFrame | null, mode: GestureMode, resetProgress?: number): void;
|
|
29
|
+
private updateResetBar;
|
|
26
30
|
private drawSkeleton;
|
|
27
31
|
private updateBadge;
|
|
28
32
|
private applyContainerStyles;
|
package/dist/WebcamOverlay.js
CHANGED
|
@@ -51,12 +51,30 @@ export class WebcamOverlay {
|
|
|
51
51
|
writable: true,
|
|
52
52
|
value: void 0
|
|
53
53
|
});
|
|
54
|
+
Object.defineProperty(this, "resetBar", {
|
|
55
|
+
enumerable: true,
|
|
56
|
+
configurable: true,
|
|
57
|
+
writable: true,
|
|
58
|
+
value: void 0
|
|
59
|
+
});
|
|
60
|
+
Object.defineProperty(this, "resetFill", {
|
|
61
|
+
enumerable: true,
|
|
62
|
+
configurable: true,
|
|
63
|
+
writable: true,
|
|
64
|
+
value: void 0
|
|
65
|
+
});
|
|
54
66
|
Object.defineProperty(this, "config", {
|
|
55
67
|
enumerable: true,
|
|
56
68
|
configurable: true,
|
|
57
69
|
writable: true,
|
|
58
70
|
value: void 0
|
|
59
71
|
});
|
|
72
|
+
Object.defineProperty(this, "lastMode", {
|
|
73
|
+
enumerable: true,
|
|
74
|
+
configurable: true,
|
|
75
|
+
writable: true,
|
|
76
|
+
value: null
|
|
77
|
+
});
|
|
60
78
|
this.config = config;
|
|
61
79
|
this.container = document.createElement('div');
|
|
62
80
|
this.container.className = 'ol-gesture-overlay';
|
|
@@ -67,8 +85,16 @@ export class WebcamOverlay {
|
|
|
67
85
|
this.badge = document.createElement('div');
|
|
68
86
|
this.badge.className = 'ol-gesture-badge ol-gesture-badge--idle';
|
|
69
87
|
this.badge.textContent = 'Idle';
|
|
88
|
+
this.resetBar = document.createElement('div');
|
|
89
|
+
this.resetBar.className = 'ol-gesture-reset';
|
|
90
|
+
this.resetBar.innerHTML =
|
|
91
|
+
'<span class="ol-gesture-reset-label">Reset</span>' +
|
|
92
|
+
'<div class="ol-gesture-reset-track"><div class="ol-gesture-reset-fill"></div></div>';
|
|
93
|
+
this.resetBar.style.opacity = '0';
|
|
94
|
+
this.resetFill = this.resetBar.querySelector('.ol-gesture-reset-fill');
|
|
70
95
|
this.container.appendChild(this.canvas);
|
|
71
96
|
this.container.appendChild(this.badge);
|
|
97
|
+
this.container.appendChild(this.resetBar);
|
|
72
98
|
const ctx = this.canvas.getContext('2d');
|
|
73
99
|
if (!ctx)
|
|
74
100
|
throw new Error('Cannot get 2D canvas context');
|
|
@@ -88,13 +114,16 @@ export class WebcamOverlay {
|
|
|
88
114
|
unmount() {
|
|
89
115
|
this.container.parentElement?.removeChild(this.container);
|
|
90
116
|
}
|
|
91
|
-
/** Called each frame with the latest gesture frame and
|
|
92
|
-
render(frame, mode) {
|
|
117
|
+
/** Called each frame with the latest gesture frame, mode, and optional reset progress (0 to 1). */
|
|
118
|
+
render(frame, mode, resetProgress = 0) {
|
|
93
119
|
this.updateBadge(mode);
|
|
120
|
+
this.updateResetBar(resetProgress);
|
|
94
121
|
const w = this.config.width;
|
|
95
122
|
const h = this.config.height;
|
|
96
|
-
this.canvas.width
|
|
97
|
-
|
|
123
|
+
if (this.canvas.width !== w || this.canvas.height !== h) {
|
|
124
|
+
this.canvas.width = w;
|
|
125
|
+
this.canvas.height = h;
|
|
126
|
+
}
|
|
98
127
|
this.ctx.clearRect(0, 0, w, h);
|
|
99
128
|
if (frame === null)
|
|
100
129
|
return;
|
|
@@ -102,6 +131,10 @@ export class WebcamOverlay {
|
|
|
102
131
|
this.drawSkeleton(hand.landmarks, mode, hand.gesture === 'fist');
|
|
103
132
|
}
|
|
104
133
|
}
|
|
134
|
+
updateResetBar(resetProgress) {
|
|
135
|
+
this.resetFill.style.width = `${resetProgress * 100}%`;
|
|
136
|
+
this.resetBar.style.opacity = resetProgress > 0 ? '1' : '0';
|
|
137
|
+
}
|
|
105
138
|
drawSkeleton(landmarks, mode, isActionHand) {
|
|
106
139
|
const { ctx } = this;
|
|
107
140
|
const w = this.config.width;
|
|
@@ -141,6 +174,9 @@ export class WebcamOverlay {
|
|
|
141
174
|
}
|
|
142
175
|
}
|
|
143
176
|
updateBadge(mode) {
|
|
177
|
+
if (mode === this.lastMode)
|
|
178
|
+
return;
|
|
179
|
+
this.lastMode = mode;
|
|
144
180
|
const labels = {
|
|
145
181
|
idle: 'Idle',
|
|
146
182
|
panning: 'Pan',
|
package/dist/constants.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export declare const LANDMARKS: {
|
|
|
13
13
|
readonly PINKY_TIP: 20;
|
|
14
14
|
readonly PINKY_MCP: 17;
|
|
15
15
|
};
|
|
16
|
+
export declare const PINCH_THRESHOLD = 0.25;
|
|
17
|
+
export declare const PINCH_RELEASE_THRESHOLD = 0.35;
|
|
16
18
|
export declare const FINGERTIP_INDICES: readonly [8, 12, 16, 20];
|
|
17
19
|
export declare const FINGER_BASE_INDICES: readonly [5, 9, 13, 17];
|
|
18
20
|
export declare const COLORS: {
|
package/dist/constants.js
CHANGED
|
@@ -29,6 +29,12 @@ export const LANDMARKS = {
|
|
|
29
29
|
PINKY_TIP: 20,
|
|
30
30
|
PINKY_MCP: 17,
|
|
31
31
|
};
|
|
32
|
+
// Pinch detection thresholds as a fraction of hand size.
|
|
33
|
+
// Hysteresis: enter pinch at PINCH_THRESHOLD, exit (release) at PINCH_RELEASE_THRESHOLD.
|
|
34
|
+
// The wider release threshold prevents the classifier from flickering between
|
|
35
|
+
// 'pinch' and 'none' when the fingers hover at the edge of the enter threshold.
|
|
36
|
+
export const PINCH_THRESHOLD = 0.25;
|
|
37
|
+
export const PINCH_RELEASE_THRESHOLD = 0.35;
|
|
32
38
|
// Fingertip indices for open-palm detection
|
|
33
39
|
export const FINGERTIP_INDICES = [
|
|
34
40
|
LANDMARKS.INDEX_TIP,
|
|
@@ -14,9 +14,24 @@ export declare function getTwoHandDistance(landmarksA: HandLandmark[], landmarks
|
|
|
14
14
|
/**
|
|
15
15
|
* Classify the gesture for a single hand from its landmarks.
|
|
16
16
|
*
|
|
17
|
-
* Priority: fist > openPalm > none
|
|
17
|
+
* Priority: fist > pinch > openPalm > none
|
|
18
18
|
*
|
|
19
|
-
* Fist:
|
|
20
|
-
*
|
|
19
|
+
* Fist: most fingers curled
|
|
20
|
+
* Pinch: thumb tip and index tip close together
|
|
21
|
+
* Open palm: all fingers extended and spread
|
|
22
|
+
*
|
|
23
|
+
* NOTE: This stateless version has no pinch hysteresis. Use `createHandClassifier`
|
|
24
|
+
* for a stateful per-hand classifier that prevents pinch flickering.
|
|
21
25
|
*/
|
|
22
26
|
export declare function classifyGesture(landmarks: HandLandmark[]): GestureType;
|
|
27
|
+
/**
|
|
28
|
+
* Returns a stateful classify function for a single hand.
|
|
29
|
+
*
|
|
30
|
+
* Pinch uses hysteresis: the hand enters 'pinch' when tips are within
|
|
31
|
+
* PINCH_THRESHOLD of hand size, and stays in 'pinch' until tips separate
|
|
32
|
+
* beyond PINCH_RELEASE_THRESHOLD. This prevents rapid toggling when fingers
|
|
33
|
+
* hover at the entry threshold during a held pinch gesture.
|
|
34
|
+
*
|
|
35
|
+
* Use one instance per hand (left / right) and reset between hand sessions.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createHandClassifier(): (landmarks: HandLandmark[]) => GestureType;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LANDMARKS, FINGERTIP_INDICES, FINGER_BASE_INDICES } from './constants.js';
|
|
1
|
+
import { LANDMARKS, FINGERTIP_INDICES, FINGER_BASE_INDICES, PINCH_THRESHOLD, PINCH_RELEASE_THRESHOLD } from './constants.js';
|
|
2
2
|
/**
|
|
3
3
|
* Euclidean distance between two landmarks (ignoring Z).
|
|
4
4
|
*/
|
|
@@ -89,13 +89,42 @@ export function getTwoHandDistance(landmarksA, landmarksB) {
|
|
|
89
89
|
return 0;
|
|
90
90
|
return dist(tipA, tipB);
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Stateless pinch check used only as the entry condition.
|
|
94
|
+
* Returns true when thumb tip and index tip are within PINCH_THRESHOLD of hand size.
|
|
95
|
+
*/
|
|
96
|
+
function isPinchEnter(landmarks) {
|
|
97
|
+
const handSize = getHandSize(landmarks);
|
|
98
|
+
if (handSize === 0)
|
|
99
|
+
return false;
|
|
100
|
+
const thumbTip = landmarks[LANDMARKS.THUMB_TIP];
|
|
101
|
+
const indexTip = landmarks[LANDMARKS.INDEX_TIP];
|
|
102
|
+
return dist(thumbTip, indexTip) < handSize * PINCH_THRESHOLD;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Returns true when thumb tip and index tip are still within PINCH_RELEASE_THRESHOLD.
|
|
106
|
+
* Used as the exit condition to implement hysteresis: once pinching, we stay in
|
|
107
|
+
* 'pinch' until the fingers open wider than the release threshold.
|
|
108
|
+
*/
|
|
109
|
+
function isPinchHeld(landmarks) {
|
|
110
|
+
const handSize = getHandSize(landmarks);
|
|
111
|
+
if (handSize === 0)
|
|
112
|
+
return false;
|
|
113
|
+
const thumbTip = landmarks[LANDMARKS.THUMB_TIP];
|
|
114
|
+
const indexTip = landmarks[LANDMARKS.INDEX_TIP];
|
|
115
|
+
return dist(thumbTip, indexTip) < handSize * PINCH_RELEASE_THRESHOLD;
|
|
116
|
+
}
|
|
92
117
|
/**
|
|
93
118
|
* Classify the gesture for a single hand from its landmarks.
|
|
94
119
|
*
|
|
95
|
-
* Priority: fist > openPalm > none
|
|
120
|
+
* Priority: fist > pinch > openPalm > none
|
|
121
|
+
*
|
|
122
|
+
* Fist: most fingers curled
|
|
123
|
+
* Pinch: thumb tip and index tip close together
|
|
124
|
+
* Open palm: all fingers extended and spread
|
|
96
125
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
126
|
+
* NOTE: This stateless version has no pinch hysteresis. Use `createHandClassifier`
|
|
127
|
+
* for a stateful per-hand classifier that prevents pinch flickering.
|
|
99
128
|
*/
|
|
100
129
|
export function classifyGesture(landmarks) {
|
|
101
130
|
if (landmarks.length < 21)
|
|
@@ -103,8 +132,48 @@ export function classifyGesture(landmarks) {
|
|
|
103
132
|
if (areAllFingersCurled(landmarks)) {
|
|
104
133
|
return 'fist';
|
|
105
134
|
}
|
|
135
|
+
if (isPinchEnter(landmarks)) {
|
|
136
|
+
return 'pinch';
|
|
137
|
+
}
|
|
106
138
|
if (areAllFingersExtended(landmarks)) {
|
|
107
139
|
return 'openPalm';
|
|
108
140
|
}
|
|
109
141
|
return 'none';
|
|
110
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Returns a stateful classify function for a single hand.
|
|
145
|
+
*
|
|
146
|
+
* Pinch uses hysteresis: the hand enters 'pinch' when tips are within
|
|
147
|
+
* PINCH_THRESHOLD of hand size, and stays in 'pinch' until tips separate
|
|
148
|
+
* beyond PINCH_RELEASE_THRESHOLD. This prevents rapid toggling when fingers
|
|
149
|
+
* hover at the entry threshold during a held pinch gesture.
|
|
150
|
+
*
|
|
151
|
+
* Use one instance per hand (left / right) and reset between hand sessions.
|
|
152
|
+
*/
|
|
153
|
+
export function createHandClassifier() {
|
|
154
|
+
let pinching = false;
|
|
155
|
+
return function classify(landmarks) {
|
|
156
|
+
if (landmarks.length < 21) {
|
|
157
|
+
pinching = false;
|
|
158
|
+
return 'none';
|
|
159
|
+
}
|
|
160
|
+
if (areAllFingersCurled(landmarks)) {
|
|
161
|
+
pinching = false;
|
|
162
|
+
return 'fist';
|
|
163
|
+
}
|
|
164
|
+
// Hysteresis: enter pinch on PINCH_THRESHOLD, exit on PINCH_RELEASE_THRESHOLD.
|
|
165
|
+
if (pinching) {
|
|
166
|
+
pinching = isPinchHeld(landmarks);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
pinching = isPinchEnter(landmarks);
|
|
170
|
+
}
|
|
171
|
+
if (pinching) {
|
|
172
|
+
return 'pinch';
|
|
173
|
+
}
|
|
174
|
+
if (areAllFingersExtended(landmarks)) {
|
|
175
|
+
return 'openPalm';
|
|
176
|
+
}
|
|
177
|
+
return 'none';
|
|
178
|
+
};
|
|
179
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,6 @@ export { GestureController } from './GestureController.js';
|
|
|
2
2
|
export { GestureStateMachine } from './GestureStateMachine.js';
|
|
3
3
|
export type { StateMachineOutput } from './GestureStateMachine.js';
|
|
4
4
|
export { WebcamOverlay } from './WebcamOverlay.js';
|
|
5
|
-
export { classifyGesture, getHandSize, getTwoHandDistance } from './gestureClassifier.js';
|
|
5
|
+
export { classifyGesture, createHandClassifier, getHandSize, getTwoHandDistance } from './gestureClassifier.js';
|
|
6
6
|
export type { GestureMode, GestureType, HandednessLabel, GestureFrame, DetectedHand, WebcamConfig, TuningConfig, SmoothedPoint, Point2D, HandLandmark, } from './types.js';
|
|
7
7
|
export { DEFAULT_WEBCAM_CONFIG, DEFAULT_TUNING_CONFIG, LANDMARKS, COLORS, } from './constants.js';
|