@map-gesture-controls/core 0.1.9 → 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 +3 -1
- package/dist/GestureStateMachine.js +1 -1
- package/dist/WebcamOverlay.d.ts +6 -2
- package/dist/WebcamOverlay.js +40 -4
- package/dist/index.js +72 -64
- package/package.json +1 -1
- package/dist/GestureStateMachine.test.d.ts +0 -1
- package/dist/GestureStateMachine.test.js +0 -264
- package/dist/gestureClassifier.test.d.ts +0 -1
- package/dist/gestureClassifier.test.js +0 -98
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ Full TypeScript types are exported for `GestureMode`, `GestureFrame`, `DetectedH
|
|
|
70
70
|
|
|
71
71
|
## Gesture recognition
|
|
72
72
|
|
|
73
|
-
Both **fist** and **pinch** trigger the same map actions
|
|
73
|
+
Both **fist** and **pinch** trigger the same map actions, users can use whichever is more comfortable.
|
|
74
74
|
|
|
75
75
|
| Gesture | Detection rule | Map action |
|
|
76
76
|
| --- | --- | --- |
|
|
@@ -81,6 +81,8 @@ Both **fist** and **pinch** trigger the same map actions — users can use which
|
|
|
81
81
|
| **Both hands active** | Both hands fist or pinch (mixed is fine) | Rotate map |
|
|
82
82
|
| **Idle** | Anything else | No action |
|
|
83
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
|
+
|
|
84
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.
|
|
85
87
|
|
|
86
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.
|
|
@@ -170,7 +170,7 @@ export class GestureStateMachine {
|
|
|
170
170
|
const { actionDwellMs, releaseGraceMs } = this.tuning;
|
|
171
171
|
const { leftHand, rightHand } = frame;
|
|
172
172
|
// ── Determine desired mode for this frame ─────────────────────────────────
|
|
173
|
-
// Both fist and pinch trigger the same modes
|
|
173
|
+
// Both fist and pinch trigger the same modes, users can choose either.
|
|
174
174
|
const isActive = (hand) => hand !== null && (hand.gesture === 'fist' || hand.gesture === 'pinch');
|
|
175
175
|
const rightActive = isActive(rightHand);
|
|
176
176
|
const leftActive = isActive(leftHand);
|
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/index.js
CHANGED
|
@@ -29,17 +29,17 @@ const Y = {
|
|
|
29
29
|
RING_MCP: 13,
|
|
30
30
|
PINKY_TIP: 20,
|
|
31
31
|
PINKY_MCP: 17
|
|
32
|
-
}, L = 0.25, z = 0.35,
|
|
32
|
+
}, L = 0.25, z = 0.35, b = [
|
|
33
33
|
r.INDEX_TIP,
|
|
34
34
|
r.MIDDLE_TIP,
|
|
35
35
|
r.RING_TIP,
|
|
36
36
|
r.PINKY_TIP
|
|
37
|
-
],
|
|
37
|
+
], N = [
|
|
38
38
|
r.INDEX_MCP,
|
|
39
39
|
r.MIDDLE_MCP,
|
|
40
40
|
r.RING_MCP,
|
|
41
41
|
r.PINKY_MCP
|
|
42
|
-
],
|
|
42
|
+
], w = {
|
|
43
43
|
idle: "#888888",
|
|
44
44
|
panning: "#00ccff",
|
|
45
45
|
zooming: "#00ffcc",
|
|
@@ -47,21 +47,21 @@ const Y = {
|
|
|
47
47
|
landmark: "rgba(255,255,255,0.6)",
|
|
48
48
|
connection: "rgba(255,255,255,0.3)",
|
|
49
49
|
fingertipGlow: "#4488ff"
|
|
50
|
-
},
|
|
50
|
+
}, B = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm";
|
|
51
51
|
function T(i, t) {
|
|
52
52
|
const e = i.x - t.x, n = i.y - t.y;
|
|
53
53
|
return Math.sqrt(e * e + n * n);
|
|
54
54
|
}
|
|
55
|
-
function
|
|
55
|
+
function _(i) {
|
|
56
56
|
const t = i[r.WRIST];
|
|
57
|
-
for (let s = 0; s <
|
|
58
|
-
const u = i[
|
|
57
|
+
for (let s = 0; s < b.length; s++) {
|
|
58
|
+
const u = i[b[s]], c = i[N[s]];
|
|
59
59
|
if (T(u, t) < T(c, t) * 0.9)
|
|
60
60
|
return !1;
|
|
61
61
|
}
|
|
62
|
-
const e =
|
|
62
|
+
const e = D(i);
|
|
63
63
|
if (e === 0) return !1;
|
|
64
|
-
const n =
|
|
64
|
+
const n = b.map((s) => i[s]);
|
|
65
65
|
let o = 1 / 0;
|
|
66
66
|
for (let s = 0; s < n.length - 1; s++) {
|
|
67
67
|
const u = T(n[s], n[s + 1]);
|
|
@@ -72,39 +72,39 @@ function N(i) {
|
|
|
72
72
|
function R(i) {
|
|
73
73
|
const t = i[r.WRIST];
|
|
74
74
|
let e = 0;
|
|
75
|
-
for (let n = 0; n <
|
|
76
|
-
const o = i[
|
|
75
|
+
for (let n = 0; n < b.length; n++) {
|
|
76
|
+
const o = i[b[n]], a = i[N[n]];
|
|
77
77
|
T(o, t) < T(a, t) * 1.1 && e++;
|
|
78
78
|
}
|
|
79
79
|
return e >= 3;
|
|
80
80
|
}
|
|
81
|
-
function
|
|
81
|
+
function D(i) {
|
|
82
82
|
const t = i[r.WRIST], e = i[r.MIDDLE_MCP];
|
|
83
83
|
return !t || !e ? 0 : T(t, e);
|
|
84
84
|
}
|
|
85
|
-
function
|
|
85
|
+
function k(i, t) {
|
|
86
86
|
const e = i[r.INDEX_TIP], n = t[r.INDEX_TIP];
|
|
87
87
|
return !e || !n ? 0 : T(e, n);
|
|
88
88
|
}
|
|
89
89
|
function F(i) {
|
|
90
|
-
const t =
|
|
90
|
+
const t = D(i);
|
|
91
91
|
if (t === 0) return !1;
|
|
92
92
|
const e = i[r.THUMB_TIP], n = i[r.INDEX_TIP];
|
|
93
93
|
return T(e, n) < t * L;
|
|
94
94
|
}
|
|
95
|
-
function
|
|
96
|
-
const t =
|
|
95
|
+
function G(i) {
|
|
96
|
+
const t = D(i);
|
|
97
97
|
if (t === 0) return !1;
|
|
98
98
|
const e = i[r.THUMB_TIP], n = i[r.INDEX_TIP];
|
|
99
99
|
return T(e, n) < t * z;
|
|
100
100
|
}
|
|
101
|
-
function
|
|
102
|
-
return i.length < 21 ? "none" : R(i) ? "fist" : F(i) ? "pinch" :
|
|
101
|
+
function j(i) {
|
|
102
|
+
return i.length < 21 ? "none" : R(i) ? "fist" : F(i) ? "pinch" : _(i) ? "openPalm" : "none";
|
|
103
103
|
}
|
|
104
|
-
function
|
|
104
|
+
function M() {
|
|
105
105
|
let i = !1;
|
|
106
106
|
return function(e) {
|
|
107
|
-
return e.length < 21 ? (i = !1, "none") : R(e) ? (i = !1, "fist") : (i ? i =
|
|
107
|
+
return e.length < 21 ? (i = !1, "none") : R(e) ? (i = !1, "fist") : (i ? i = G(e) : i = F(e), i ? "pinch" : _(e) ? "openPalm" : "none");
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
110
|
class $ {
|
|
@@ -118,8 +118,8 @@ class $ {
|
|
|
118
118
|
l(this, "tuning");
|
|
119
119
|
l(this, "lastVideoTime", -1);
|
|
120
120
|
// One stateful classifier per hand label; persists pinch hysteresis across frames.
|
|
121
|
-
l(this, "leftClassifier",
|
|
122
|
-
l(this, "rightClassifier",
|
|
121
|
+
l(this, "leftClassifier", M());
|
|
122
|
+
l(this, "rightClassifier", M());
|
|
123
123
|
this.tuning = t, this.onFrame = e;
|
|
124
124
|
}
|
|
125
125
|
/**
|
|
@@ -127,7 +127,7 @@ class $ {
|
|
|
127
127
|
* Returns the video element so the overlay can render it.
|
|
128
128
|
*/
|
|
129
129
|
async init() {
|
|
130
|
-
const t = await H.forVisionTasks(
|
|
130
|
+
const t = await H.forVisionTasks(B), { HandLandmarker: e } = await import("@mediapipe/tasks-vision");
|
|
131
131
|
return this.landmarker = await e.createFromOptions(t, {
|
|
132
132
|
baseOptions: {
|
|
133
133
|
modelAssetPath: "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task",
|
|
@@ -174,8 +174,8 @@ class $ {
|
|
|
174
174
|
}
|
|
175
175
|
buildFrame(t, e) {
|
|
176
176
|
const n = t.landmarks.map((s, u) => {
|
|
177
|
-
var
|
|
178
|
-
const c = t.handedness[u], d = ((
|
|
177
|
+
var p, g;
|
|
178
|
+
const c = t.handedness[u], d = ((p = c == null ? void 0 : c[0]) == null ? void 0 : p.categoryName) === "Left" ? "Left" : "Right", P = ((g = c == null ? void 0 : c[0]) == null ? void 0 : g.score) ?? 0, f = (d === "Left" ? this.leftClassifier : this.rightClassifier)(s);
|
|
179
179
|
return { handedness: d, score: P, landmarks: s, gesture: f };
|
|
180
180
|
}), o = n.find((s) => s.handedness === "Left") ?? null, a = n.find((s) => s.handedness === "Right") ?? null;
|
|
181
181
|
return { timestamp: e, hands: n, leftHand: o, rightHand: a };
|
|
@@ -199,7 +199,7 @@ class U {
|
|
|
199
199
|
this.value = null;
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
|
-
class
|
|
202
|
+
class C {
|
|
203
203
|
constructor(t) {
|
|
204
204
|
l(this, "value", null);
|
|
205
205
|
this.alpha = t;
|
|
@@ -211,7 +211,7 @@ class M {
|
|
|
211
211
|
this.value = null;
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
|
-
const
|
|
214
|
+
const E = class E {
|
|
215
215
|
constructor(t) {
|
|
216
216
|
l(this, "mode", "idle");
|
|
217
217
|
l(this, "actionDwell", null);
|
|
@@ -228,7 +228,7 @@ const y = class y {
|
|
|
228
228
|
// interrupting an ongoing single-hand gesture.
|
|
229
229
|
l(this, "leftActiveFrames", 0);
|
|
230
230
|
l(this, "rightActiveFrames", 0);
|
|
231
|
-
this.tuning = t, this.panSmoother = new U(t.smoothingAlpha), this.zoomSmoother = new
|
|
231
|
+
this.tuning = t, this.panSmoother = new U(t.smoothingAlpha), this.zoomSmoother = new C(t.smoothingAlpha), this.rotateSmoother = new C(t.smoothingAlpha);
|
|
232
232
|
}
|
|
233
233
|
getMode() {
|
|
234
234
|
return this.mode;
|
|
@@ -236,53 +236,53 @@ const y = class y {
|
|
|
236
236
|
update(t) {
|
|
237
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
238
|
this.leftActiveFrames = h ? this.leftActiveFrames + 1 : 0, this.rightActiveFrames = c ? this.rightActiveFrames + 1 : 0;
|
|
239
|
-
const d = this.leftActiveFrames >=
|
|
239
|
+
const d = this.leftActiveFrames >= E.ESCALATION_FRAMES && this.rightActiveFrames >= E.ESCALATION_FRAMES, m = d ? "rotating" : h && c && !d && (this.mode === "panning" || this.mode === "zooming") ? this.mode : c ? "zooming" : h ? "panning" : "idle";
|
|
240
240
|
if (this.mode === "idle")
|
|
241
|
-
return
|
|
241
|
+
return m !== "idle" ? this.actionDwell === null || this.actionDwell.gesture !== m ? this.actionDwell = { gesture: m, startMs: e } : e - this.actionDwell.startMs >= n && this.transitionTo(m) : this.actionDwell = null, this.buildOutput(null, null, null);
|
|
242
242
|
if (this.mode === "panning") {
|
|
243
243
|
if (d)
|
|
244
244
|
return this.transitionTo("rotating"), this.buildOutput(null, null, null);
|
|
245
|
-
if (
|
|
245
|
+
if (m !== "panning")
|
|
246
246
|
return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= o && this.transitionTo("idle"), this.buildOutput(null, null, null);
|
|
247
247
|
this.releaseTimer = null;
|
|
248
248
|
const f = u(a) ? a : null;
|
|
249
249
|
if (!f)
|
|
250
250
|
return this.transitionTo("idle"), this.buildOutput(null, null, null);
|
|
251
|
-
const
|
|
251
|
+
const p = f.landmarks[0], g = this.panSmoother.update(p.x, p.y);
|
|
252
252
|
let I = null;
|
|
253
253
|
if (this.prevPanPos !== null) {
|
|
254
|
-
const
|
|
255
|
-
(Math.abs(
|
|
254
|
+
const y = g.x - this.prevPanPos.x, v = g.y - this.prevPanPos.y, A = this.tuning.panDeadzonePx / 640;
|
|
255
|
+
(Math.abs(y) > A || Math.abs(v) > A) && (I = { x: y, y: v });
|
|
256
256
|
}
|
|
257
|
-
return this.prevPanPos =
|
|
257
|
+
return this.prevPanPos = g, this.buildOutput(I, null, null);
|
|
258
258
|
}
|
|
259
259
|
if (this.mode === "zooming") {
|
|
260
260
|
if (d)
|
|
261
261
|
return this.transitionTo("rotating"), this.buildOutput(null, null, null);
|
|
262
|
-
if (
|
|
262
|
+
if (m !== "zooming")
|
|
263
263
|
return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= o && this.transitionTo("idle"), this.buildOutput(null, null, null);
|
|
264
264
|
if (this.releaseTimer = null, !s)
|
|
265
265
|
return this.transitionTo("idle"), this.buildOutput(null, null, null);
|
|
266
|
-
const f = s.landmarks[0],
|
|
267
|
-
let
|
|
266
|
+
const f = s.landmarks[0], p = this.zoomSmoother.update(f.y);
|
|
267
|
+
let g = null;
|
|
268
268
|
if (this.prevZoomDist !== null) {
|
|
269
|
-
const I =
|
|
270
|
-
Math.abs(I) > this.tuning.zoomDeadzoneRatio && (
|
|
269
|
+
const I = p - this.prevZoomDist;
|
|
270
|
+
Math.abs(I) > this.tuning.zoomDeadzoneRatio && (g = -I);
|
|
271
271
|
}
|
|
272
|
-
return this.prevZoomDist =
|
|
272
|
+
return this.prevZoomDist = p, this.buildOutput(null, g, null);
|
|
273
273
|
}
|
|
274
274
|
if (this.mode === "rotating") {
|
|
275
|
-
if (
|
|
275
|
+
if (m !== "rotating")
|
|
276
276
|
return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= o && this.transitionTo("idle"), this.buildOutput(null, null, null);
|
|
277
277
|
if (this.releaseTimer = null, !a || !s)
|
|
278
278
|
return this.transitionTo("idle"), this.buildOutput(null, null, null);
|
|
279
|
-
const f = a.landmarks[0],
|
|
280
|
-
let
|
|
279
|
+
const f = a.landmarks[0], p = s.landmarks[0], g = Math.atan2(p.y - f.y, p.x - f.x), I = this.rotateSmoother.update(g);
|
|
280
|
+
let y = null;
|
|
281
281
|
if (this.prevRotateAngle !== null) {
|
|
282
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 && (
|
|
283
|
+
v > Math.PI && (v -= 2 * Math.PI), v < -Math.PI && (v += 2 * Math.PI), Math.abs(v) > 5e-3 && (y = v);
|
|
284
284
|
}
|
|
285
|
-
return this.prevRotateAngle = I, this.buildOutput(null, null,
|
|
285
|
+
return this.prevRotateAngle = I, this.buildOutput(null, null, y);
|
|
286
286
|
}
|
|
287
287
|
return this.buildOutput(null, null, null);
|
|
288
288
|
}
|
|
@@ -296,8 +296,8 @@ const y = class y {
|
|
|
296
296
|
this.transitionTo("idle");
|
|
297
297
|
}
|
|
298
298
|
};
|
|
299
|
-
l(
|
|
300
|
-
let S =
|
|
299
|
+
l(E, "ESCALATION_FRAMES", 3);
|
|
300
|
+
let S = E;
|
|
301
301
|
const X = [
|
|
302
302
|
[0, 1],
|
|
303
303
|
[1, 2],
|
|
@@ -341,8 +341,11 @@ class q {
|
|
|
341
341
|
l(this, "canvas");
|
|
342
342
|
l(this, "ctx");
|
|
343
343
|
l(this, "badge");
|
|
344
|
+
l(this, "resetBar");
|
|
345
|
+
l(this, "resetFill");
|
|
344
346
|
l(this, "config");
|
|
345
|
-
|
|
347
|
+
l(this, "lastMode", null);
|
|
348
|
+
this.config = t, this.container = document.createElement("div"), this.container.className = "ol-gesture-overlay", this.applyContainerStyles(), this.canvas = document.createElement("canvas"), this.canvas.className = "ol-gesture-canvas", this.canvas.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;", this.badge = document.createElement("div"), this.badge.className = "ol-gesture-badge ol-gesture-badge--idle", this.badge.textContent = "Idle", this.resetBar = document.createElement("div"), this.resetBar.className = "ol-gesture-reset", this.resetBar.innerHTML = '<span class="ol-gesture-reset-label">Reset</span><div class="ol-gesture-reset-track"><div class="ol-gesture-reset-fill"></div></div>', this.resetBar.style.opacity = "0", this.resetFill = this.resetBar.querySelector(".ol-gesture-reset-fill"), this.container.appendChild(this.canvas), this.container.appendChild(this.badge), this.container.appendChild(this.resetBar);
|
|
346
349
|
const e = this.canvas.getContext("2d");
|
|
347
350
|
if (!e) throw new Error("Cannot get 2D canvas context");
|
|
348
351
|
this.ctx = e;
|
|
@@ -359,25 +362,30 @@ class q {
|
|
|
359
362
|
var t;
|
|
360
363
|
(t = this.container.parentElement) == null || t.removeChild(this.container);
|
|
361
364
|
}
|
|
362
|
-
/** Called each frame with the latest gesture frame and
|
|
363
|
-
render(t, e) {
|
|
364
|
-
this.updateBadge(e);
|
|
365
|
-
const
|
|
366
|
-
if (this.canvas.width =
|
|
367
|
-
for (const
|
|
368
|
-
this.drawSkeleton(
|
|
365
|
+
/** Called each frame with the latest gesture frame, mode, and optional reset progress (0 to 1). */
|
|
366
|
+
render(t, e, n = 0) {
|
|
367
|
+
this.updateBadge(e), this.updateResetBar(n);
|
|
368
|
+
const o = this.config.width, a = this.config.height;
|
|
369
|
+
if ((this.canvas.width !== o || this.canvas.height !== a) && (this.canvas.width = o, this.canvas.height = a), this.ctx.clearRect(0, 0, o, a), t !== null)
|
|
370
|
+
for (const s of t.hands)
|
|
371
|
+
this.drawSkeleton(s.landmarks, e, s.gesture === "fist");
|
|
372
|
+
}
|
|
373
|
+
updateResetBar(t) {
|
|
374
|
+
this.resetFill.style.width = `${t * 100}%`, this.resetBar.style.opacity = t > 0 ? "1" : "0";
|
|
369
375
|
}
|
|
370
376
|
drawSkeleton(t, e, n) {
|
|
371
377
|
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 =
|
|
378
|
+
o.strokeStyle = w.connection, o.lineWidth = 1.5;
|
|
373
379
|
for (const [h, d] of X)
|
|
374
380
|
!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
381
|
for (let h = 0; h < t.length; h++) {
|
|
376
|
-
const d = t[h], P = V.includes(h),
|
|
377
|
-
o.beginPath(), o.arc(u(d), c(d), P ? 5 : 3, 0, Math.PI * 2), o.fillStyle =
|
|
382
|
+
const d = t[h], P = V.includes(h), m = e !== "idle" && P ? w.fingertipGlow : w.landmark;
|
|
383
|
+
o.beginPath(), o.arc(u(d), c(d), P ? 5 : 3, 0, Math.PI * 2), o.fillStyle = m, o.fill(), e !== "idle" && P && (o.shadowBlur = n ? 12 : 6, o.shadowColor = w.fingertipGlow, o.fill(), o.shadowBlur = 0);
|
|
378
384
|
}
|
|
379
385
|
}
|
|
380
386
|
updateBadge(t) {
|
|
387
|
+
if (t === this.lastMode) return;
|
|
388
|
+
this.lastMode = t;
|
|
381
389
|
const e = {
|
|
382
390
|
idle: "Idle",
|
|
383
391
|
panning: "Pan",
|
|
@@ -399,15 +407,15 @@ class q {
|
|
|
399
407
|
}
|
|
400
408
|
}
|
|
401
409
|
export {
|
|
402
|
-
|
|
410
|
+
w as COLORS,
|
|
403
411
|
Z as DEFAULT_TUNING_CONFIG,
|
|
404
412
|
Y as DEFAULT_WEBCAM_CONFIG,
|
|
405
413
|
$ as GestureController,
|
|
406
414
|
S as GestureStateMachine,
|
|
407
415
|
r as LANDMARKS,
|
|
408
416
|
q as WebcamOverlay,
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
417
|
+
j as classifyGesture,
|
|
418
|
+
M as createHandClassifier,
|
|
419
|
+
D as getHandSize,
|
|
420
|
+
k as getTwoHandDistance
|
|
413
421
|
};
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import { GestureStateMachine } from './GestureStateMachine.js';
|
|
3
|
-
import { LANDMARKS } from './constants.js';
|
|
4
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
5
|
-
const FAST_TUNING = {
|
|
6
|
-
actionDwellMs: 0, // dwell=0: transition fires on the SECOND frame (first sets timer)
|
|
7
|
-
releaseGraceMs: 0, // grace=0: idle on the SECOND frame after release
|
|
8
|
-
panDeadzonePx: 0,
|
|
9
|
-
zoomDeadzoneRatio: 0,
|
|
10
|
-
smoothingAlpha: 1, // no smoothing, raw values pass through
|
|
11
|
-
minDetectionConfidence: 0.65,
|
|
12
|
-
minTrackingConfidence: 0.65,
|
|
13
|
-
minPresenceConfidence: 0.60,
|
|
14
|
-
};
|
|
15
|
-
function makeLandmarks(overrides = {}) {
|
|
16
|
-
const lm = Array.from({ length: 21 }, () => ({ x: 0.5, y: 0.5, z: 0 }));
|
|
17
|
-
for (const [idx, vals] of Object.entries(overrides)) {
|
|
18
|
-
lm[Number(idx)] = { ...lm[Number(idx)], ...vals };
|
|
19
|
-
}
|
|
20
|
-
return lm;
|
|
21
|
-
}
|
|
22
|
-
function makeHand(gesture, landmarks = makeLandmarks()) {
|
|
23
|
-
return { handedness: 'Right', score: 1, landmarks, gesture };
|
|
24
|
-
}
|
|
25
|
-
function makeFrame(timestamp, leftHand, rightHand) {
|
|
26
|
-
const hands = [leftHand, rightHand].filter(Boolean);
|
|
27
|
-
return { timestamp, hands, leftHand, rightHand };
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Drive the FSM into 'panning' with dwell=0 (requires two identical frames).
|
|
31
|
-
* Pan = left fist only.
|
|
32
|
-
*/
|
|
33
|
-
function enterPanning(fsm, hand = makeHand('fist')) {
|
|
34
|
-
fsm.update(makeFrame(0, hand, null)); // starts dwell timer
|
|
35
|
-
fsm.update(makeFrame(0, hand, null)); // dwell elapsed → panning
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Drive the FSM into 'zooming' with dwell=0 (requires two identical frames).
|
|
39
|
-
* Zoom = right fist only.
|
|
40
|
-
*/
|
|
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));
|
|
60
|
-
fsm.update(makeFrame(0, left, right));
|
|
61
|
-
fsm.update(makeFrame(0, left, right));
|
|
62
|
-
}
|
|
63
|
-
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
64
|
-
describe('GestureStateMachine', () => {
|
|
65
|
-
let fsm;
|
|
66
|
-
beforeEach(() => {
|
|
67
|
-
fsm = new GestureStateMachine(FAST_TUNING);
|
|
68
|
-
});
|
|
69
|
-
// ── Initial state ──────────────────────────────────────────────────────────
|
|
70
|
-
it('starts in idle mode', () => {
|
|
71
|
-
expect(fsm.getMode()).toBe('idle');
|
|
72
|
-
});
|
|
73
|
-
// ── idle → panning ─────────────────────────────────────────────────────────
|
|
74
|
-
it('transitions to panning when left fist is held (two frames, dwell=0)', () => {
|
|
75
|
-
const fistHand = makeHand('fist');
|
|
76
|
-
fsm.update(makeFrame(0, fistHand, null)); // starts dwell
|
|
77
|
-
const out = fsm.update(makeFrame(0, fistHand, null)); // 0 >= 0 → panning
|
|
78
|
-
expect(out.mode).toBe('panning');
|
|
79
|
-
});
|
|
80
|
-
// ── idle → zooming ─────────────────────────────────────────────────────────
|
|
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));
|
|
94
|
-
fsm.update(makeFrame(0, left, right));
|
|
95
|
-
const out = fsm.update(makeFrame(0, left, right));
|
|
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');
|
|
118
|
-
});
|
|
119
|
-
// ── panning → idle ─────────────────────────────────────────────────────────
|
|
120
|
-
it('returns to idle when the left fist is released (releaseGraceMs=0)', () => {
|
|
121
|
-
enterPanning(fsm);
|
|
122
|
-
expect(fsm.getMode()).toBe('panning');
|
|
123
|
-
fsm.update(makeFrame(1, null, null)); // starts release timer
|
|
124
|
-
const out = fsm.update(makeFrame(1, null, null)); // 0 >= 0 → idle
|
|
125
|
-
expect(out.mode).toBe('idle');
|
|
126
|
-
});
|
|
127
|
-
// ── zooming → idle ─────────────────────────────────────────────────────────
|
|
128
|
-
it('returns to idle when right fist is released (releaseGraceMs=0)', () => {
|
|
129
|
-
enterZooming(fsm);
|
|
130
|
-
expect(fsm.getMode()).toBe('zooming');
|
|
131
|
-
fsm.update(makeFrame(1, null, null)); // starts release timer
|
|
132
|
-
const out = fsm.update(makeFrame(1, null, null)); // 0 >= 0 → idle
|
|
133
|
-
expect(out.mode).toBe('idle');
|
|
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
|
-
});
|
|
143
|
-
// ── panDelta output ────────────────────────────────────────────────────────
|
|
144
|
-
it('emits no panDelta on the first panning frame (no previous position)', () => {
|
|
145
|
-
const fistHand = makeHand('fist');
|
|
146
|
-
fsm.update(makeFrame(0, fistHand, null)); // dwell starts
|
|
147
|
-
const out = fsm.update(makeFrame(0, fistHand, null)); // enters panning, sets prevPos
|
|
148
|
-
expect(out.panDelta).toBeNull();
|
|
149
|
-
});
|
|
150
|
-
it('emits panDelta once a previous position is established', () => {
|
|
151
|
-
const wristPos1 = { x: 0.3, y: 0.4, z: 0 };
|
|
152
|
-
const wristPos2 = { x: 0.35, y: 0.45, z: 0 };
|
|
153
|
-
const lm1 = makeLandmarks({ [LANDMARKS.WRIST]: wristPos1 });
|
|
154
|
-
const lm2 = makeLandmarks({ [LANDMARKS.WRIST]: wristPos2 });
|
|
155
|
-
// Frame 1: start dwell (idle)
|
|
156
|
-
fsm.update(makeFrame(0, makeHand('fist', lm1), null));
|
|
157
|
-
// Frame 2: transition fires → panning, but buildOutput returns from idle branch (panDelta=null)
|
|
158
|
-
fsm.update(makeFrame(0, makeHand('fist', lm1), null));
|
|
159
|
-
// Frame 3: first real panning frame, sets prevPanPos = wristPos1, no delta yet
|
|
160
|
-
fsm.update(makeFrame(1, makeHand('fist', lm1), null));
|
|
161
|
-
// Frame 4: second panning frame, emits delta
|
|
162
|
-
const out = fsm.update(makeFrame(2, makeHand('fist', lm2), null));
|
|
163
|
-
expect(out.mode).toBe('panning');
|
|
164
|
-
expect(out.panDelta).not.toBeNull();
|
|
165
|
-
expect(out.panDelta.x).toBeCloseTo(wristPos2.x - wristPos1.x);
|
|
166
|
-
expect(out.panDelta.y).toBeCloseTo(wristPos2.y - wristPos1.y);
|
|
167
|
-
});
|
|
168
|
-
// ── zoomDelta output ───────────────────────────────────────────────────────
|
|
169
|
-
it('emits no zoomDelta on the first zooming frame', () => {
|
|
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
|
|
173
|
-
expect(out.zoomDelta).toBeNull();
|
|
174
|
-
});
|
|
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 } });
|
|
179
|
-
// Frame 1+2: dwell + transition (zooming entered on frame 2, but output comes from idle branch)
|
|
180
|
-
fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
|
|
181
|
-
fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
|
|
182
|
-
// Frame 3: first real zooming frame, sets prevZoomDist, no delta yet
|
|
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)));
|
|
186
|
-
expect(out.mode).toBe('zooming');
|
|
187
|
-
expect(out.zoomDelta).not.toBeNull();
|
|
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();
|
|
227
|
-
});
|
|
228
|
-
// ── reset ──────────────────────────────────────────────────────────────────
|
|
229
|
-
it('reset() returns the FSM to idle', () => {
|
|
230
|
-
enterPanning(fsm);
|
|
231
|
-
expect(fsm.getMode()).toBe('panning');
|
|
232
|
-
fsm.reset();
|
|
233
|
-
expect(fsm.getMode()).toBe('idle');
|
|
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
|
-
});
|
|
241
|
-
// ── dwell timer ────────────────────────────────────────────────────────────
|
|
242
|
-
it('does NOT transition before actionDwellMs elapses', () => {
|
|
243
|
-
const slowFsm = new GestureStateMachine({ ...FAST_TUNING, actionDwellMs: 200 });
|
|
244
|
-
const fistHand = makeHand('fist');
|
|
245
|
-
const out1 = slowFsm.update(makeFrame(0, fistHand, null));
|
|
246
|
-
expect(out1.mode).toBe('idle');
|
|
247
|
-
const out2 = slowFsm.update(makeFrame(100, fistHand, null)); // 100 ms < 200 ms
|
|
248
|
-
expect(out2.mode).toBe('idle');
|
|
249
|
-
const out3 = slowFsm.update(makeFrame(200, fistHand, null)); // 200 ms >= 200 ms
|
|
250
|
-
expect(out3.mode).toBe('panning');
|
|
251
|
-
});
|
|
252
|
-
// ── release grace period ───────────────────────────────────────────────────
|
|
253
|
-
it('stays in panning during release grace period', () => {
|
|
254
|
-
const graceFsm = new GestureStateMachine({ ...FAST_TUNING, releaseGraceMs: 100 });
|
|
255
|
-
enterPanning(graceFsm);
|
|
256
|
-
expect(graceFsm.getMode()).toBe('panning');
|
|
257
|
-
const out1 = graceFsm.update(makeFrame(1, null, null)); // grace starts
|
|
258
|
-
expect(out1.mode).toBe('panning');
|
|
259
|
-
const out2 = graceFsm.update(makeFrame(50, null, null)); // 50 ms < 100 ms
|
|
260
|
-
expect(out2.mode).toBe('panning');
|
|
261
|
-
const out3 = graceFsm.update(makeFrame(101, null, null)); // 101 ms >= 100 ms
|
|
262
|
-
expect(out3.mode).toBe('idle');
|
|
263
|
-
});
|
|
264
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { classifyGesture, getTwoHandDistance } from './gestureClassifier.js';
|
|
3
|
-
import { LANDMARKS } from './constants.js';
|
|
4
|
-
/**
|
|
5
|
-
* Build a flat array of 21 landmarks, all at (0.5, 0.5, 0).
|
|
6
|
-
* Individual landmarks can be overridden via the `overrides` map.
|
|
7
|
-
*/
|
|
8
|
-
function makeLandmarks(overrides = {}) {
|
|
9
|
-
const lm = Array.from({ length: 21 }, () => ({ x: 0.5, y: 0.5, z: 0 }));
|
|
10
|
-
for (const [idx, vals] of Object.entries(overrides)) {
|
|
11
|
-
lm[Number(idx)] = { ...lm[Number(idx)], ...vals };
|
|
12
|
-
}
|
|
13
|
-
return lm;
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* Build an open-palm hand.
|
|
17
|
-
*
|
|
18
|
-
* Strategy: place wrist at origin, MCP joints at y=0.3, fingertips at y=0.1
|
|
19
|
-
* (tips are farther from the wrist than MCPs) and spread them horizontally
|
|
20
|
-
* so adjacent-tip distance >> 18% of hand size.
|
|
21
|
-
*
|
|
22
|
-
* Hand size = dist(wrist, middle-MCP) = dist({0,0},{0,0.3}) = 0.3
|
|
23
|
-
* Adjacent tip spread = 0.1 per finger → 0.1 > 0.3 * 0.18 = 0.054 ✓
|
|
24
|
-
*/
|
|
25
|
-
function makeOpenPalmLandmarks() {
|
|
26
|
-
const lm = makeLandmarks();
|
|
27
|
-
// Wrist at origin
|
|
28
|
-
lm[LANDMARKS.WRIST] = { x: 0.5, y: 0.8, z: 0 };
|
|
29
|
-
// MCP joints
|
|
30
|
-
lm[LANDMARKS.INDEX_MCP] = { x: 0.3, y: 0.5, z: 0 };
|
|
31
|
-
lm[LANDMARKS.MIDDLE_MCP] = { x: 0.4, y: 0.5, z: 0 };
|
|
32
|
-
lm[LANDMARKS.RING_MCP] = { x: 0.5, y: 0.5, z: 0 };
|
|
33
|
-
lm[LANDMARKS.PINKY_MCP] = { x: 0.6, y: 0.5, z: 0 };
|
|
34
|
-
// Fingertips: extended (farther from wrist than MCPs) and spread
|
|
35
|
-
lm[LANDMARKS.INDEX_TIP] = { x: 0.2, y: 0.1, z: 0 };
|
|
36
|
-
lm[LANDMARKS.MIDDLE_TIP] = { x: 0.35, y: 0.1, z: 0 };
|
|
37
|
-
lm[LANDMARKS.RING_TIP] = { x: 0.5, y: 0.1, z: 0 };
|
|
38
|
-
lm[LANDMARKS.PINKY_TIP] = { x: 0.65, y: 0.1, z: 0 };
|
|
39
|
-
return lm;
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Build a fist hand.
|
|
43
|
-
*
|
|
44
|
-
* Strategy: wrist at (0.5, 0.8), MCP joints at (0.5, 0.5),
|
|
45
|
-
* fingertips curled back to (0.5, 0.7), closer to wrist than MCPs.
|
|
46
|
-
*
|
|
47
|
-
* dist(tip, wrist) ≈ 0.1 < dist(mcp, wrist) * 1.1 ≈ 0.33 ✓
|
|
48
|
-
*/
|
|
49
|
-
function makeFistLandmarks() {
|
|
50
|
-
const lm = makeLandmarks();
|
|
51
|
-
lm[LANDMARKS.WRIST] = { x: 0.5, y: 0.8, z: 0 };
|
|
52
|
-
lm[LANDMARKS.INDEX_MCP] = { x: 0.4, y: 0.5, z: 0 };
|
|
53
|
-
lm[LANDMARKS.MIDDLE_MCP] = { x: 0.45, y: 0.5, z: 0 };
|
|
54
|
-
lm[LANDMARKS.RING_MCP] = { x: 0.5, y: 0.5, z: 0 };
|
|
55
|
-
lm[LANDMARKS.PINKY_MCP] = { x: 0.55, y: 0.5, z: 0 };
|
|
56
|
-
lm[LANDMARKS.INDEX_TIP] = { x: 0.4, y: 0.72, z: 0 };
|
|
57
|
-
lm[LANDMARKS.MIDDLE_TIP] = { x: 0.45, y: 0.72, z: 0 };
|
|
58
|
-
lm[LANDMARKS.RING_TIP] = { x: 0.5, y: 0.72, z: 0 };
|
|
59
|
-
lm[LANDMARKS.PINKY_TIP] = { x: 0.55, y: 0.72, z: 0 };
|
|
60
|
-
return lm;
|
|
61
|
-
}
|
|
62
|
-
// ─── classifyGesture ──────────────────────────────────────────────────────────
|
|
63
|
-
describe('classifyGesture', () => {
|
|
64
|
-
it('returns "none" when fewer than 21 landmarks are provided', () => {
|
|
65
|
-
expect(classifyGesture([])).toBe('none');
|
|
66
|
-
expect(classifyGesture(makeLandmarks().slice(0, 20))).toBe('none');
|
|
67
|
-
});
|
|
68
|
-
it('classifies an open palm as "openPalm"', () => {
|
|
69
|
-
expect(classifyGesture(makeOpenPalmLandmarks())).toBe('openPalm');
|
|
70
|
-
});
|
|
71
|
-
it('classifies a fist as "fist"', () => {
|
|
72
|
-
expect(classifyGesture(makeFistLandmarks())).toBe('fist');
|
|
73
|
-
});
|
|
74
|
-
it('returns "none" for an ambiguous / neutral hand', () => {
|
|
75
|
-
// All landmarks at the same point, fingers neither extended nor curled
|
|
76
|
-
expect(classifyGesture(makeLandmarks())).toBe('none');
|
|
77
|
-
});
|
|
78
|
-
it('prefers "fist" over "openPalm" when both criteria match', () => {
|
|
79
|
-
// The function checks fist first, so a fist should never become openPalm.
|
|
80
|
-
const fist = makeFistLandmarks();
|
|
81
|
-
expect(classifyGesture(fist)).toBe('fist');
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
// ─── getTwoHandDistance ───────────────────────────────────────────────────────
|
|
85
|
-
describe('getTwoHandDistance', () => {
|
|
86
|
-
it('returns the Euclidean distance between the two index fingertips', () => {
|
|
87
|
-
const handA = makeLandmarks({ [LANDMARKS.INDEX_TIP]: { x: 0.0, y: 0.0, z: 0 } });
|
|
88
|
-
const handB = makeLandmarks({ [LANDMARKS.INDEX_TIP]: { x: 0.3, y: 0.4, z: 0 } });
|
|
89
|
-
expect(getTwoHandDistance(handA, handB)).toBeCloseTo(0.5);
|
|
90
|
-
});
|
|
91
|
-
it('returns 0 when the index fingertips are at the same position', () => {
|
|
92
|
-
const hand = makeLandmarks({ [LANDMARKS.INDEX_TIP]: { x: 0.5, y: 0.5, z: 0 } });
|
|
93
|
-
expect(getTwoHandDistance(hand, hand)).toBe(0);
|
|
94
|
-
});
|
|
95
|
-
it('returns 0 when landmarks array is empty (missing tips)', () => {
|
|
96
|
-
expect(getTwoHandDistance([], [])).toBe(0);
|
|
97
|
-
});
|
|
98
|
-
});
|