hand-guest-control 0.1.0 → 0.3.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/dist/index.js ADDED
@@ -0,0 +1,819 @@
1
+ // src/constants.ts
2
+ var HAND_STATES = {
3
+ NOT_DETECTED: "not_detected",
4
+ WAITING: "waiting",
5
+ STARTING: "starting",
6
+ OPEN: "open",
7
+ CLOSED: "closed",
8
+ POINTING: "pointing",
9
+ FLIPPING: "flipping",
10
+ THUMB_UP: "thumb_up",
11
+ SWIPE_NEXT: "swipe_next",
12
+ SWIPE_PREVIOUS: "swipe_previous",
13
+ PALM_LEFT: "palm_left",
14
+ PALM_RIGHT: "palm_right",
15
+ PALM_UP: "palm_up",
16
+ PALM_DOWN: "palm_down"
17
+ };
18
+ var GESTURE_ACTIONS = {
19
+ NONE: "none",
20
+ SWIPE_LEFT: "swipe_left",
21
+ SWIPE_RIGHT: "swipe_right",
22
+ ZOOM_IN: "zoom_in",
23
+ ZOOM_OUT: "zoom_out",
24
+ ROTATE_LEFT: "rotate_left",
25
+ ROTATE_RIGHT: "rotate_right",
26
+ ROTATE_UP: "rotate_up",
27
+ ROTATE_DOWN: "rotate_down",
28
+ OK: "ok",
29
+ CUSTOM: "custom"
30
+ };
31
+ var LANDMARKS = {
32
+ WRIST: 0,
33
+ THUMB_TIP: 4,
34
+ INDEX_TIP: 8,
35
+ MIDDLE_TIP: 12,
36
+ RING_TIP: 16,
37
+ PINKY_TIP: 20
38
+ };
39
+ var HAND_CONNECTIONS = [
40
+ [0, 1],
41
+ [1, 2],
42
+ [2, 3],
43
+ [3, 4],
44
+ [0, 5],
45
+ [5, 6],
46
+ [6, 7],
47
+ [7, 8],
48
+ [0, 9],
49
+ [9, 10],
50
+ [10, 11],
51
+ [11, 12],
52
+ [0, 13],
53
+ [13, 14],
54
+ [14, 15],
55
+ [15, 16],
56
+ [0, 17],
57
+ [17, 18],
58
+ [18, 19],
59
+ [19, 20],
60
+ [5, 9],
61
+ [9, 13],
62
+ [13, 17]
63
+ ];
64
+ var DEFAULT_ACTION_MAP = {
65
+ [HAND_STATES.SWIPE_PREVIOUS]: GESTURE_ACTIONS.SWIPE_LEFT,
66
+ [HAND_STATES.SWIPE_NEXT]: GESTURE_ACTIONS.SWIPE_RIGHT,
67
+ [HAND_STATES.OPEN]: GESTURE_ACTIONS.ZOOM_OUT,
68
+ [HAND_STATES.CLOSED]: GESTURE_ACTIONS.ZOOM_IN,
69
+ [HAND_STATES.THUMB_UP]: GESTURE_ACTIONS.OK,
70
+ [HAND_STATES.PALM_LEFT]: GESTURE_ACTIONS.ROTATE_LEFT,
71
+ [HAND_STATES.PALM_RIGHT]: GESTURE_ACTIONS.ROTATE_RIGHT,
72
+ [HAND_STATES.PALM_UP]: GESTURE_ACTIONS.ROTATE_UP,
73
+ [HAND_STATES.PALM_DOWN]: GESTURE_ACTIONS.ROTATE_DOWN,
74
+ [HAND_STATES.FLIPPING]: GESTURE_ACTIONS.CUSTOM
75
+ };
76
+ var DEFAULT_CONFIG = {
77
+ maxHistorySize: 10,
78
+ maxCentroidHistorySize: 10,
79
+ rotationSensitivity: 4,
80
+ detectionTolerance: 2e3,
81
+ startingDelay: 1e3,
82
+ waitingTimeout: 3e3,
83
+ minOpenConfidence: 5,
84
+ minPointingConfidence: 25,
85
+ minFlippingConfidence: 25,
86
+ minClosedConfidence: 5,
87
+ minThumbUpConfidence: 15,
88
+ minPointingCount: 5,
89
+ pointingThreshold: 0.3,
90
+ swipeSource: "index",
91
+ swipeMinRange: 0.03,
92
+ swipeCooldownMs: 2e3,
93
+ swipeMinHistory: 6,
94
+ palmDirectionThreshold: 0.025,
95
+ palmDirectionMinFrames: 6,
96
+ rotationActionThreshold: 0.5,
97
+ enableContinuousRotation: true,
98
+ mapActions: true,
99
+ actionMap: null,
100
+ openHandThresholds: {
101
+ fingerSpread: 0.2,
102
+ indexToMiddle: 0.15,
103
+ minExtendedFingers: 4,
104
+ maxExtendedFingers: 6,
105
+ middleAngle: 50
106
+ },
107
+ thumbUpThresholds: {
108
+ minThumbExtension: 0.9,
109
+ maxOtherFingersRatio: 0.8
110
+ },
111
+ debug: false
112
+ };
113
+
114
+ // src/HandGestureDetector.ts
115
+ var HandGestureDetector = class _HandGestureDetector {
116
+ config;
117
+ constructor(config) {
118
+ this.config = config;
119
+ }
120
+ static getDistance(p1, p2) {
121
+ const dx = p1.x - p2.x;
122
+ const dy = p1.y - p2.y;
123
+ return Math.sqrt(dx * dx + dy * dy);
124
+ }
125
+ static getAngle(p1, p2, p3) {
126
+ const v1x = p2.x - p1.x;
127
+ const v1y = p2.y - p1.y;
128
+ const v2x = p3.x - p2.x;
129
+ const v2y = p3.y - p2.y;
130
+ const dot = v1x * v2x + v1y * v2y;
131
+ const det = v1x * v2y - v1y * v2x;
132
+ let angle = Math.atan2(det, dot) * 180 / Math.PI;
133
+ return angle < 0 ? angle + 360 : angle;
134
+ }
135
+ computeDistances(landmarks) {
136
+ const wrist = landmarks[LANDMARKS.WRIST];
137
+ const tips = [
138
+ LANDMARKS.THUMB_TIP,
139
+ LANDMARKS.INDEX_TIP,
140
+ LANDMARKS.MIDDLE_TIP,
141
+ LANDMARKS.RING_TIP,
142
+ LANDMARKS.PINKY_TIP
143
+ ];
144
+ const fingerNames = ["Thumb", "Index", "Middle", "Ring", "Pinky"];
145
+ const adjacentNames = ["thumbToIndex", "indexToMiddle", "middleToRing", "ringToPinky"];
146
+ const distances = {};
147
+ tips.forEach((tip, i) => {
148
+ const palmKey = `palmTo${fingerNames[i]}`;
149
+ distances[palmKey] = _HandGestureDetector.getDistance(wrist, landmarks[tip]);
150
+ if (i > 0) {
151
+ distances[adjacentNames[i - 1]] = _HandGestureDetector.getDistance(
152
+ landmarks[tips[i - 1]],
153
+ landmarks[tip]
154
+ );
155
+ }
156
+ });
157
+ return distances;
158
+ }
159
+ getNormalizedFingerLengths(distances) {
160
+ const handSize = (distances.palmToThumb + distances.palmToMiddle) / 2 || 0.15;
161
+ return {
162
+ thumb: distances.palmToThumb / handSize,
163
+ index: distances.palmToIndex / handSize,
164
+ middle: distances.palmToMiddle / handSize,
165
+ ring: distances.palmToRing / handSize,
166
+ pinky: distances.palmToPinky / handSize,
167
+ thumbToIndex: distances.thumbToIndex / handSize
168
+ };
169
+ }
170
+ getTimedEntries(history) {
171
+ return history.filter((entry) => entry !== null).sort((a, b) => a.time - b.time || (a.seq ?? 0) - (b.seq ?? 0));
172
+ }
173
+ getDirectionDelta(first, last) {
174
+ return {
175
+ dx: last.x - first.x,
176
+ dy: last.y - first.y
177
+ };
178
+ }
179
+ checkOpenHand(distances, histories, indices) {
180
+ const normalized = this.getNormalizedFingerLengths(distances);
181
+ if (this.config.debug) {
182
+ console.log(`Hand state check (open): thumb=${normalized.thumb.toFixed(2)}, index=${normalized.index.toFixed(2)}, middle=${normalized.middle.toFixed(2)}, ring=${normalized.ring.toFixed(2)}, pinky=${normalized.pinky.toFixed(2)}, thumbToIndex=${normalized.thumbToIndex.toFixed(2)}`);
183
+ }
184
+ const fingers = ["index", "middle", "ring", "pinky"];
185
+ const extended = fingers.filter((finger) => normalized[finger] > normalized.thumb).length;
186
+ const isCandidate = extended >= this.config.openHandThresholds.minExtendedFingers;
187
+ histories.open[indices.open] = isCandidate;
188
+ indices.open = (indices.open + 1) % this.config.minOpenConfidence;
189
+ return histories.open.filter(Boolean).length >= Math.ceil(this.config.minOpenConfidence * 0.7);
190
+ }
191
+ checkClosedHand(distances, histories, indices) {
192
+ const normalized = this.getNormalizedFingerLengths(distances);
193
+ if (this.config.debug) {
194
+ console.log(`Hand state check (closed): thumb=${normalized.thumb.toFixed(2)}, index=${normalized.index.toFixed(2)}, middle=${normalized.middle.toFixed(2)}, ring=${normalized.ring.toFixed(2)}, pinky=${normalized.pinky.toFixed(2)}, thumbToIndex=${normalized.thumbToIndex.toFixed(2)}`);
195
+ }
196
+ const fingers = ["index", "middle", "ring", "pinky"];
197
+ const closedFingers = fingers.filter((finger) => normalized[finger] < normalized.thumb).length;
198
+ const isCandidate = closedFingers >= this.config.openHandThresholds.minExtendedFingers;
199
+ histories.closed[indices.closed] = isCandidate;
200
+ indices.closed = (indices.closed + 1) % this.config.minClosedConfidence;
201
+ return histories.closed.filter(Boolean).length >= Math.ceil(this.config.minClosedConfidence * 0.7);
202
+ }
203
+ isPointing(distances, histories, indices) {
204
+ const handSize = distances.palmToIndex || 0.15;
205
+ const normalized = {
206
+ index: distances.palmToIndex / handSize,
207
+ middle: distances.palmToMiddle / handSize,
208
+ ring: distances.palmToRing / handSize,
209
+ pinky: distances.palmToPinky / handSize,
210
+ thumb: distances.palmToThumb / handSize
211
+ };
212
+ const otherFingers = [normalized.middle, normalized.ring, normalized.pinky, normalized.thumb];
213
+ const isIndexExtended = 0.5 > Math.max(...otherFingers) && normalized.thumb < 0.7;
214
+ histories.pointing[indices.pointing] = isIndexExtended;
215
+ indices.pointing = (indices.pointing + 1) % this.config.minPointingConfidence;
216
+ return histories.pointing.filter(Boolean).length >= Math.ceil(this.config.minPointingConfidence * 0.9);
217
+ }
218
+ isThumbUp(distances, landmarks, histories, indices) {
219
+ const normalized = this.getNormalizedFingerLengths(distances);
220
+ const { minThumbExtension, maxOtherFingersRatio } = this.config.thumbUpThresholds;
221
+ const thumbTip = landmarks[LANDMARKS.THUMB_TIP];
222
+ const wrist = landmarks[LANDMARKS.WRIST];
223
+ const othersClosed = ["index", "middle", "ring", "pinky"].every((finger) => normalized[finger] < normalized.thumb * maxOtherFingersRatio);
224
+ const thumbExtended = normalized.thumb >= minThumbExtension;
225
+ const thumbAboveWrist = thumbTip.y < wrist.y;
226
+ const isCandidate = thumbExtended && othersClosed && thumbAboveWrist;
227
+ histories.thumbUp[indices.thumbUp] = isCandidate;
228
+ indices.thumbUp = (indices.thumbUp + 1) % this.config.minThumbUpConfidence;
229
+ return histories.thumbUp.filter(Boolean).length >= Math.ceil(this.config.minThumbUpConfidence * 0.8);
230
+ }
231
+ isFlipping(distances, histories, indices) {
232
+ const handSize = distances.palmToIndex || 0.15;
233
+ const normalized = {
234
+ index: distances.palmToIndex / handSize,
235
+ middle: distances.palmToMiddle / handSize,
236
+ ring: distances.palmToRing / handSize,
237
+ pinky: distances.palmToPinky / handSize,
238
+ thumb: distances.palmToThumb / handSize
239
+ };
240
+ if (this.config.debug) {
241
+ console.log(`Hand state check (flip): index=${normalized.index.toFixed(2)}, middle=${normalized.middle.toFixed(2)}, ring=${normalized.ring.toFixed(2)}, pinky=${normalized.pinky.toFixed(2)}, thumb=${normalized.thumb.toFixed(2)}`);
242
+ }
243
+ const isFlippingGesture = normalized.middle > 0.8 && Math.max(normalized.ring, normalized.pinky, normalized.thumb) < 0.5;
244
+ histories.flipping[indices.flipping] = isFlippingGesture;
245
+ indices.flipping = (indices.flipping + 1) % this.config.minFlippingConfidence;
246
+ return histories.flipping.filter(Boolean).length >= Math.ceil(this.config.minFlippingConfidence * 0.9);
247
+ }
248
+ getSwipeHistory(histories) {
249
+ return this.config.swipeSource === "index" ? histories.indexTip : histories.landmark0;
250
+ }
251
+ checkSwipeNext(swipeHistory, distances, histories, indices, pointingCount, lastActionTime) {
252
+ if (!this.isPointing(distances, histories, indices) || this.getTimedEntries(swipeHistory).length < this.config.swipeMinHistory || pointingCount < this.config.minPointingCount || Date.now() - lastActionTime < this.config.swipeCooldownMs) {
253
+ return false;
254
+ }
255
+ const entries = this.getTimedEntries(swipeHistory);
256
+ const xPositions = entries.map((e) => e.x);
257
+ const xRange = Math.max(...xPositions) - Math.min(...xPositions);
258
+ const firstX = entries[0].x;
259
+ const lastX = entries[entries.length - 1].x;
260
+ return xRange > this.config.swipeMinRange && lastX - firstX > this.config.swipeMinRange;
261
+ }
262
+ checkSwipePrevious(swipeHistory, distances, histories, indices, pointingCount, lastActionTime) {
263
+ if (!this.isPointing(distances, histories, indices) || this.getTimedEntries(swipeHistory).length < this.config.swipeMinHistory || pointingCount < this.config.minPointingCount || Date.now() - lastActionTime < this.config.swipeCooldownMs) {
264
+ return false;
265
+ }
266
+ const entries = this.getTimedEntries(swipeHistory);
267
+ const xPositions = entries.map((e) => e.x);
268
+ const xRange = Math.max(...xPositions) - Math.min(...xPositions);
269
+ const firstX = entries[0].x;
270
+ const lastX = entries[entries.length - 1].x;
271
+ return xRange > this.config.swipeMinRange && firstX - lastX > this.config.swipeMinRange;
272
+ }
273
+ detectPalmDirection(centroidHistory) {
274
+ const entries = this.getTimedEntries(centroidHistory);
275
+ if (entries.length < this.config.palmDirectionMinFrames) {
276
+ return null;
277
+ }
278
+ const { dx, dy } = this.getDirectionDelta(entries[0], entries[entries.length - 1]);
279
+ const threshold = this.config.palmDirectionThreshold;
280
+ if (Math.abs(dx) >= Math.abs(dy) && Math.abs(dx) > threshold) {
281
+ return dx > 0 ? HAND_STATES.PALM_RIGHT : HAND_STATES.PALM_LEFT;
282
+ }
283
+ if (Math.abs(dy) > threshold) {
284
+ return dy > 0 ? HAND_STATES.PALM_DOWN : HAND_STATES.PALM_UP;
285
+ }
286
+ return null;
287
+ }
288
+ };
289
+
290
+ // src/ActionMapper.ts
291
+ function resolveRotationAction(rotation, threshold = 0.5) {
292
+ const [vertical, horizontal] = rotation;
293
+ const absVertical = Math.abs(vertical);
294
+ const absHorizontal = Math.abs(horizontal);
295
+ if (absVertical < threshold && absHorizontal < threshold) {
296
+ return null;
297
+ }
298
+ if (absHorizontal >= absVertical) {
299
+ return horizontal > 0 ? GESTURE_ACTIONS.ROTATE_RIGHT : GESTURE_ACTIONS.ROTATE_LEFT;
300
+ }
301
+ return vertical > 0 ? GESTURE_ACTIONS.ROTATE_DOWN : GESTURE_ACTIONS.ROTATE_UP;
302
+ }
303
+ var ActionMapper = class {
304
+ actionMap;
305
+ rotationThreshold;
306
+ enableContinuousRotation;
307
+ constructor(config = {}) {
308
+ this.actionMap = {
309
+ ...DEFAULT_ACTION_MAP,
310
+ ...config.actionMap ?? {}
311
+ };
312
+ this.rotationThreshold = config.rotationActionThreshold ?? 0.5;
313
+ this.enableContinuousRotation = config.enableContinuousRotation ?? true;
314
+ }
315
+ map(result) {
316
+ const base = {
317
+ ...result,
318
+ gesture: result.gesture ?? result.state
319
+ };
320
+ const actions = [];
321
+ const add = (action) => {
322
+ if (action && action !== GESTURE_ACTIONS.NONE && !actions.includes(action)) {
323
+ actions.push(action);
324
+ }
325
+ };
326
+ add(this.actionMap[base.state]);
327
+ if (base.palmDirection) {
328
+ add(this.actionMap[base.palmDirection]);
329
+ }
330
+ const rotationStates = [HAND_STATES.OPEN, HAND_STATES.CLOSED];
331
+ if (this.enableContinuousRotation && rotationStates.includes(base.state) && !base.palmDirection) {
332
+ add(resolveRotationAction(base.rotation, this.rotationThreshold));
333
+ }
334
+ return {
335
+ ...base,
336
+ actions,
337
+ primaryAction: actions[0] ?? GESTURE_ACTIONS.NONE
338
+ };
339
+ }
340
+ };
341
+ function mapGestureToAction(result, actionMap = DEFAULT_ACTION_MAP, options = {}) {
342
+ const mapper = new ActionMapper({
343
+ actionMap,
344
+ rotationActionThreshold: options.rotationActionThreshold,
345
+ enableContinuousRotation: options.enableContinuousRotation
346
+ });
347
+ return mapper.map(result);
348
+ }
349
+
350
+ // src/validate.ts
351
+ var ConfigValidationError = class extends Error {
352
+ issues;
353
+ constructor(issues) {
354
+ super(`Invalid hand-guest-control config: ${issues.join("; ")}`);
355
+ this.name = "ConfigValidationError";
356
+ this.issues = issues;
357
+ }
358
+ };
359
+ function isPositiveInt(value, field, issues) {
360
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
361
+ issues.push(`${field} must be a positive integer`);
362
+ }
363
+ }
364
+ function isPositiveNumber(value, field, issues) {
365
+ if (typeof value !== "number" || Number.isNaN(value) || value <= 0) {
366
+ issues.push(`${field} must be a positive number`);
367
+ }
368
+ }
369
+ function isNonNegativeInt(value, field, issues) {
370
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
371
+ issues.push(`${field} must be a non-negative integer`);
372
+ }
373
+ }
374
+ function isRatio(value, field, issues) {
375
+ if (typeof value !== "number" || Number.isNaN(value) || value < 0 || value > 1) {
376
+ issues.push(`${field} must be between 0 and 1`);
377
+ }
378
+ }
379
+ function isSwipeSource(value, issues) {
380
+ if (value !== "index" && value !== "wrist") {
381
+ issues.push(`swipeSource must be "index" or "wrist"`);
382
+ }
383
+ }
384
+ function validateHandConfig(config) {
385
+ const issues = [];
386
+ if (config.maxHistorySize !== void 0) isPositiveInt(config.maxHistorySize, "maxHistorySize", issues);
387
+ if (config.maxCentroidHistorySize !== void 0) {
388
+ isPositiveInt(config.maxCentroidHistorySize, "maxCentroidHistorySize", issues);
389
+ }
390
+ if (config.rotationSensitivity !== void 0) {
391
+ isPositiveNumber(config.rotationSensitivity, "rotationSensitivity", issues);
392
+ }
393
+ if (config.detectionTolerance !== void 0) {
394
+ isPositiveInt(config.detectionTolerance, "detectionTolerance", issues);
395
+ }
396
+ if (config.startingDelay !== void 0) isPositiveInt(config.startingDelay, "startingDelay", issues);
397
+ if (config.waitingTimeout !== void 0) isPositiveInt(config.waitingTimeout, "waitingTimeout", issues);
398
+ if (config.minOpenConfidence !== void 0) isPositiveInt(config.minOpenConfidence, "minOpenConfidence", issues);
399
+ if (config.minPointingConfidence !== void 0) {
400
+ isPositiveInt(config.minPointingConfidence, "minPointingConfidence", issues);
401
+ }
402
+ if (config.minFlippingConfidence !== void 0) {
403
+ isPositiveInt(config.minFlippingConfidence, "minFlippingConfidence", issues);
404
+ }
405
+ if (config.minClosedConfidence !== void 0) {
406
+ isPositiveInt(config.minClosedConfidence, "minClosedConfidence", issues);
407
+ }
408
+ if (config.minThumbUpConfidence !== void 0) {
409
+ isPositiveInt(config.minThumbUpConfidence, "minThumbUpConfidence", issues);
410
+ }
411
+ if (config.minPointingCount !== void 0) isPositiveInt(config.minPointingCount, "minPointingCount", issues);
412
+ if (config.pointingThreshold !== void 0) isRatio(config.pointingThreshold, "pointingThreshold", issues);
413
+ if (config.swipeSource !== void 0) isSwipeSource(config.swipeSource, issues);
414
+ if (config.swipeMinRange !== void 0) isPositiveNumber(config.swipeMinRange, "swipeMinRange", issues);
415
+ if (config.swipeCooldownMs !== void 0) isNonNegativeInt(config.swipeCooldownMs, "swipeCooldownMs", issues);
416
+ if (config.swipeMinHistory !== void 0) isPositiveInt(config.swipeMinHistory, "swipeMinHistory", issues);
417
+ if (config.palmDirectionThreshold !== void 0) {
418
+ isPositiveNumber(config.palmDirectionThreshold, "palmDirectionThreshold", issues);
419
+ }
420
+ if (config.palmDirectionMinFrames !== void 0) {
421
+ isPositiveInt(config.palmDirectionMinFrames, "palmDirectionMinFrames", issues);
422
+ }
423
+ if (config.rotationActionThreshold !== void 0) {
424
+ isPositiveNumber(config.rotationActionThreshold, "rotationActionThreshold", issues);
425
+ }
426
+ if (config.mapActions !== void 0 && typeof config.mapActions !== "boolean") {
427
+ issues.push("mapActions must be a boolean");
428
+ }
429
+ if (config.debug !== void 0 && typeof config.debug !== "boolean") {
430
+ issues.push("debug must be a boolean");
431
+ }
432
+ if (config.enableContinuousRotation !== void 0 && typeof config.enableContinuousRotation !== "boolean") {
433
+ issues.push("enableContinuousRotation must be a boolean");
434
+ }
435
+ if (config.openHandThresholds) {
436
+ const t = config.openHandThresholds;
437
+ if (t.minExtendedFingers !== void 0 && (!Number.isInteger(t.minExtendedFingers) || t.minExtendedFingers < 1)) {
438
+ issues.push("openHandThresholds.minExtendedFingers must be >= 1");
439
+ }
440
+ }
441
+ if (config.thumbUpThresholds) {
442
+ const t = config.thumbUpThresholds;
443
+ if (t.minThumbExtension !== void 0) {
444
+ isPositiveNumber(t.minThumbExtension, "thumbUpThresholds.minThumbExtension", issues);
445
+ }
446
+ if (t.maxOtherFingersRatio !== void 0) {
447
+ isPositiveNumber(t.maxOtherFingersRatio, "thumbUpThresholds.maxOtherFingersRatio", issues);
448
+ }
449
+ }
450
+ if (issues.length > 0) {
451
+ throw new ConfigValidationError(issues);
452
+ }
453
+ }
454
+ function mergeHandConfig(config = {}) {
455
+ validateHandConfig(config);
456
+ return {
457
+ ...DEFAULT_CONFIG,
458
+ ...config,
459
+ openHandThresholds: {
460
+ ...DEFAULT_CONFIG.openHandThresholds,
461
+ ...config.openHandThresholds ?? {}
462
+ },
463
+ thumbUpThresholds: {
464
+ ...DEFAULT_CONFIG.thumbUpThresholds,
465
+ ...config.thumbUpThresholds ?? {}
466
+ },
467
+ actionMap: config.actionMap ?? DEFAULT_CONFIG.actionMap
468
+ };
469
+ }
470
+
471
+ // src/HandStateManager.ts
472
+ var CONFIDENCE_BY_HISTORY = {
473
+ open: "minOpenConfidence",
474
+ pointing: "minPointingConfidence",
475
+ closed: "minClosedConfidence",
476
+ flipping: "minFlippingConfidence",
477
+ thumbUp: "minThumbUpConfidence"
478
+ };
479
+ var HISTORY_KEY_BY_STATE = {
480
+ [HAND_STATES.OPEN]: "open",
481
+ [HAND_STATES.POINTING]: "pointing",
482
+ [HAND_STATES.CLOSED]: "closed",
483
+ [HAND_STATES.FLIPPING]: "flipping",
484
+ [HAND_STATES.THUMB_UP]: "thumbUp"
485
+ };
486
+ var ROTATION_DISABLED_STATES = [
487
+ HAND_STATES.POINTING,
488
+ HAND_STATES.SWIPE_NEXT,
489
+ HAND_STATES.SWIPE_PREVIOUS,
490
+ HAND_STATES.FLIPPING,
491
+ HAND_STATES.THUMB_UP
492
+ ];
493
+ var PALM_DIRECTION_STATES = [
494
+ HAND_STATES.OPEN,
495
+ HAND_STATES.CLOSED
496
+ ];
497
+ var HandStateManager = class {
498
+ config;
499
+ gestureDetector;
500
+ actionMapper;
501
+ histories;
502
+ indices;
503
+ prevLandmark0 = null;
504
+ lastActionTime = 0;
505
+ pointingCount = 0;
506
+ flippingCount = 0;
507
+ detectionPausedUntil = 0;
508
+ noDetectionStartTime = 0;
509
+ lastDetectionTime = 0;
510
+ lastState = null;
511
+ lastPalmDirection = null;
512
+ frameSeq = 0;
513
+ constructor(config = {}) {
514
+ this.config = mergeHandConfig(config);
515
+ this.gestureDetector = new HandGestureDetector(this.config);
516
+ this.actionMapper = new ActionMapper({
517
+ actionMap: this.config.actionMap ?? void 0,
518
+ rotationActionThreshold: this.config.rotationActionThreshold,
519
+ enableContinuousRotation: this.config.enableContinuousRotation
520
+ });
521
+ this.initializeState();
522
+ }
523
+ initializeState() {
524
+ this.histories = {
525
+ centroid: new Array(this.config.maxCentroidHistorySize).fill(null),
526
+ landmark0: new Array(this.config.maxHistorySize).fill(null),
527
+ indexTip: new Array(this.config.maxHistorySize).fill(null),
528
+ open: new Array(this.config.minOpenConfidence).fill(false),
529
+ pointing: new Array(this.config.minPointingConfidence).fill(false),
530
+ flipping: new Array(this.config.minFlippingConfidence).fill(false),
531
+ closed: new Array(this.config.minClosedConfidence).fill(false),
532
+ thumbUp: new Array(this.config.minThumbUpConfidence).fill(false)
533
+ };
534
+ this.indices = {
535
+ history: 0,
536
+ indexTip: 0,
537
+ centroid: 0,
538
+ open: 0,
539
+ pointing: 0,
540
+ flipping: 0,
541
+ closed: 0,
542
+ thumbUp: 0
543
+ };
544
+ this.resetTrackingState();
545
+ }
546
+ resetTrackingState() {
547
+ this.prevLandmark0 = null;
548
+ this.lastActionTime = 0;
549
+ this.pointingCount = 0;
550
+ this.flippingCount = 0;
551
+ this.detectionPausedUntil = 0;
552
+ this.noDetectionStartTime = 0;
553
+ this.lastDetectionTime = 0;
554
+ this.lastState = null;
555
+ this.lastPalmDirection = null;
556
+ this.frameSeq = 0;
557
+ }
558
+ buildResult(state, rotation = [0, 0, 0], palmDirection = null) {
559
+ const result = {
560
+ state,
561
+ gesture: state,
562
+ rotation,
563
+ palmDirection
564
+ };
565
+ if (!this.config.mapActions) {
566
+ return {
567
+ ...result,
568
+ actions: [],
569
+ primaryAction: GESTURE_ACTIONS.NONE
570
+ };
571
+ }
572
+ return this.actionMapper.map(result);
573
+ }
574
+ calculateCentroid(landmarks, now) {
575
+ if (!landmarks?.length) {
576
+ return { x: 0, y: 0 };
577
+ }
578
+ const wrist = landmarks[LANDMARKS.WRIST] ?? landmarks[0];
579
+ const indexTip = landmarks[LANDMARKS.INDEX_TIP];
580
+ this.frameSeq += 1;
581
+ const pointMeta = { time: now, seq: this.frameSeq };
582
+ this.histories.landmark0[this.indices.history] = {
583
+ x: wrist.x,
584
+ y: wrist.y,
585
+ ...pointMeta
586
+ };
587
+ this.indices.history = (this.indices.history + 1) % this.config.maxHistorySize;
588
+ if (indexTip) {
589
+ this.histories.indexTip[this.indices.indexTip] = {
590
+ x: indexTip.x,
591
+ y: indexTip.y,
592
+ ...pointMeta
593
+ };
594
+ this.indices.indexTip = (this.indices.indexTip + 1) % this.config.maxHistorySize;
595
+ }
596
+ const validPoints = this.histories.landmark0.filter((p) => p !== null);
597
+ if (!validPoints.length) {
598
+ return { x: wrist.x, y: wrist.y };
599
+ }
600
+ const sum = validPoints.reduce(
601
+ (acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }),
602
+ { x: 0, y: 0 }
603
+ );
604
+ return {
605
+ x: sum.x / validPoints.length,
606
+ y: sum.y / validPoints.length
607
+ };
608
+ }
609
+ getRotation(landmarks, centroid) {
610
+ if (!landmarks[LANDMARKS.MIDDLE_TIP]) {
611
+ return [0, 0, 0];
612
+ }
613
+ const handSize = HandGestureDetector.getDistance(
614
+ landmarks[LANDMARKS.WRIST],
615
+ landmarks[LANDMARKS.THUMB_TIP]
616
+ ) || 0.15;
617
+ const scaleFactor = 0.15 / handSize;
618
+ const delta = this.prevLandmark0 ? {
619
+ x: (centroid.x - this.prevLandmark0.x) * scaleFactor,
620
+ y: (centroid.y - this.prevLandmark0.y) * scaleFactor
621
+ } : { x: 0, y: 0 };
622
+ this.prevLandmark0 = { x: centroid.x, y: centroid.y };
623
+ return [
624
+ delta.y * this.config.rotationSensitivity,
625
+ delta.x * this.config.rotationSensitivity,
626
+ 0
627
+ ];
628
+ }
629
+ handleNoDetection(now) {
630
+ this.pointingCount = 0;
631
+ this.flippingCount = 0;
632
+ this.lastPalmDirection = null;
633
+ if (!this.noDetectionStartTime) {
634
+ this.noDetectionStartTime = now;
635
+ }
636
+ const noDetectionDuration = now - this.noDetectionStartTime;
637
+ if (noDetectionDuration < this.config.detectionTolerance) {
638
+ return this.buildResult(this.lastState ?? HAND_STATES.NOT_DETECTED);
639
+ }
640
+ if (noDetectionDuration >= this.config.waitingTimeout) {
641
+ this.lastState = HAND_STATES.WAITING;
642
+ if (this.config.debug) {
643
+ console.log(`Transitioned to WAITING after ${this.config.waitingTimeout / 1e3}s`);
644
+ }
645
+ return this.buildResult(HAND_STATES.WAITING);
646
+ }
647
+ this.lastState = HAND_STATES.NOT_DETECTED;
648
+ if (this.config.debug) {
649
+ console.log(`Transitioned to NOT_DETECTED after ${this.config.detectionTolerance / 1e3}s`);
650
+ }
651
+ return this.buildResult(HAND_STATES.NOT_DETECTED);
652
+ }
653
+ handleDetectionReconnection(now) {
654
+ const isWaitingOrNotDetected = this.lastState === HAND_STATES.WAITING || this.lastState === HAND_STATES.NOT_DETECTED;
655
+ const isReconnection = this.lastState !== HAND_STATES.WAITING && this.lastState !== HAND_STATES.NOT_DETECTED && this.lastState !== HAND_STATES.STARTING && now - this.lastDetectionTime <= this.config.detectionTolerance;
656
+ if (isReconnection) {
657
+ if (this.config.debug) {
658
+ console.log(
659
+ `Short reconnection (${(now - this.lastDetectionTime) / 1e3}s), continuing with last state: ${this.lastState}`
660
+ );
661
+ }
662
+ } else if (isWaitingOrNotDetected) {
663
+ this.detectionPausedUntil = now + this.config.startingDelay;
664
+ this.noDetectionStartTime = 0;
665
+ this.lastState = HAND_STATES.STARTING;
666
+ return this.buildResult(HAND_STATES.STARTING);
667
+ }
668
+ return null;
669
+ }
670
+ determineHandState(distances, landmarks) {
671
+ const swipeHistory = this.gestureDetector.getSwipeHistory(this.histories);
672
+ if (this.gestureDetector.checkSwipeNext(
673
+ swipeHistory,
674
+ distances,
675
+ this.histories,
676
+ this.indices,
677
+ this.pointingCount,
678
+ this.lastActionTime
679
+ )) {
680
+ this.resetSwipeState();
681
+ return HAND_STATES.SWIPE_NEXT;
682
+ }
683
+ if (this.gestureDetector.checkSwipePrevious(
684
+ swipeHistory,
685
+ distances,
686
+ this.histories,
687
+ this.indices,
688
+ this.pointingCount,
689
+ this.lastActionTime
690
+ )) {
691
+ this.resetSwipeState();
692
+ return HAND_STATES.SWIPE_PREVIOUS;
693
+ }
694
+ if (this.gestureDetector.isThumbUp(distances, landmarks, this.histories, this.indices)) {
695
+ return HAND_STATES.THUMB_UP;
696
+ }
697
+ if (this.gestureDetector.isFlipping(distances, this.histories, this.indices)) {
698
+ return HAND_STATES.FLIPPING;
699
+ }
700
+ if (this.gestureDetector.checkClosedHand(distances, this.histories, this.indices)) {
701
+ return HAND_STATES.CLOSED;
702
+ }
703
+ if (this.gestureDetector.checkOpenHand(distances, this.histories, this.indices)) {
704
+ return HAND_STATES.OPEN;
705
+ }
706
+ if (this.gestureDetector.isPointing(distances, this.histories, this.indices)) {
707
+ return HAND_STATES.POINTING;
708
+ }
709
+ return HAND_STATES.CLOSED;
710
+ }
711
+ resetSwipeState() {
712
+ this.histories.landmark0.fill(null);
713
+ this.histories.indexTip.fill(null);
714
+ this.indices.history = 0;
715
+ this.indices.indexTip = 0;
716
+ this.lastActionTime = Date.now();
717
+ this.pointingCount = 0;
718
+ this.flippingCount = 0;
719
+ }
720
+ validateStateTransition(newState) {
721
+ const validTransitionStates = [
722
+ HAND_STATES.OPEN,
723
+ HAND_STATES.POINTING,
724
+ HAND_STATES.CLOSED,
725
+ HAND_STATES.FLIPPING,
726
+ HAND_STATES.THUMB_UP
727
+ ];
728
+ if (newState === this.lastState || !validTransitionStates.includes(newState)) {
729
+ return newState;
730
+ }
731
+ const historyKey = HISTORY_KEY_BY_STATE[newState];
732
+ if (!historyKey) {
733
+ return newState;
734
+ }
735
+ const confidenceKey = CONFIDENCE_BY_HISTORY[historyKey];
736
+ const requiredConfidence = this.config[confidenceKey];
737
+ const actualConfidence = this.histories[historyKey].filter(Boolean).length;
738
+ if (actualConfidence < requiredConfidence * 0.9) {
739
+ return this.lastState ?? newState;
740
+ }
741
+ return newState;
742
+ }
743
+ clearAllHistory() {
744
+ this.histories.centroid.fill(null);
745
+ this.histories.landmark0.fill(null);
746
+ this.histories.indexTip.fill(null);
747
+ this.histories.open.fill(false);
748
+ this.histories.pointing.fill(false);
749
+ this.histories.flipping.fill(false);
750
+ this.histories.closed.fill(false);
751
+ this.histories.thumbUp.fill(false);
752
+ Object.keys(this.indices).forEach((key) => {
753
+ this.indices[key] = 0;
754
+ });
755
+ this.resetTrackingState();
756
+ }
757
+ processLandmarks(landmarks) {
758
+ const now = Date.now();
759
+ if (this.detectionPausedUntil > now) {
760
+ if (this.config.debug) {
761
+ console.log(`In STARTING state until ${new Date(this.detectionPausedUntil).toLocaleTimeString()}`);
762
+ }
763
+ return this.buildResult(HAND_STATES.STARTING);
764
+ }
765
+ if (!landmarks?.[LANDMARKS.PINKY_TIP]) {
766
+ return this.handleNoDetection(now);
767
+ }
768
+ this.lastDetectionTime = now;
769
+ const reconnectionResult = this.handleDetectionReconnection(now);
770
+ if (reconnectionResult) {
771
+ return reconnectionResult;
772
+ }
773
+ this.noDetectionStartTime = 0;
774
+ this.detectionPausedUntil = 0;
775
+ const distances = this.gestureDetector.computeDistances(landmarks);
776
+ const centroid = this.calculateCentroid(landmarks, now);
777
+ this.histories.centroid[this.indices.centroid] = {
778
+ x: centroid.x,
779
+ y: centroid.y,
780
+ time: now,
781
+ seq: this.frameSeq
782
+ };
783
+ this.indices.centroid = (this.indices.centroid + 1) % this.config.maxCentroidHistorySize;
784
+ const rotation = this.getRotation(landmarks, centroid);
785
+ this.pointingCount += this.gestureDetector.isPointing(distances, this.histories, this.indices) ? 1 : 0;
786
+ this.flippingCount += this.gestureDetector.isFlipping(distances, this.histories, this.indices) ? 1 : 0;
787
+ let newState = this.determineHandState(distances, landmarks);
788
+ newState = this.validateStateTransition(newState);
789
+ let palmDirection = null;
790
+ if (PALM_DIRECTION_STATES.includes(newState)) {
791
+ palmDirection = this.gestureDetector.detectPalmDirection(this.histories.centroid);
792
+ this.lastPalmDirection = palmDirection;
793
+ } else {
794
+ this.lastPalmDirection = null;
795
+ }
796
+ this.lastState = newState;
797
+ if (ROTATION_DISABLED_STATES.includes(newState)) {
798
+ rotation.fill(0);
799
+ }
800
+ return this.buildResult(newState, rotation, palmDirection);
801
+ }
802
+ };
803
+ export {
804
+ ActionMapper,
805
+ ConfigValidationError,
806
+ DEFAULT_ACTION_MAP,
807
+ DEFAULT_CONFIG,
808
+ GESTURE_ACTIONS,
809
+ HAND_CONNECTIONS,
810
+ HAND_STATES,
811
+ HandGestureDetector,
812
+ HandStateManager,
813
+ LANDMARKS,
814
+ mapGestureToAction,
815
+ mergeHandConfig,
816
+ resolveRotationAction,
817
+ validateHandConfig
818
+ };
819
+ //# sourceMappingURL=index.js.map