tperm-visor 1.0.1 → 1.0.4
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/backend/cube/renderer.py +42 -3
- package/backend/gesture_engine.py +21 -4
- package/backend/server.py +36 -35
- package/backend/utils/transforms.py +0 -37
- package/bin/t-perm.js +1 -1
- package/frontend/css/style.css +5 -0
- package/frontend/index.html +2 -2
- package/package.json +1 -1
package/backend/cube/renderer.py
CHANGED
|
@@ -101,6 +101,8 @@ _BOUND_CORNERS = [(x, y, z) for x in (-2.8, 2.8)
|
|
|
101
101
|
for y in (-2.8, 2.8)
|
|
102
102
|
for z in (-2.8, 2.8)]
|
|
103
103
|
|
|
104
|
+
_PROBE_DEG = 0.5 # probe angle for measuring slice screen direction
|
|
105
|
+
|
|
104
106
|
_POINTERS_3D = []
|
|
105
107
|
for _x in [-1.6, 0.0, 1.6]:
|
|
106
108
|
for _y in [-1.6, 0.0, 1.6]:
|
|
@@ -156,6 +158,7 @@ class CubeRenderer:
|
|
|
156
158
|
self._ready = False
|
|
157
159
|
self._body_list = None # display list: one cubie body
|
|
158
160
|
self._sticker_base = None # 54 consecutive display lists, one per sticker
|
|
161
|
+
self._slice_dirs = {} # face_key -> unit screen dir of the grabbed pointer
|
|
159
162
|
|
|
160
163
|
def init_gl(self):
|
|
161
164
|
self._fbo = glGenFramebuffers(1)
|
|
@@ -329,9 +332,9 @@ class CubeRenderer:
|
|
|
329
332
|
|
|
330
333
|
for p3d in _POINTERS_3D:
|
|
331
334
|
try:
|
|
332
|
-
#
|
|
333
|
-
#
|
|
334
|
-
#
|
|
335
|
+
# Project the UN-rotated pointer: while you drag, the slice turns
|
|
336
|
+
# but your finger stays near where you grabbed, so the un-rotated
|
|
337
|
+
# position is the right hit-test target.
|
|
335
338
|
win_x, win_y, win_z = gluProject(p3d[0], p3d[1], p3d[2], modelview, projection, viewport)
|
|
336
339
|
if 0 <= win_z <= 1:
|
|
337
340
|
cv_y = h - win_y
|
|
@@ -339,6 +342,34 @@ class CubeRenderer:
|
|
|
339
342
|
except Exception:
|
|
340
343
|
pass
|
|
341
344
|
|
|
345
|
+
# Screen direction each pointer travels for a small POSITIVE turn of each
|
|
346
|
+
# layer it belongs to. Measured through the very matrices used to draw
|
|
347
|
+
# this frame, rather than re-deriving the transform by hand - an analytic
|
|
348
|
+
# version disagreed with the renderer on 88% of random orientations.
|
|
349
|
+
self._slice_dirs = {}
|
|
350
|
+
if highlighted_pointer is not None:
|
|
351
|
+
try:
|
|
352
|
+
base = gluProject(highlighted_pointer[0], highlighted_pointer[1],
|
|
353
|
+
highlighted_pointer[2], modelview, projection, viewport)
|
|
354
|
+
p = np.asarray(highlighted_pointer, dtype=float)
|
|
355
|
+
for face_key in ('U', 'D', 'E', 'R', 'L', 'M'):
|
|
356
|
+
if not _is_in_layer(face_key, *highlighted_pointer):
|
|
357
|
+
continue
|
|
358
|
+
axis = np.asarray(_get_rotation_axis(face_key), dtype=float)
|
|
359
|
+
# render() applies glRotatef(-angle, axis), so probe with -eps
|
|
360
|
+
theta = np.radians(-_PROBE_DEG)
|
|
361
|
+
c, s_ = np.cos(theta), np.sin(theta)
|
|
362
|
+
moved = (p * c + np.cross(axis, p) * s_
|
|
363
|
+
+ axis * np.dot(axis, p) * (1.0 - c))
|
|
364
|
+
wx, wy, _wz = gluProject(moved[0], moved[1], moved[2],
|
|
365
|
+
modelview, projection, viewport)
|
|
366
|
+
d = np.array([wx - base[0], (h - wy) - (h - base[1])])
|
|
367
|
+
n = np.linalg.norm(d)
|
|
368
|
+
if n > 1e-9:
|
|
369
|
+
self._slice_dirs[face_key] = d / n
|
|
370
|
+
except Exception:
|
|
371
|
+
self._slice_dirs = {}
|
|
372
|
+
|
|
342
373
|
# Read back only the cube's bounding box, not the whole frame. A full 720p
|
|
343
374
|
# RGBA readback is 3.7 MB across the bus every tick and dominates the
|
|
344
375
|
# frame; the cube usually covers a few percent of the screen.
|
|
@@ -379,6 +410,14 @@ class CubeRenderer:
|
|
|
379
410
|
|
|
380
411
|
return cv_frame, pointers_2d
|
|
381
412
|
|
|
413
|
+
def slice_directions(self):
|
|
414
|
+
"""Screen direction each candidate layer pushes the grabbed pointer.
|
|
415
|
+
|
|
416
|
+
Populated during render() for whichever pointer is highlighted, i.e. the
|
|
417
|
+
one currently grabbed. Empty when nothing is grabbed.
|
|
418
|
+
"""
|
|
419
|
+
return self._slice_dirs
|
|
420
|
+
|
|
382
421
|
def cleanup(self):
|
|
383
422
|
if self._body_list:
|
|
384
423
|
glDeleteLists(self._body_list, 1)
|
|
@@ -62,10 +62,27 @@ def is_open_palm(hand: HandData) -> bool:
|
|
|
62
62
|
return extended >= 4 and thumb_idx_dist > 0.08
|
|
63
63
|
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
# Hysteresis band for the pinch. A single threshold makes the gesture drop out
|
|
66
|
+
# whenever landmark noise nudges the distance across it, which ends a drag
|
|
67
|
+
# mid-swipe. Requiring a firm pinch to START but a clearly-open hand to RELEASE
|
|
68
|
+
# means jitter around the boundary cannot break contact.
|
|
69
|
+
PINCH_CLOSE = 0.055 # must get at least this close to begin pinching
|
|
70
|
+
PINCH_OPEN = 0.085 # must open at least this wide to stop
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def pinch_distance(hand: HandData) -> float:
|
|
74
|
+
"""Thumb tip to index tip, in normalised units."""
|
|
75
|
+
return _norm_dist(hand.landmarks, 4, 8)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_pinch(hand: HandData, was_pinching: bool = False) -> bool:
|
|
79
|
+
"""True while the thumb and index finger are pinched together.
|
|
80
|
+
|
|
81
|
+
Pass the previous frame's state to get hysteresis; without it this is a
|
|
82
|
+
plain threshold at PINCH_CLOSE.
|
|
83
|
+
"""
|
|
84
|
+
d = pinch_distance(hand)
|
|
85
|
+
return d < (PINCH_OPEN if was_pinching else PINCH_CLOSE)
|
|
69
86
|
|
|
70
87
|
|
|
71
88
|
# ── Palm orientation ──────────────────────────────────────────────────────────
|
package/backend/server.py
CHANGED
|
@@ -28,8 +28,8 @@ from gesture_engine import (
|
|
|
28
28
|
from cube.rubiks import solved_state, scramble, is_solved, apply_move
|
|
29
29
|
from cube.renderer import CubeRenderer, LAYER_TURN_MOVE
|
|
30
30
|
from utils.smoothing import EMA, QuatEMA
|
|
31
|
-
from utils.transforms import (quat_multiply, quat_conjugate,
|
|
32
|
-
|
|
31
|
+
from utils.transforms import (quat_multiply, quat_conjugate, snap_to_nearest_90,
|
|
32
|
+
hand_orientation_quat)
|
|
33
33
|
import hud
|
|
34
34
|
|
|
35
35
|
|
|
@@ -224,7 +224,7 @@ class AREngine:
|
|
|
224
224
|
snapping = False
|
|
225
225
|
active_pointer_3d = None
|
|
226
226
|
drag_start_pos = None
|
|
227
|
-
|
|
227
|
+
drag_sign = 1.0
|
|
228
228
|
DRAG_LOCK_THRESHOLD = 15
|
|
229
229
|
pinch_released = True
|
|
230
230
|
|
|
@@ -493,43 +493,45 @@ class AREngine:
|
|
|
493
493
|
prev_hands = {h.label: h for h in hands}
|
|
494
494
|
|
|
495
495
|
elif state == State.DRAGGING_SLICE:
|
|
496
|
-
|
|
496
|
+
# Already pinching: use the wide release threshold so a
|
|
497
|
+
# noisy frame cannot break the drag half-way through.
|
|
498
|
+
pinching_hand = next(
|
|
499
|
+
(h for h in hands if is_pinch(h, was_pinching=True)), None)
|
|
497
500
|
if pinching_hand and not snapping:
|
|
498
501
|
px = pinching_hand.landmarks[8].x * frame_w
|
|
499
502
|
py = pinching_hand.landmarks[8].y * frame_h
|
|
500
503
|
dx = px - drag_start_pos[0]
|
|
501
504
|
dy = py - drag_start_pos[1]
|
|
502
505
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
face_rot_angle = sign * proj / 2.0
|
|
506
|
+
# Both the layer and the direction come from
|
|
507
|
+
# renderer._slice_dirs: the screen direction each
|
|
508
|
+
# candidate layer would push the grabbed cubie,
|
|
509
|
+
# measured through the same matrices that drew the
|
|
510
|
+
# frame. Deriving this analytically did not match the
|
|
511
|
+
# renderer on 88% of random orientations, so it is
|
|
512
|
+
# measured rather than computed.
|
|
513
|
+
swipe = np.array([dx, dy], dtype=float)
|
|
514
|
+
swipe_len = float(np.linalg.norm(swipe))
|
|
515
|
+
|
|
516
|
+
if face_rot_face is None and swipe_len > DRAG_LOCK_THRESHOLD:
|
|
517
|
+
best_align = 0.0
|
|
518
|
+
for key, direction in renderer.slice_directions().items():
|
|
519
|
+
align = float(np.dot(direction, swipe / swipe_len))
|
|
520
|
+
if abs(align) > abs(best_align):
|
|
521
|
+
best_align = align
|
|
522
|
+
face_rot_face = key
|
|
523
|
+
drag_sign = 1.0 if align > 0 else -1.0
|
|
524
|
+
if face_rot_face is not None and abs(best_align) < 0.1:
|
|
525
|
+
face_rot_face = None # too ambiguous, keep waiting
|
|
526
|
+
|
|
527
|
+
if face_rot_face is not None:
|
|
528
|
+
direction = renderer.slice_directions().get(face_rot_face)
|
|
529
|
+
if direction is not None:
|
|
530
|
+
# Project the drag onto the direction that layer
|
|
531
|
+
# actually travels, so the turn keeps following
|
|
532
|
+
# the finger for the whole gesture.
|
|
533
|
+
face_rot_angle = drag_sign * float(np.dot(swipe, direction)) / 2.0
|
|
534
|
+
|
|
533
535
|
elif not snapping:
|
|
534
536
|
snap_target_angle = snap_to_nearest_90(face_rot_angle)
|
|
535
537
|
snap_start_angle = face_rot_angle
|
|
@@ -580,7 +582,6 @@ class AREngine:
|
|
|
580
582
|
face_rot_angle = 0.0
|
|
581
583
|
snapping = False
|
|
582
584
|
active_pointer_3d = None
|
|
583
|
-
drag_direction = None
|
|
584
585
|
state = State.HOLDING
|
|
585
586
|
|
|
586
587
|
# ── Always render the cube at current state ──
|
|
@@ -109,40 +109,3 @@ def quat_multiply(q1: np.ndarray, q2: np.ndarray) -> np.ndarray:
|
|
|
109
109
|
def snap_to_nearest_90(angle_degrees: float) -> float:
|
|
110
110
|
"""Round to nearest multiple of 90°."""
|
|
111
111
|
return round(angle_degrees / 90.0) * 90.0
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def cube_axes_on_screen(q: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
115
|
-
"""
|
|
116
|
-
Given cube rotation quaternion, return the cube's local X and Y
|
|
117
|
-
axes projected to 2D screen space (as unit vectors in pixel-direction).
|
|
118
|
-
|
|
119
|
-
Uses the SAME quaternion convention as the renderer: [x, y, z, w].
|
|
120
|
-
|
|
121
|
-
Returns (screen_x, screen_y) — each is a 2D numpy array.
|
|
122
|
-
"""
|
|
123
|
-
x, y, z, w = q
|
|
124
|
-
|
|
125
|
-
# Rotation matrix columns (local axes in world space)
|
|
126
|
-
# Same formula as renderer.py's quat_to_matrix
|
|
127
|
-
local_x = np.array([
|
|
128
|
-
1 - 2*y*y - 2*z*z,
|
|
129
|
-
2*x*y + 2*z*w,
|
|
130
|
-
2*x*z - 2*y*w
|
|
131
|
-
])
|
|
132
|
-
local_y = np.array([
|
|
133
|
-
2*x*y - 2*z*w,
|
|
134
|
-
1 - 2*x*x - 2*z*z,
|
|
135
|
-
2*y*z + 2*x*w
|
|
136
|
-
])
|
|
137
|
-
|
|
138
|
-
# Project to screen: take x,y components, flip y for pixel coords
|
|
139
|
-
sx = np.array([local_x[0], -local_x[1]])
|
|
140
|
-
sy = np.array([local_y[0], -local_y[1]])
|
|
141
|
-
|
|
142
|
-
# Normalize
|
|
143
|
-
nx = np.linalg.norm(sx)
|
|
144
|
-
ny = np.linalg.norm(sy)
|
|
145
|
-
if nx > 1e-6: sx /= nx
|
|
146
|
-
if ny > 1e-6: sy /= ny
|
|
147
|
-
|
|
148
|
-
return sx, sy
|
package/bin/t-perm.js
CHANGED
|
@@ -84,7 +84,7 @@ const TASK = path.join(BACKEND, 'hand_landmarker.task');
|
|
|
84
84
|
if (!fs.existsSync(TASK)) {
|
|
85
85
|
abort(
|
|
86
86
|
`MediaPipe model missing: ${TASK}\n` +
|
|
87
|
-
' If you installed via npx: npm cache clean --force && npx
|
|
87
|
+
' If you installed via npx: npm cache clean --force && npx tperm-visor\n' +
|
|
88
88
|
' If you cloned the repo: re-clone; the model is committed at\n' +
|
|
89
89
|
' backend/hand_landmarker.task'
|
|
90
90
|
);
|
package/frontend/css/style.css
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
|
|
18
18
|
* { box-sizing: border-box; }
|
|
19
19
|
|
|
20
|
+
/* The `hidden` attribute is only a UA-level `display:none`, so any author rule
|
|
21
|
+
that sets `display` overrides it - which silently unhid #quit-screen on page
|
|
22
|
+
load. Make `hidden` win everywhere. */
|
|
23
|
+
[hidden] { display: none !important; }
|
|
24
|
+
|
|
20
25
|
html, body {
|
|
21
26
|
margin: 0;
|
|
22
27
|
padding: 0;
|
package/frontend/index.html
CHANGED
|
@@ -87,10 +87,10 @@
|
|
|
87
87
|
<div id="quit-screen" hidden>
|
|
88
88
|
<div class="quit-card">
|
|
89
89
|
<h2>Server stopped</h2>
|
|
90
|
-
<p>The camera has been released and the terminal is free.<br />Run <code>npx
|
|
90
|
+
<p>The camera has been released and the terminal is free.<br />Run <code>npx tperm-visor</code> again to restart.</p>
|
|
91
91
|
</div>
|
|
92
92
|
</div>
|
|
93
93
|
|
|
94
94
|
<script src="js/app.js"></script>
|
|
95
95
|
</body>
|
|
96
|
-
</html>
|
|
96
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tperm-visor",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "T-PERM - gesture-driven AR Rubik's Cube in your browser. Webcam hand tracking via MediaPipe, 3D cube rendered with OpenGL. Requires Python 3.9+.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tperm-visor": "bin/t-perm.js"
|