@map-gesture-controls/core 0.1.7 → 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 +29 -13
- package/dist/GestureController.d.ts +2 -0
- package/dist/GestureController.js +16 -2
- package/dist/GestureStateMachine.d.ts +17 -7
- package/dist/GestureStateMachine.js +151 -32
- package/dist/GestureStateMachine.test.js +124 -23
- package/dist/WebcamOverlay.js +1 -0
- package/dist/constants.d.ts +3 -0
- package/dist/constants.js +7 -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 +197 -144
- package/dist/types.d.ts +2 -2
- package/package.json +1 -1
|
@@ -28,6 +28,7 @@ function makeFrame(timestamp, leftHand, rightHand) {
|
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* Drive the FSM into 'panning' with dwell=0 (requires two identical frames).
|
|
31
|
+
* Pan = left fist only.
|
|
31
32
|
*/
|
|
32
33
|
function enterPanning(fsm, hand = makeHand('fist')) {
|
|
33
34
|
fsm.update(makeFrame(0, hand, null)); // starts dwell timer
|
|
@@ -35,8 +36,27 @@ function enterPanning(fsm, hand = makeHand('fist')) {
|
|
|
35
36
|
}
|
|
36
37
|
/**
|
|
37
38
|
* Drive the FSM into 'zooming' with dwell=0 (requires two identical frames).
|
|
39
|
+
* Zoom = right fist only.
|
|
38
40
|
*/
|
|
39
|
-
function enterZooming(fsm,
|
|
41
|
+
function enterZooming(fsm, right = makeHand('fist')) {
|
|
42
|
+
fsm.update(makeFrame(0, null, right));
|
|
43
|
+
fsm.update(makeFrame(0, null, right));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
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).
|
|
50
|
+
* Rotate = both fists.
|
|
51
|
+
*/
|
|
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));
|
|
40
60
|
fsm.update(makeFrame(0, left, right));
|
|
41
61
|
fsm.update(makeFrame(0, left, right));
|
|
42
62
|
}
|
|
@@ -51,22 +71,53 @@ describe('GestureStateMachine', () => {
|
|
|
51
71
|
expect(fsm.getMode()).toBe('idle');
|
|
52
72
|
});
|
|
53
73
|
// ── idle → panning ─────────────────────────────────────────────────────────
|
|
54
|
-
it('transitions to panning
|
|
74
|
+
it('transitions to panning when left fist is held (two frames, dwell=0)', () => {
|
|
55
75
|
const fistHand = makeHand('fist');
|
|
56
76
|
fsm.update(makeFrame(0, fistHand, null)); // starts dwell
|
|
57
77
|
const out = fsm.update(makeFrame(0, fistHand, null)); // 0 >= 0 → panning
|
|
58
78
|
expect(out.mode).toBe('panning');
|
|
59
79
|
});
|
|
60
80
|
// ── idle → zooming ─────────────────────────────────────────────────────────
|
|
61
|
-
it('transitions to zooming when
|
|
62
|
-
const
|
|
63
|
-
|
|
81
|
+
it('transitions to zooming when right fist is held (two frames, dwell=0)', () => {
|
|
82
|
+
const right = makeHand('fist');
|
|
83
|
+
fsm.update(makeFrame(0, null, right));
|
|
84
|
+
const out = fsm.update(makeFrame(0, null, right));
|
|
85
|
+
expect(out.mode).toBe('zooming');
|
|
86
|
+
});
|
|
87
|
+
// ── idle → rotating ────────────────────────────────────────────────────────
|
|
88
|
+
it('transitions to rotating when both fists are held stably (5 frames: dwell + 3 escalation)', () => {
|
|
89
|
+
const left = makeHand('fist');
|
|
90
|
+
const right = makeHand('fist');
|
|
91
|
+
fsm.update(makeFrame(0, left, right));
|
|
92
|
+
fsm.update(makeFrame(0, left, right));
|
|
93
|
+
fsm.update(makeFrame(0, left, right));
|
|
64
94
|
fsm.update(makeFrame(0, left, right));
|
|
65
95
|
const out = fsm.update(makeFrame(0, left, right));
|
|
66
|
-
expect(out.mode).toBe('
|
|
96
|
+
expect(out.mode).toBe('rotating');
|
|
97
|
+
});
|
|
98
|
+
it('prefers rotating over zooming once both fists are held stably', () => {
|
|
99
|
+
const left = makeHand('fist');
|
|
100
|
+
const right = makeHand('fist');
|
|
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));
|
|
105
|
+
const out = fsm.update(makeFrame(0, left, right));
|
|
106
|
+
// both fists stable for ESCALATION_FRAMES = rotating, not zooming
|
|
107
|
+
expect(out.mode).toBe('rotating');
|
|
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');
|
|
67
118
|
});
|
|
68
119
|
// ── panning → idle ─────────────────────────────────────────────────────────
|
|
69
|
-
it('returns to idle when the fist is released (releaseGraceMs=0)', () => {
|
|
120
|
+
it('returns to idle when the left fist is released (releaseGraceMs=0)', () => {
|
|
70
121
|
enterPanning(fsm);
|
|
71
122
|
expect(fsm.getMode()).toBe('panning');
|
|
72
123
|
fsm.update(makeFrame(1, null, null)); // starts release timer
|
|
@@ -74,13 +125,21 @@ describe('GestureStateMachine', () => {
|
|
|
74
125
|
expect(out.mode).toBe('idle');
|
|
75
126
|
});
|
|
76
127
|
// ── zooming → idle ─────────────────────────────────────────────────────────
|
|
77
|
-
it('returns to idle when
|
|
128
|
+
it('returns to idle when right fist is released (releaseGraceMs=0)', () => {
|
|
78
129
|
enterZooming(fsm);
|
|
79
130
|
expect(fsm.getMode()).toBe('zooming');
|
|
80
131
|
fsm.update(makeFrame(1, null, null)); // starts release timer
|
|
81
132
|
const out = fsm.update(makeFrame(1, null, null)); // 0 >= 0 → idle
|
|
82
133
|
expect(out.mode).toBe('idle');
|
|
83
134
|
});
|
|
135
|
+
// ── rotating → idle ────────────────────────────────────────────────────────
|
|
136
|
+
it('returns to idle when both fists are released (releaseGraceMs=0)', () => {
|
|
137
|
+
enterRotating(fsm);
|
|
138
|
+
expect(fsm.getMode()).toBe('rotating');
|
|
139
|
+
fsm.update(makeFrame(1, null, null)); // starts release timer
|
|
140
|
+
const out = fsm.update(makeFrame(1, null, null)); // 0 >= 0 → idle
|
|
141
|
+
expect(out.mode).toBe('idle');
|
|
142
|
+
});
|
|
84
143
|
// ── panDelta output ────────────────────────────────────────────────────────
|
|
85
144
|
it('emits no panDelta on the first panning frame (no previous position)', () => {
|
|
86
145
|
const fistHand = makeHand('fist');
|
|
@@ -108,27 +167,63 @@ describe('GestureStateMachine', () => {
|
|
|
108
167
|
});
|
|
109
168
|
// ── zoomDelta output ───────────────────────────────────────────────────────
|
|
110
169
|
it('emits no zoomDelta on the first zooming frame', () => {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
fsm.update(makeFrame(0,
|
|
114
|
-
const out = fsm.update(makeFrame(0, left, right)); // enters zooming, sets prevZoomDist
|
|
170
|
+
const right = makeHand('fist');
|
|
171
|
+
fsm.update(makeFrame(0, null, right));
|
|
172
|
+
const out = fsm.update(makeFrame(0, null, right)); // enters zooming, sets prevZoomDist
|
|
115
173
|
expect(out.zoomDelta).toBeNull();
|
|
116
174
|
});
|
|
117
|
-
it('emits zoomDelta once a previous
|
|
118
|
-
|
|
119
|
-
const lmR1 = makeLandmarks({ [LANDMARKS.
|
|
120
|
-
const
|
|
121
|
-
const lmR2 = makeLandmarks({ [LANDMARKS.INDEX_TIP]: { x: 0.7, y: 0.5, z: 0 } });
|
|
175
|
+
it('emits zoomDelta once a previous position is established', () => {
|
|
176
|
+
// Right wrist starts lower (y=0.6), then moves up (y=0.4) → zoom in (positive delta)
|
|
177
|
+
const lmR1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.6, z: 0 } });
|
|
178
|
+
const lmR2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.4, z: 0 } });
|
|
122
179
|
// Frame 1+2: dwell + transition (zooming entered on frame 2, but output comes from idle branch)
|
|
123
|
-
fsm.update(makeFrame(0,
|
|
124
|
-
fsm.update(makeFrame(0,
|
|
180
|
+
fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
|
|
181
|
+
fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
|
|
125
182
|
// Frame 3: first real zooming frame, sets prevZoomDist, no delta yet
|
|
126
|
-
fsm.update(makeFrame(1,
|
|
127
|
-
// Frame 4:
|
|
128
|
-
const out = fsm.update(makeFrame(2,
|
|
183
|
+
fsm.update(makeFrame(1, null, makeHand('fist', lmR1)));
|
|
184
|
+
// Frame 4: hand moved up → zoom in (positive delta)
|
|
185
|
+
const out = fsm.update(makeFrame(2, null, makeHand('fist', lmR2)));
|
|
129
186
|
expect(out.mode).toBe('zooming');
|
|
130
187
|
expect(out.zoomDelta).not.toBeNull();
|
|
131
|
-
expect(out.zoomDelta).toBeGreaterThan(0);
|
|
188
|
+
expect(out.zoomDelta).toBeGreaterThan(0); // hand moved up = zoom in
|
|
189
|
+
});
|
|
190
|
+
it('emits negative zoomDelta when hand moves down (zoom out)', () => {
|
|
191
|
+
const lmR1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.4, z: 0 } });
|
|
192
|
+
const lmR2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.6, z: 0 } });
|
|
193
|
+
fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
|
|
194
|
+
fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
|
|
195
|
+
fsm.update(makeFrame(1, null, makeHand('fist', lmR1)));
|
|
196
|
+
const out = fsm.update(makeFrame(2, null, makeHand('fist', lmR2)));
|
|
197
|
+
expect(out.zoomDelta).not.toBeNull();
|
|
198
|
+
expect(out.zoomDelta).toBeLessThan(0); // hand moved down = zoom out
|
|
199
|
+
});
|
|
200
|
+
// ── rotateDelta output ─────────────────────────────────────────────────────
|
|
201
|
+
it('emits no rotateDelta on the first rotating frame', () => {
|
|
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')));
|
|
208
|
+
expect(out.rotateDelta).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
it('emits rotateDelta once a previous angle is established', () => {
|
|
211
|
+
// Left wrist at (0.2, 0.5), right wrist at (0.8, 0.5) → angle = 0 (horizontal)
|
|
212
|
+
const lmL1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.2, y: 0.5, z: 0 } });
|
|
213
|
+
const lmR1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.8, y: 0.5, z: 0 } });
|
|
214
|
+
// Tilt clockwise: right wrist drops, left wrist rises → angle increases
|
|
215
|
+
const lmL2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.2, y: 0.4, z: 0 } });
|
|
216
|
+
const lmR2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.8, y: 0.6, z: 0 } });
|
|
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
|
|
222
|
+
fsm.update(makeFrame(1, makeHand('fist', lmL1), makeHand('fist', lmR1)));
|
|
223
|
+
// Second frame: emits delta
|
|
224
|
+
const out = fsm.update(makeFrame(2, makeHand('fist', lmL2), makeHand('fist', lmR2)));
|
|
225
|
+
expect(out.mode).toBe('rotating');
|
|
226
|
+
expect(out.rotateDelta).not.toBeNull();
|
|
132
227
|
});
|
|
133
228
|
// ── reset ──────────────────────────────────────────────────────────────────
|
|
134
229
|
it('reset() returns the FSM to idle', () => {
|
|
@@ -137,6 +232,12 @@ describe('GestureStateMachine', () => {
|
|
|
137
232
|
fsm.reset();
|
|
138
233
|
expect(fsm.getMode()).toBe('idle');
|
|
139
234
|
});
|
|
235
|
+
it('reset() returns the FSM to idle from rotating', () => {
|
|
236
|
+
enterRotating(fsm);
|
|
237
|
+
expect(fsm.getMode()).toBe('rotating');
|
|
238
|
+
fsm.reset();
|
|
239
|
+
expect(fsm.getMode()).toBe('idle');
|
|
240
|
+
});
|
|
140
241
|
// ── dwell timer ────────────────────────────────────────────────────────────
|
|
141
242
|
it('does NOT transition before actionDwellMs elapses', () => {
|
|
142
243
|
const slowFsm = new GestureStateMachine({ ...FAST_TUNING, actionDwellMs: 200 });
|
package/dist/WebcamOverlay.js
CHANGED
package/dist/constants.d.ts
CHANGED
|
@@ -13,12 +13,15 @@ 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: {
|
|
19
21
|
readonly idle: "#888888";
|
|
20
22
|
readonly panning: "#00ccff";
|
|
21
23
|
readonly zooming: "#00ffcc";
|
|
24
|
+
readonly rotating: "#ff9900";
|
|
22
25
|
readonly landmark: "rgba(255,255,255,0.6)";
|
|
23
26
|
readonly connection: "rgba(255,255,255,0.3)";
|
|
24
27
|
readonly fingertipGlow: "#4488ff";
|
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,
|
|
@@ -47,6 +53,7 @@ export const COLORS = {
|
|
|
47
53
|
idle: '#888888',
|
|
48
54
|
panning: '#00ccff',
|
|
49
55
|
zooming: '#00ffcc',
|
|
56
|
+
rotating: '#ff9900',
|
|
50
57
|
landmark: 'rgba(255,255,255,0.6)',
|
|
51
58
|
connection: 'rgba(255,255,255,0.3)',
|
|
52
59
|
fingertipGlow: '#4488ff',
|
|
@@ -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';
|