@map-gesture-controls/core 0.1.8 → 0.1.9
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 +12 -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/GestureStateMachine.test.js +42 -10
- 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 +148 -112
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
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,19 @@ 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
|
+
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.
|
|
85
|
+
|
|
79
86
|
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
87
|
|
|
81
88
|
## 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
|
+
});
|
|
@@ -43,10 +43,20 @@ function enterZooming(fsm, right = makeHand('fist')) {
|
|
|
43
43
|
fsm.update(makeFrame(0, null, right));
|
|
44
44
|
}
|
|
45
45
|
/**
|
|
46
|
-
* Drive the FSM into 'rotating' with dwell=0
|
|
46
|
+
* Drive the FSM into 'rotating' with dwell=0.
|
|
47
|
+
* Requires ESCALATION_FRAMES (3) consecutive both-active frames, then one
|
|
48
|
+
* more for the dwell to fire (dwell=0 means it fires on the frame that meets
|
|
49
|
+
* the threshold, so 3 stable frames + 1 dwell frame = 4 total).
|
|
47
50
|
* Rotate = both fists.
|
|
48
51
|
*/
|
|
49
52
|
function enterRotating(fsm, left = makeHand('fist'), right = makeHand('fist')) {
|
|
53
|
+
// Frame 1: both active, dwell timer starts (idle branch, no transition yet).
|
|
54
|
+
// Frame 2: dwell fires → transitionTo('zooming'), resets leftActiveFrames to 0.
|
|
55
|
+
// Frames 3-5: leftActiveFrames counts 1 → 2 → 3 (= ESCALATION_FRAMES).
|
|
56
|
+
// Frame 5: bothStable=true → zooming branch escalates to rotating.
|
|
57
|
+
fsm.update(makeFrame(0, left, right));
|
|
58
|
+
fsm.update(makeFrame(0, left, right));
|
|
59
|
+
fsm.update(makeFrame(0, left, right));
|
|
50
60
|
fsm.update(makeFrame(0, left, right));
|
|
51
61
|
fsm.update(makeFrame(0, left, right));
|
|
52
62
|
}
|
|
@@ -75,21 +85,37 @@ describe('GestureStateMachine', () => {
|
|
|
75
85
|
expect(out.mode).toBe('zooming');
|
|
76
86
|
});
|
|
77
87
|
// ── idle → rotating ────────────────────────────────────────────────────────
|
|
78
|
-
it('transitions to rotating when both fists are held (
|
|
88
|
+
it('transitions to rotating when both fists are held stably (5 frames: dwell + 3 escalation)', () => {
|
|
79
89
|
const left = makeHand('fist');
|
|
80
90
|
const right = makeHand('fist');
|
|
81
91
|
fsm.update(makeFrame(0, left, right));
|
|
92
|
+
fsm.update(makeFrame(0, left, right));
|
|
93
|
+
fsm.update(makeFrame(0, left, right));
|
|
94
|
+
fsm.update(makeFrame(0, left, right));
|
|
82
95
|
const out = fsm.update(makeFrame(0, left, right));
|
|
83
96
|
expect(out.mode).toBe('rotating');
|
|
84
97
|
});
|
|
85
|
-
it('prefers rotating over zooming
|
|
98
|
+
it('prefers rotating over zooming once both fists are held stably', () => {
|
|
86
99
|
const left = makeHand('fist');
|
|
87
100
|
const right = makeHand('fist');
|
|
88
101
|
fsm.update(makeFrame(0, left, right));
|
|
102
|
+
fsm.update(makeFrame(0, left, right));
|
|
103
|
+
fsm.update(makeFrame(0, left, right));
|
|
104
|
+
fsm.update(makeFrame(0, left, right));
|
|
89
105
|
const out = fsm.update(makeFrame(0, left, right));
|
|
90
|
-
// both fists = rotating, not zooming
|
|
106
|
+
// both fists stable for ESCALATION_FRAMES = rotating, not zooming
|
|
91
107
|
expect(out.mode).toBe('rotating');
|
|
92
108
|
});
|
|
109
|
+
it('does NOT transition to rotating if second hand only appears for 1-2 frames (noise guard)', () => {
|
|
110
|
+
const left = makeHand('fist');
|
|
111
|
+
const right = makeHand('fist');
|
|
112
|
+
// Only 2 frames with both hands — below ESCALATION_FRAMES threshold
|
|
113
|
+
fsm.update(makeFrame(0, left, null)); // establish left pan
|
|
114
|
+
fsm.update(makeFrame(0, left, null)); // → panning
|
|
115
|
+
fsm.update(makeFrame(1, left, right)); // right appears — 1 frame, not stable yet
|
|
116
|
+
const out = fsm.update(makeFrame(1, left, right)); // 2 frames — still below threshold
|
|
117
|
+
expect(out.mode).toBe('panning');
|
|
118
|
+
});
|
|
93
119
|
// ── panning → idle ─────────────────────────────────────────────────────────
|
|
94
120
|
it('returns to idle when the left fist is released (releaseGraceMs=0)', () => {
|
|
95
121
|
enterPanning(fsm);
|
|
@@ -173,10 +199,12 @@ describe('GestureStateMachine', () => {
|
|
|
173
199
|
});
|
|
174
200
|
// ── rotateDelta output ─────────────────────────────────────────────────────
|
|
175
201
|
it('emits no rotateDelta on the first rotating frame', () => {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
202
|
+
// enterRotating drives into rotating mode (5 frames); the escalation frame
|
|
203
|
+
// itself returns null (no prevAngle yet). The *next* frame sets prevAngle,
|
|
204
|
+
// still no delta. Only the frame after that can emit a delta.
|
|
205
|
+
enterRotating(fsm);
|
|
206
|
+
// First full frame inside rotating — sets prevRotateAngle, no delta yet
|
|
207
|
+
const out = fsm.update(makeFrame(1, makeHand('fist'), makeHand('fist')));
|
|
180
208
|
expect(out.rotateDelta).toBeNull();
|
|
181
209
|
});
|
|
182
210
|
it('emits rotateDelta once a previous angle is established', () => {
|
|
@@ -186,9 +214,13 @@ describe('GestureStateMachine', () => {
|
|
|
186
214
|
// Tilt clockwise: right wrist drops, left wrist rises → angle increases
|
|
187
215
|
const lmL2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.2, y: 0.4, z: 0 } });
|
|
188
216
|
const lmR2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.8, y: 0.6, z: 0 } });
|
|
189
|
-
|
|
190
|
-
|
|
217
|
+
// Get into rotating mode (5 frames with lmL1/lmR1)
|
|
218
|
+
for (let i = 0; i < 5; i++) {
|
|
219
|
+
fsm.update(makeFrame(0, makeHand('fist', lmL1), makeHand('fist', lmR1)));
|
|
220
|
+
}
|
|
221
|
+
// First frame inside rotating: sets prevRotateAngle, no delta
|
|
191
222
|
fsm.update(makeFrame(1, makeHand('fist', lmL1), makeHand('fist', lmR1)));
|
|
223
|
+
// Second frame: emits delta
|
|
192
224
|
const out = fsm.update(makeFrame(2, makeHand('fist', lmL2), makeHand('fist', lmR2)));
|
|
193
225
|
expect(out.mode).toBe('rotating');
|
|
194
226
|
expect(out.rotateDelta).not.toBeNull();
|
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';
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
var
|
|
2
|
-
var
|
|
3
|
-
var l = (
|
|
4
|
-
import { FilesetResolver as
|
|
5
|
-
const
|
|
1
|
+
var O = Object.defineProperty;
|
|
2
|
+
var x = (i, t, e) => t in i ? O(i, t, { enumerable: !0, configurable: !0, writable: !0, value: e }) : i[t] = e;
|
|
3
|
+
var l = (i, t, e) => x(i, typeof t != "symbol" ? t + "" : t, e);
|
|
4
|
+
import { FilesetResolver as H } from "@mediapipe/tasks-vision";
|
|
5
|
+
const Y = {
|
|
6
6
|
enabled: !0,
|
|
7
7
|
mode: "corner",
|
|
8
8
|
opacity: 0.85,
|
|
9
9
|
position: "bottom-right",
|
|
10
10
|
width: 320,
|
|
11
11
|
height: 240
|
|
12
|
-
},
|
|
12
|
+
}, Z = {
|
|
13
13
|
actionDwellMs: 80,
|
|
14
14
|
releaseGraceMs: 150,
|
|
15
15
|
panDeadzonePx: 10,
|
|
@@ -29,17 +29,17 @@ const L = {
|
|
|
29
29
|
RING_MCP: 13,
|
|
30
30
|
PINKY_TIP: 20,
|
|
31
31
|
PINKY_MCP: 17
|
|
32
|
-
},
|
|
32
|
+
}, L = 0.25, z = 0.35, E = [
|
|
33
33
|
r.INDEX_TIP,
|
|
34
34
|
r.MIDDLE_TIP,
|
|
35
35
|
r.RING_TIP,
|
|
36
36
|
r.PINKY_TIP
|
|
37
|
-
],
|
|
37
|
+
], _ = [
|
|
38
38
|
r.INDEX_MCP,
|
|
39
39
|
r.MIDDLE_MCP,
|
|
40
40
|
r.RING_MCP,
|
|
41
41
|
r.PINKY_MCP
|
|
42
|
-
],
|
|
42
|
+
], D = {
|
|
43
43
|
idle: "#888888",
|
|
44
44
|
panning: "#00ccff",
|
|
45
45
|
zooming: "#00ffcc",
|
|
@@ -47,49 +47,67 @@ const L = {
|
|
|
47
47
|
landmark: "rgba(255,255,255,0.6)",
|
|
48
48
|
connection: "rgba(255,255,255,0.3)",
|
|
49
49
|
fingertipGlow: "#4488ff"
|
|
50
|
-
},
|
|
51
|
-
function T(
|
|
52
|
-
const e =
|
|
53
|
-
return Math.sqrt(e * e +
|
|
50
|
+
}, G = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm";
|
|
51
|
+
function T(i, t) {
|
|
52
|
+
const e = i.x - t.x, n = i.y - t.y;
|
|
53
|
+
return Math.sqrt(e * e + n * n);
|
|
54
54
|
}
|
|
55
|
-
function N(
|
|
56
|
-
const t =
|
|
57
|
-
for (let
|
|
58
|
-
const
|
|
59
|
-
if (T(
|
|
55
|
+
function N(i) {
|
|
56
|
+
const t = i[r.WRIST];
|
|
57
|
+
for (let s = 0; s < E.length; s++) {
|
|
58
|
+
const u = i[E[s]], c = i[_[s]];
|
|
59
|
+
if (T(u, t) < T(c, t) * 0.9)
|
|
60
60
|
return !1;
|
|
61
61
|
}
|
|
62
|
-
const e =
|
|
62
|
+
const e = w(i);
|
|
63
63
|
if (e === 0) return !1;
|
|
64
|
-
const
|
|
65
|
-
let
|
|
66
|
-
for (let
|
|
67
|
-
const
|
|
68
|
-
|
|
64
|
+
const n = E.map((s) => i[s]);
|
|
65
|
+
let o = 1 / 0;
|
|
66
|
+
for (let s = 0; s < n.length - 1; s++) {
|
|
67
|
+
const u = T(n[s], n[s + 1]);
|
|
68
|
+
u < o && (o = u);
|
|
69
69
|
}
|
|
70
|
-
return
|
|
70
|
+
return o >= e * 0.18;
|
|
71
71
|
}
|
|
72
|
-
function
|
|
73
|
-
const t =
|
|
72
|
+
function R(i) {
|
|
73
|
+
const t = i[r.WRIST];
|
|
74
74
|
let e = 0;
|
|
75
|
-
for (let
|
|
76
|
-
const
|
|
77
|
-
T(
|
|
75
|
+
for (let n = 0; n < E.length; n++) {
|
|
76
|
+
const o = i[E[n]], a = i[_[n]];
|
|
77
|
+
T(o, t) < T(a, t) * 1.1 && e++;
|
|
78
78
|
}
|
|
79
79
|
return e >= 3;
|
|
80
80
|
}
|
|
81
|
-
function
|
|
82
|
-
const t =
|
|
81
|
+
function w(i) {
|
|
82
|
+
const t = i[r.WRIST], e = i[r.MIDDLE_MCP];
|
|
83
83
|
return !t || !e ? 0 : T(t, e);
|
|
84
84
|
}
|
|
85
|
-
function
|
|
86
|
-
const e =
|
|
87
|
-
return !e || !
|
|
85
|
+
function j(i, t) {
|
|
86
|
+
const e = i[r.INDEX_TIP], n = t[r.INDEX_TIP];
|
|
87
|
+
return !e || !n ? 0 : T(e, n);
|
|
88
88
|
}
|
|
89
|
-
function
|
|
90
|
-
|
|
89
|
+
function F(i) {
|
|
90
|
+
const t = w(i);
|
|
91
|
+
if (t === 0) return !1;
|
|
92
|
+
const e = i[r.THUMB_TIP], n = i[r.INDEX_TIP];
|
|
93
|
+
return T(e, n) < t * L;
|
|
91
94
|
}
|
|
92
|
-
|
|
95
|
+
function B(i) {
|
|
96
|
+
const t = w(i);
|
|
97
|
+
if (t === 0) return !1;
|
|
98
|
+
const e = i[r.THUMB_TIP], n = i[r.INDEX_TIP];
|
|
99
|
+
return T(e, n) < t * z;
|
|
100
|
+
}
|
|
101
|
+
function k(i) {
|
|
102
|
+
return i.length < 21 ? "none" : R(i) ? "fist" : F(i) ? "pinch" : N(i) ? "openPalm" : "none";
|
|
103
|
+
}
|
|
104
|
+
function C() {
|
|
105
|
+
let i = !1;
|
|
106
|
+
return function(e) {
|
|
107
|
+
return e.length < 21 ? (i = !1, "none") : R(e) ? (i = !1, "fist") : (i ? i = B(e) : i = F(e), i ? "pinch" : N(e) ? "openPalm" : "none");
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
class $ {
|
|
93
111
|
constructor(t, e) {
|
|
94
112
|
l(this, "landmarker", null);
|
|
95
113
|
l(this, "videoEl", null);
|
|
@@ -99,6 +117,9 @@ class B {
|
|
|
99
117
|
l(this, "onFrame");
|
|
100
118
|
l(this, "tuning");
|
|
101
119
|
l(this, "lastVideoTime", -1);
|
|
120
|
+
// One stateful classifier per hand label; persists pinch hysteresis across frames.
|
|
121
|
+
l(this, "leftClassifier", C());
|
|
122
|
+
l(this, "rightClassifier", C());
|
|
102
123
|
this.tuning = t, this.onFrame = e;
|
|
103
124
|
}
|
|
104
125
|
/**
|
|
@@ -106,7 +127,7 @@ class B {
|
|
|
106
127
|
* Returns the video element so the overlay can render it.
|
|
107
128
|
*/
|
|
108
129
|
async init() {
|
|
109
|
-
const t = await
|
|
130
|
+
const t = await H.forVisionTasks(G), { HandLandmarker: e } = await import("@mediapipe/tasks-vision");
|
|
110
131
|
return this.landmarker = await e.createFromOptions(t, {
|
|
111
132
|
baseOptions: {
|
|
112
133
|
modelAssetPath: "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task",
|
|
@@ -119,8 +140,8 @@ class B {
|
|
|
119
140
|
minTrackingConfidence: this.tuning.minTrackingConfidence
|
|
120
141
|
}), this.videoEl = document.createElement("video"), this.videoEl.setAttribute("playsinline", ""), this.videoEl.setAttribute("autoplay", ""), this.videoEl.muted = !0, this.videoEl.width = 640, this.videoEl.height = 480, this.stream = await navigator.mediaDevices.getUserMedia({
|
|
121
142
|
video: { width: 640, height: 480, facingMode: "user" }
|
|
122
|
-
}), this.videoEl.srcObject = this.stream, await new Promise((
|
|
123
|
-
this.videoEl.addEventListener("loadeddata", () =>
|
|
143
|
+
}), this.videoEl.srcObject = this.stream, await new Promise((n) => {
|
|
144
|
+
this.videoEl.addEventListener("loadeddata", () => n(), { once: !0 });
|
|
124
145
|
}), this.videoEl;
|
|
125
146
|
}
|
|
126
147
|
start() {
|
|
@@ -131,7 +152,7 @@ class B {
|
|
|
131
152
|
}
|
|
132
153
|
destroy() {
|
|
133
154
|
var t, e;
|
|
134
|
-
this.stop(), (t = this.stream) == null || t.getTracks().forEach((
|
|
155
|
+
this.stop(), (t = this.stream) == null || t.getTracks().forEach((n) => n.stop()), (e = this.landmarker) == null || e.close(), this.landmarker = null, this.videoEl = null, this.stream = null;
|
|
135
156
|
}
|
|
136
157
|
loop() {
|
|
137
158
|
this.running && (this.rafHandle = requestAnimationFrame(() => this.loop()), this.processFrame());
|
|
@@ -139,31 +160,31 @@ class B {
|
|
|
139
160
|
processFrame() {
|
|
140
161
|
const t = this.videoEl, e = this.landmarker;
|
|
141
162
|
if (!t || !e || t.readyState < 2) return;
|
|
142
|
-
const
|
|
163
|
+
const n = performance.now();
|
|
143
164
|
if (t.currentTime === this.lastVideoTime) return;
|
|
144
165
|
this.lastVideoTime = t.currentTime;
|
|
145
|
-
let
|
|
166
|
+
let o;
|
|
146
167
|
try {
|
|
147
|
-
|
|
168
|
+
o = e.detectForVideo(t, n);
|
|
148
169
|
} catch {
|
|
149
170
|
return;
|
|
150
171
|
}
|
|
151
|
-
const a = this.buildFrame(
|
|
172
|
+
const a = this.buildFrame(o, n);
|
|
152
173
|
this.onFrame(a);
|
|
153
174
|
}
|
|
154
175
|
buildFrame(t, e) {
|
|
155
|
-
const
|
|
156
|
-
var
|
|
157
|
-
const c = t.handedness[
|
|
158
|
-
return { handedness:
|
|
159
|
-
}),
|
|
160
|
-
return { timestamp: e, hands:
|
|
176
|
+
const n = t.landmarks.map((s, u) => {
|
|
177
|
+
var m, p;
|
|
178
|
+
const c = t.handedness[u], d = ((m = c == null ? void 0 : c[0]) == null ? void 0 : m.categoryName) === "Left" ? "Left" : "Right", P = ((p = c == null ? void 0 : c[0]) == null ? void 0 : p.score) ?? 0, f = (d === "Left" ? this.leftClassifier : this.rightClassifier)(s);
|
|
179
|
+
return { handedness: d, score: P, landmarks: s, gesture: f };
|
|
180
|
+
}), o = n.find((s) => s.handedness === "Left") ?? null, a = n.find((s) => s.handedness === "Right") ?? null;
|
|
181
|
+
return { timestamp: e, hands: n, leftHand: o, rightHand: a };
|
|
161
182
|
}
|
|
162
183
|
getVideoElement() {
|
|
163
184
|
return this.videoEl;
|
|
164
185
|
}
|
|
165
186
|
}
|
|
166
|
-
class
|
|
187
|
+
class U {
|
|
167
188
|
constructor(t) {
|
|
168
189
|
l(this, "value", null);
|
|
169
190
|
this.alpha = t;
|
|
@@ -178,7 +199,7 @@ class A {
|
|
|
178
199
|
this.value = null;
|
|
179
200
|
}
|
|
180
201
|
}
|
|
181
|
-
class
|
|
202
|
+
class M {
|
|
182
203
|
constructor(t) {
|
|
183
204
|
l(this, "value", null);
|
|
184
205
|
this.alpha = t;
|
|
@@ -190,7 +211,7 @@ class D {
|
|
|
190
211
|
this.value = null;
|
|
191
212
|
}
|
|
192
213
|
}
|
|
193
|
-
class
|
|
214
|
+
const y = class y {
|
|
194
215
|
constructor(t) {
|
|
195
216
|
l(this, "mode", "idle");
|
|
196
217
|
l(this, "actionDwell", null);
|
|
@@ -201,69 +222,83 @@ class U {
|
|
|
201
222
|
l(this, "prevZoomDist", null);
|
|
202
223
|
l(this, "rotateSmoother");
|
|
203
224
|
l(this, "prevRotateAngle", null);
|
|
204
|
-
|
|
225
|
+
// Tracks how many consecutive frames each hand has been active.
|
|
226
|
+
// Used to require the secondary hand to be stable before escalating
|
|
227
|
+
// the mode (e.g. pan → rotate), preventing a single noisy frame from
|
|
228
|
+
// interrupting an ongoing single-hand gesture.
|
|
229
|
+
l(this, "leftActiveFrames", 0);
|
|
230
|
+
l(this, "rightActiveFrames", 0);
|
|
231
|
+
this.tuning = t, this.panSmoother = new U(t.smoothingAlpha), this.zoomSmoother = new M(t.smoothingAlpha), this.rotateSmoother = new M(t.smoothingAlpha);
|
|
205
232
|
}
|
|
206
233
|
getMode() {
|
|
207
234
|
return this.mode;
|
|
208
235
|
}
|
|
209
236
|
update(t) {
|
|
210
|
-
const e = t.timestamp, { actionDwellMs:
|
|
237
|
+
const e = t.timestamp, { actionDwellMs: n, releaseGraceMs: o } = this.tuning, { leftHand: a, rightHand: s } = t, u = (f) => f !== null && (f.gesture === "fist" || f.gesture === "pinch"), c = u(s), h = u(a);
|
|
238
|
+
this.leftActiveFrames = h ? this.leftActiveFrames + 1 : 0, this.rightActiveFrames = c ? this.rightActiveFrames + 1 : 0;
|
|
239
|
+
const d = this.leftActiveFrames >= y.ESCALATION_FRAMES && this.rightActiveFrames >= y.ESCALATION_FRAMES, g = d ? "rotating" : h && c && !d && (this.mode === "panning" || this.mode === "zooming") ? this.mode : c ? "zooming" : h ? "panning" : "idle";
|
|
211
240
|
if (this.mode === "idle")
|
|
212
|
-
return
|
|
241
|
+
return g !== "idle" ? this.actionDwell === null || this.actionDwell.gesture !== g ? this.actionDwell = { gesture: g, startMs: e } : e - this.actionDwell.startMs >= n && this.transitionTo(g) : this.actionDwell = null, this.buildOutput(null, null, null);
|
|
213
242
|
if (this.mode === "panning") {
|
|
214
|
-
if (
|
|
215
|
-
return this.
|
|
243
|
+
if (d)
|
|
244
|
+
return this.transitionTo("rotating"), this.buildOutput(null, null, null);
|
|
245
|
+
if (g !== "panning")
|
|
246
|
+
return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= o && this.transitionTo("idle"), this.buildOutput(null, null, null);
|
|
216
247
|
this.releaseTimer = null;
|
|
217
|
-
const
|
|
218
|
-
if (!
|
|
248
|
+
const f = u(a) ? a : null;
|
|
249
|
+
if (!f)
|
|
219
250
|
return this.transitionTo("idle"), this.buildOutput(null, null, null);
|
|
220
|
-
const
|
|
221
|
-
let
|
|
251
|
+
const m = f.landmarks[0], p = this.panSmoother.update(m.x, m.y);
|
|
252
|
+
let I = null;
|
|
222
253
|
if (this.prevPanPos !== null) {
|
|
223
|
-
const
|
|
224
|
-
(Math.abs(
|
|
254
|
+
const b = p.x - this.prevPanPos.x, v = p.y - this.prevPanPos.y, A = this.tuning.panDeadzonePx / 640;
|
|
255
|
+
(Math.abs(b) > A || Math.abs(v) > A) && (I = { x: b, y: v });
|
|
225
256
|
}
|
|
226
|
-
return this.prevPanPos = p, this.buildOutput(
|
|
257
|
+
return this.prevPanPos = p, this.buildOutput(I, null, null);
|
|
227
258
|
}
|
|
228
259
|
if (this.mode === "zooming") {
|
|
229
|
-
if (
|
|
230
|
-
return this.
|
|
231
|
-
if (
|
|
260
|
+
if (d)
|
|
261
|
+
return this.transitionTo("rotating"), this.buildOutput(null, null, null);
|
|
262
|
+
if (g !== "zooming")
|
|
263
|
+
return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= o && this.transitionTo("idle"), this.buildOutput(null, null, null);
|
|
264
|
+
if (this.releaseTimer = null, !s)
|
|
232
265
|
return this.transitionTo("idle"), this.buildOutput(null, null, null);
|
|
233
|
-
const
|
|
266
|
+
const f = s.landmarks[0], m = this.zoomSmoother.update(f.y);
|
|
234
267
|
let p = null;
|
|
235
268
|
if (this.prevZoomDist !== null) {
|
|
236
|
-
const
|
|
237
|
-
Math.abs(
|
|
269
|
+
const I = m - this.prevZoomDist;
|
|
270
|
+
Math.abs(I) > this.tuning.zoomDeadzoneRatio && (p = -I);
|
|
238
271
|
}
|
|
239
|
-
return this.prevZoomDist =
|
|
272
|
+
return this.prevZoomDist = m, this.buildOutput(null, p, null);
|
|
240
273
|
}
|
|
241
274
|
if (this.mode === "rotating") {
|
|
242
|
-
if (
|
|
243
|
-
return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >=
|
|
244
|
-
if (this.releaseTimer = null, !a || !
|
|
275
|
+
if (g !== "rotating")
|
|
276
|
+
return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= o && this.transitionTo("idle"), this.buildOutput(null, null, null);
|
|
277
|
+
if (this.releaseTimer = null, !a || !s)
|
|
245
278
|
return this.transitionTo("idle"), this.buildOutput(null, null, null);
|
|
246
|
-
const
|
|
247
|
-
let
|
|
279
|
+
const f = a.landmarks[0], m = s.landmarks[0], p = Math.atan2(m.y - f.y, m.x - f.x), I = this.rotateSmoother.update(p);
|
|
280
|
+
let b = null;
|
|
248
281
|
if (this.prevRotateAngle !== null) {
|
|
249
|
-
let v =
|
|
250
|
-
v > Math.PI && (v -= 2 * Math.PI), v < -Math.PI && (v += 2 * Math.PI), Math.abs(v) > 5e-3 && (
|
|
282
|
+
let v = I - this.prevRotateAngle;
|
|
283
|
+
v > Math.PI && (v -= 2 * Math.PI), v < -Math.PI && (v += 2 * Math.PI), Math.abs(v) > 5e-3 && (b = v);
|
|
251
284
|
}
|
|
252
|
-
return this.prevRotateAngle =
|
|
285
|
+
return this.prevRotateAngle = I, this.buildOutput(null, null, b);
|
|
253
286
|
}
|
|
254
287
|
return this.buildOutput(null, null, null);
|
|
255
288
|
}
|
|
256
289
|
transitionTo(t) {
|
|
257
|
-
this.mode = t, this.releaseTimer = null, this.actionDwell = null, t !== "panning" && (this.panSmoother.reset(), this.prevPanPos = null), t !== "zooming" && (this.zoomSmoother.reset(), this.prevZoomDist = null), t !== "rotating" && (this.rotateSmoother.reset(), this.prevRotateAngle = null);
|
|
290
|
+
this.mode = t, this.releaseTimer = null, this.actionDwell = null, t !== "panning" && t !== "rotating" && (this.leftActiveFrames = 0), t !== "zooming" && t !== "rotating" && (this.rightActiveFrames = 0), t !== "panning" && (this.panSmoother.reset(), this.prevPanPos = null), t !== "zooming" && (this.zoomSmoother.reset(), this.prevZoomDist = null), t !== "rotating" && (this.rotateSmoother.reset(), this.prevRotateAngle = null);
|
|
258
291
|
}
|
|
259
|
-
buildOutput(t, e,
|
|
260
|
-
return { mode: this.mode, panDelta: t, zoomDelta: e, rotateDelta:
|
|
292
|
+
buildOutput(t, e, n) {
|
|
293
|
+
return { mode: this.mode, panDelta: t, zoomDelta: e, rotateDelta: n };
|
|
261
294
|
}
|
|
262
295
|
reset() {
|
|
263
296
|
this.transitionTo("idle");
|
|
264
297
|
}
|
|
265
|
-
}
|
|
266
|
-
|
|
298
|
+
};
|
|
299
|
+
l(y, "ESCALATION_FRAMES", 3);
|
|
300
|
+
let S = y;
|
|
301
|
+
const X = [
|
|
267
302
|
[0, 1],
|
|
268
303
|
[1, 2],
|
|
269
304
|
[2, 3],
|
|
@@ -293,14 +328,14 @@ const F = [
|
|
|
293
328
|
[9, 13],
|
|
294
329
|
[13, 17]
|
|
295
330
|
// palm cross
|
|
296
|
-
],
|
|
331
|
+
], V = [
|
|
297
332
|
r.THUMB_TIP,
|
|
298
333
|
r.INDEX_TIP,
|
|
299
334
|
r.MIDDLE_TIP,
|
|
300
335
|
r.RING_TIP,
|
|
301
336
|
r.PINKY_TIP
|
|
302
337
|
];
|
|
303
|
-
class
|
|
338
|
+
class q {
|
|
304
339
|
constructor(t) {
|
|
305
340
|
l(this, "container");
|
|
306
341
|
l(this, "canvas");
|
|
@@ -327,19 +362,19 @@ class V {
|
|
|
327
362
|
/** Called each frame with the latest gesture frame and mode. */
|
|
328
363
|
render(t, e) {
|
|
329
364
|
this.updateBadge(e);
|
|
330
|
-
const
|
|
331
|
-
if (this.canvas.width =
|
|
365
|
+
const n = this.config.width, o = this.config.height;
|
|
366
|
+
if (this.canvas.width = n, this.canvas.height = o, this.ctx.clearRect(0, 0, n, o), t !== null)
|
|
332
367
|
for (const a of t.hands)
|
|
333
368
|
this.drawSkeleton(a.landmarks, e, a.gesture === "fist");
|
|
334
369
|
}
|
|
335
|
-
drawSkeleton(t, e,
|
|
336
|
-
const { ctx:
|
|
337
|
-
|
|
338
|
-
for (const [
|
|
339
|
-
!t[
|
|
340
|
-
for (let
|
|
341
|
-
const
|
|
342
|
-
|
|
370
|
+
drawSkeleton(t, e, n) {
|
|
371
|
+
const { ctx: o } = this, a = this.config.width, s = this.config.height, u = (h) => (1 - h.x) * a, c = (h) => h.y * s;
|
|
372
|
+
o.strokeStyle = D.connection, o.lineWidth = 1.5;
|
|
373
|
+
for (const [h, d] of X)
|
|
374
|
+
!t[h] || !t[d] || (o.beginPath(), o.moveTo(u(t[h]), c(t[h])), o.lineTo(u(t[d]), c(t[d])), o.stroke());
|
|
375
|
+
for (let h = 0; h < t.length; h++) {
|
|
376
|
+
const d = t[h], P = V.includes(h), g = e !== "idle" && P ? D.fingertipGlow : D.landmark;
|
|
377
|
+
o.beginPath(), o.arc(u(d), c(d), P ? 5 : 3, 0, Math.PI * 2), o.fillStyle = g, o.fill(), e !== "idle" && P && (o.shadowBlur = n ? 12 : 6, o.shadowColor = D.fingertipGlow, o.fill(), o.shadowBlur = 0);
|
|
343
378
|
}
|
|
344
379
|
}
|
|
345
380
|
updateBadge(t) {
|
|
@@ -352,11 +387,11 @@ class V {
|
|
|
352
387
|
this.badge.textContent = e[t], this.badge.className = `ol-gesture-badge ol-gesture-badge--${t}`;
|
|
353
388
|
}
|
|
354
389
|
applyContainerStyles() {
|
|
355
|
-
const { mode: t, position: e, width:
|
|
390
|
+
const { mode: t, position: e, width: n, height: o, opacity: a } = this.config;
|
|
356
391
|
if (this.container.style.cssText = "", this.container.style.position = "fixed", this.container.style.zIndex = "9999", this.container.style.overflow = "hidden", this.container.style.borderRadius = "8px", this.container.style.opacity = String(a), this.container.style.display = t === "hidden" ? "none" : "block", t === "corner") {
|
|
357
|
-
this.container.style.width = `${
|
|
358
|
-
const
|
|
359
|
-
e === "bottom-right" ? (this.container.style.bottom =
|
|
392
|
+
this.container.style.width = `${n}px`, this.container.style.height = `${o}px`;
|
|
393
|
+
const s = "16px";
|
|
394
|
+
e === "bottom-right" ? (this.container.style.bottom = s, this.container.style.right = s) : e === "bottom-left" ? (this.container.style.bottom = s, this.container.style.left = s) : e === "top-right" ? (this.container.style.top = s, this.container.style.right = s) : (this.container.style.top = s, this.container.style.left = s);
|
|
360
395
|
} else t === "full" && (this.container.style.top = "0", this.container.style.left = "0", this.container.style.width = "100vw", this.container.style.height = "100vh", this.container.style.borderRadius = "0");
|
|
361
396
|
}
|
|
362
397
|
updateConfig(t) {
|
|
@@ -364,14 +399,15 @@ class V {
|
|
|
364
399
|
}
|
|
365
400
|
}
|
|
366
401
|
export {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
402
|
+
D as COLORS,
|
|
403
|
+
Z as DEFAULT_TUNING_CONFIG,
|
|
404
|
+
Y as DEFAULT_WEBCAM_CONFIG,
|
|
405
|
+
$ as GestureController,
|
|
406
|
+
S as GestureStateMachine,
|
|
372
407
|
r as LANDMARKS,
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
408
|
+
q as WebcamOverlay,
|
|
409
|
+
k as classifyGesture,
|
|
410
|
+
C as createHandClassifier,
|
|
411
|
+
w as getHandSize,
|
|
412
|
+
j as getTwoHandDistance
|
|
377
413
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type GestureMode = 'idle' | 'panning' | 'zooming' | 'rotating';
|
|
2
2
|
export type HandednessLabel = 'Left' | 'Right';
|
|
3
|
-
export type GestureType = 'openPalm' | 'fist' | 'none';
|
|
3
|
+
export type GestureType = 'openPalm' | 'fist' | 'pinch' | 'none';
|
|
4
4
|
export interface Point2D {
|
|
5
5
|
x: number;
|
|
6
6
|
y: number;
|