@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 CHANGED
@@ -10,13 +10,13 @@
10
10
  > Building with OpenLayers? Use [`@map-gesture-controls/ol`](https://www.npmjs.com/package/@map-gesture-controls/ol) instead. It wraps this package and adds map integration out of the box.
11
11
 
12
12
  <p align="center">
13
- <img src="https://raw.githubusercontent.com/sanderdesnaijer/map-gesture-controls/main/docs/public/openlayers-gesture-control-demo.gif" alt="Screen recording of the map gesture demo: an OpenLayers map with a small webcam preview; the user pans with a fist and zooms with two open hands, all in the browser via MediaPipe." width="720" />
13
+ <img src="https://raw.githubusercontent.com/sanderdesnaijer/map-gesture-controls/main/docs/public/openlayers-gesture-control-demo.gif" alt="Screen recording of the map gesture demo: an OpenLayers map with a small webcam preview; the user pans with the left fist, zooms with the right fist, and rotates with both fists, all in the browser via MediaPipe." width="720" />
14
14
  </p>
15
15
 
16
16
  ## What it does
17
17
 
18
18
  - Detects hands and classifies gestures at 30+ fps using MediaPipe Hand Landmarker
19
- - Recognizes **fist** (pan), **open palm** (zoom), and **idle** states
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
@@ -30,18 +30,26 @@ npm install @map-gesture-controls/core
30
30
  ## Quick start
31
31
 
32
32
  ```ts
33
- import { GestureController } from '@map-gesture-controls/core';
33
+ import {
34
+ GestureController,
35
+ GestureStateMachine,
36
+ DEFAULT_TUNING_CONFIG,
37
+ } from '@map-gesture-controls/core';
34
38
  import '@map-gesture-controls/core/style.css';
35
39
 
36
- const controller = new GestureController({
37
- onGestureFrame(frame) {
38
- // frame.hands contains detected hands with landmarks and gesture type
39
- console.log(frame);
40
- },
40
+ const tuning = DEFAULT_TUNING_CONFIG;
41
+ const stateMachine = new GestureStateMachine(tuning);
42
+
43
+ const controller = new GestureController(tuning, (frame) => {
44
+ const output = stateMachine.update(frame);
45
+ // output.mode: 'idle' | 'panning' | 'zooming' | 'rotating'
46
+ // output.panDelta, output.zoomDelta, output.rotateDelta
47
+ console.log(output);
41
48
  });
42
49
 
43
50
  // Must be called from a user interaction (button click) for webcam permission
44
- await controller.start();
51
+ await controller.init();
52
+ controller.start();
45
53
  ```
46
54
 
47
55
  ## Exports
@@ -51,7 +59,8 @@ await controller.start();
51
59
  | `GestureController` | Class | Opens the webcam, runs MediaPipe detection, and emits gesture frames |
52
60
  | `GestureStateMachine` | Class | Manages gesture state transitions with dwell and grace timers |
53
61
  | `WebcamOverlay` | Class | Renders a configurable camera preview overlay |
54
- | `classifyGesture` | Function | Classifies a set of hand landmarks into `fist`, `openPalm`, or `none` |
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) |
55
64
  | `getHandSize` | Function | Computes the bounding size of a hand from its landmarks |
56
65
  | `getTwoHandDistance` | Function | Measures the distance between two detected hands |
57
66
  | `DEFAULT_WEBCAM_CONFIG` | Constant | Default webcam overlay settings |
@@ -61,12 +70,19 @@ Full TypeScript types are exported for `GestureMode`, `GestureFrame`, `DetectedH
61
70
 
62
71
  ## Gesture recognition
63
72
 
64
- | Gesture | Detection rule | Use case |
73
+ Both **fist** and **pinch** trigger the same map actions — users can use whichever is more comfortable.
74
+
75
+ | Gesture | Detection rule | Map action |
65
76
  | --- | --- | --- |
66
- | **Fist** | One hand, 3+ fingers curled | Pan / drag |
67
- | **Open palm** | Two hands, all fingers extended and spread | Zoom in/out |
77
+ | **Left fist** | Left hand, 3+ fingers curled | Pan / drag |
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 |
68
82
  | **Idle** | Anything else | No action |
69
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
+
70
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.
71
87
 
72
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 { classifyGesture } from './gestureClassifier.js';
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 gesture = classifyGesture(landmarks);
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;
@@ -3,19 +3,24 @@ export interface StateMachineOutput {
3
3
  mode: GestureMode;
4
4
  panDelta: SmoothedPoint | null;
5
5
  zoomDelta: number | null;
6
+ rotateDelta: number | null;
6
7
  }
7
8
  /**
8
- * GestureStateMachine: 3-state FSM
9
+ * GestureStateMachine: 4-state FSM
9
10
  *
10
11
  * Priority rules (evaluated every frame):
11
- * both hands visible AND both open palm → desired = 'zooming'
12
- * one hand visible AND gesture = 'fist' desired = 'panning'
13
- * otherwise → desired = 'idle'
12
+ * both hands fist → desired = 'rotating'
13
+ * angle of the wrist-to-wrist line; delta rotates the map
14
+ * right hand fist (left absent or not fist) → desired = 'zooming'
15
+ * vertical motion of right wrist controls zoom (up = in, down = out)
16
+ * left hand fist (right absent or not fist) → desired = 'panning'
17
+ * horizontal and vertical motion of left wrist pans the map
18
+ * otherwise → desired = 'idle'
14
19
  *
15
20
  * Transitions:
16
- * idle → panning/zooming : desired stable for actionDwellMs
17
- * panning/zooming → idle : desired changes, grace period releaseGraceMs,
18
- * then idle (next frame starts new dwell if needed)
21
+ * idle → any active : desired stable for actionDwellMs
22
+ * active → idle : desired changes, grace period releaseGraceMs,
23
+ * then idle (next frame starts new dwell if needed)
19
24
  */
20
25
  export declare class GestureStateMachine {
21
26
  private tuning;
@@ -26,6 +31,11 @@ export declare class GestureStateMachine {
26
31
  private prevPanPos;
27
32
  private zoomSmoother;
28
33
  private prevZoomDist;
34
+ private rotateSmoother;
35
+ private prevRotateAngle;
36
+ private leftActiveFrames;
37
+ private rightActiveFrames;
38
+ private static readonly ESCALATION_FRAMES;
29
39
  constructor(tuning: TuningConfig);
30
40
  getMode(): GestureMode;
31
41
  update(frame: GestureFrame): StateMachineOutput;
@@ -1,4 +1,3 @@
1
- import { getTwoHandDistance } from './gestureClassifier.js';
2
1
  /**
3
2
  * Exponential Moving Average smoother for 2D points.
4
3
  */
@@ -65,17 +64,21 @@ class EMAScalar {
65
64
  }
66
65
  }
67
66
  /**
68
- * GestureStateMachine: 3-state FSM
67
+ * GestureStateMachine: 4-state FSM
69
68
  *
70
69
  * Priority rules (evaluated every frame):
71
- * both hands visible AND both open palm → desired = 'zooming'
72
- * one hand visible AND gesture = 'fist' desired = 'panning'
73
- * otherwise → desired = 'idle'
70
+ * both hands fist → desired = 'rotating'
71
+ * angle of the wrist-to-wrist line; delta rotates the map
72
+ * right hand fist (left absent or not fist) → desired = 'zooming'
73
+ * vertical motion of right wrist controls zoom (up = in, down = out)
74
+ * left hand fist (right absent or not fist) → desired = 'panning'
75
+ * horizontal and vertical motion of left wrist pans the map
76
+ * otherwise → desired = 'idle'
74
77
  *
75
78
  * Transitions:
76
- * idle → panning/zooming : desired stable for actionDwellMs
77
- * panning/zooming → idle : desired changes, grace period releaseGraceMs,
78
- * then idle (next frame starts new dwell if needed)
79
+ * idle → any active : desired stable for actionDwellMs
80
+ * active → idle : desired changes, grace period releaseGraceMs,
81
+ * then idle (next frame starts new dwell if needed)
79
82
  */
80
83
  export class GestureStateMachine {
81
84
  constructor(tuning) {
@@ -127,8 +130,37 @@ export class GestureStateMachine {
127
130
  writable: true,
128
131
  value: null
129
132
  });
133
+ Object.defineProperty(this, "rotateSmoother", {
134
+ enumerable: true,
135
+ configurable: true,
136
+ writable: true,
137
+ value: void 0
138
+ });
139
+ Object.defineProperty(this, "prevRotateAngle", {
140
+ enumerable: true,
141
+ configurable: true,
142
+ writable: true,
143
+ value: null
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
+ });
130
161
  this.panSmoother = new EMAPoint(tuning.smoothingAlpha);
131
162
  this.zoomSmoother = new EMAScalar(tuning.smoothingAlpha);
163
+ this.rotateSmoother = new EMAScalar(tuning.smoothingAlpha);
132
164
  }
133
165
  getMode() {
134
166
  return this.mode;
@@ -138,13 +170,35 @@ export class GestureStateMachine {
138
170
  const { actionDwellMs, releaseGraceMs } = this.tuning;
139
171
  const { leftHand, rightHand } = frame;
140
172
  // ── Determine desired mode for this frame ─────────────────────────────────
141
- const bothOpen = leftHand !== null &&
142
- rightHand !== null &&
143
- leftHand.gesture === 'openPalm' &&
144
- rightHand.gesture === 'openPalm';
145
- const oneFist = (leftHand !== null && leftHand.gesture === 'fist' && rightHand === null) ||
146
- (rightHand !== null && rightHand.gesture === 'fist' && leftHand === null);
147
- const desired = bothOpen ? 'zooming' : oneFist ? 'panning' : 'idle';
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
194
+ ? 'rotating'
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';
148
202
  // ── idle ──────────────────────────────────────────────────────────────────
149
203
  if (this.mode === 'idle') {
150
204
  if (desired !== 'idle') {
@@ -158,10 +212,15 @@ export class GestureStateMachine {
158
212
  else {
159
213
  this.actionDwell = null;
160
214
  }
161
- return this.buildOutput(null, null);
215
+ return this.buildOutput(null, null, null);
162
216
  }
163
217
  // ── panning ───────────────────────────────────────────────────────────────
164
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
+ }
165
224
  if (desired !== 'panning') {
166
225
  if (this.releaseTimer === null) {
167
226
  this.releaseTimer = now;
@@ -169,13 +228,13 @@ export class GestureStateMachine {
169
228
  else if (now - this.releaseTimer >= releaseGraceMs) {
170
229
  this.transitionTo('idle');
171
230
  }
172
- return this.buildOutput(null, null);
231
+ return this.buildOutput(null, null, null);
173
232
  }
174
233
  this.releaseTimer = null;
175
- const fistHand = leftHand?.gesture === 'fist' ? leftHand : rightHand;
234
+ const fistHand = isActive(leftHand) ? leftHand : null;
176
235
  if (!fistHand) {
177
236
  this.transitionTo('idle');
178
- return this.buildOutput(null, null);
237
+ return this.buildOutput(null, null, null);
179
238
  }
180
239
  const wrist = fistHand.landmarks[0];
181
240
  const smooth = this.panSmoother.update(wrist.x, wrist.y);
@@ -189,10 +248,15 @@ export class GestureStateMachine {
189
248
  }
190
249
  }
191
250
  this.prevPanPos = smooth;
192
- return this.buildOutput(panDelta, null);
251
+ return this.buildOutput(panDelta, null, null);
193
252
  }
194
253
  // ── zooming ───────────────────────────────────────────────────────────────
195
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
+ }
196
260
  if (desired !== 'zooming') {
197
261
  if (this.releaseTimer === null) {
198
262
  this.releaseTimer = now;
@@ -200,31 +264,76 @@ export class GestureStateMachine {
200
264
  else if (now - this.releaseTimer >= releaseGraceMs) {
201
265
  this.transitionTo('idle');
202
266
  }
203
- return this.buildOutput(null, null);
267
+ return this.buildOutput(null, null, null);
204
268
  }
205
269
  this.releaseTimer = null;
206
- if (!leftHand || !rightHand) {
270
+ if (!rightHand) {
207
271
  this.transitionTo('idle');
208
- return this.buildOutput(null, null);
272
+ return this.buildOutput(null, null, null);
209
273
  }
210
- const rawDist = getTwoHandDistance(leftHand.landmarks, rightHand.landmarks);
211
- const smoothDist = this.zoomSmoother.update(rawDist);
274
+ // Use right wrist vertical position: moving up (lower y) = zoom in, down = zoom out
275
+ const wrist = rightHand.landmarks[0];
276
+ const smoothY = this.zoomSmoother.update(wrist.y);
212
277
  let zoomDelta = null;
213
278
  if (this.prevZoomDist !== null) {
214
- const delta = smoothDist - this.prevZoomDist;
279
+ const delta = smoothY - this.prevZoomDist;
215
280
  if (Math.abs(delta) > this.tuning.zoomDeadzoneRatio) {
216
- zoomDelta = delta;
281
+ // Negate: moving hand up (y decreases) → zoom in (positive delta)
282
+ zoomDelta = -delta;
283
+ }
284
+ }
285
+ this.prevZoomDist = smoothY;
286
+ return this.buildOutput(null, zoomDelta, null);
287
+ }
288
+ // ── rotating ─────────────────────────────────────────────────────────────
289
+ if (this.mode === 'rotating') {
290
+ if (desired !== 'rotating') {
291
+ if (this.releaseTimer === null) {
292
+ this.releaseTimer = now;
293
+ }
294
+ else if (now - this.releaseTimer >= releaseGraceMs) {
295
+ this.transitionTo('idle');
217
296
  }
297
+ return this.buildOutput(null, null, null);
218
298
  }
219
- this.prevZoomDist = smoothDist;
220
- return this.buildOutput(null, zoomDelta);
299
+ this.releaseTimer = null;
300
+ if (!leftHand || !rightHand) {
301
+ this.transitionTo('idle');
302
+ return this.buildOutput(null, null, null);
303
+ }
304
+ // Angle of the line from left wrist to right wrist (in radians)
305
+ const lw = leftHand.landmarks[0];
306
+ const rw = rightHand.landmarks[0];
307
+ const rawAngle = Math.atan2(rw.y - lw.y, rw.x - lw.x);
308
+ const smoothAngle = this.rotateSmoother.update(rawAngle);
309
+ let rotateDelta = null;
310
+ if (this.prevRotateAngle !== null) {
311
+ // Wrap the delta to [-π, π] to handle the atan2 discontinuity
312
+ let delta = smoothAngle - this.prevRotateAngle;
313
+ if (delta > Math.PI)
314
+ delta -= 2 * Math.PI;
315
+ if (delta < -Math.PI)
316
+ delta += 2 * Math.PI;
317
+ if (Math.abs(delta) > 0.005) {
318
+ rotateDelta = delta;
319
+ }
320
+ }
321
+ this.prevRotateAngle = smoothAngle;
322
+ return this.buildOutput(null, null, rotateDelta);
221
323
  }
222
- return this.buildOutput(null, null);
324
+ return this.buildOutput(null, null, null);
223
325
  }
224
326
  transitionTo(next) {
225
327
  this.mode = next;
226
328
  this.releaseTimer = null;
227
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;
228
337
  if (next !== 'panning') {
229
338
  this.panSmoother.reset();
230
339
  this.prevPanPos = null;
@@ -233,11 +342,21 @@ export class GestureStateMachine {
233
342
  this.zoomSmoother.reset();
234
343
  this.prevZoomDist = null;
235
344
  }
345
+ if (next !== 'rotating') {
346
+ this.rotateSmoother.reset();
347
+ this.prevRotateAngle = null;
348
+ }
236
349
  }
237
- buildOutput(panDelta, zoomDelta) {
238
- return { mode: this.mode, panDelta, zoomDelta };
350
+ buildOutput(panDelta, zoomDelta, rotateDelta) {
351
+ return { mode: this.mode, panDelta, zoomDelta, rotateDelta };
239
352
  }
240
353
  reset() {
241
354
  this.transitionTo('idle');
242
355
  }
243
356
  }
357
+ Object.defineProperty(GestureStateMachine, "ESCALATION_FRAMES", {
358
+ enumerable: true,
359
+ configurable: true,
360
+ writable: true,
361
+ value: 3
362
+ });