tperm-visor 1.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Utkarsh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,322 @@
1
+ <div align="center">
2
+
3
+ # T-PERM
4
+ ### [ Touchless Permutation & Execution for Rubik’s Manipulation ]
5
+
6
+ <br/>
7
+
8
+ <img src="assets/poster.jpeg" alt="T-PERM Poster" width="460" style="max-width: 100%; object-fit: cover; border-radius: 8px;" />
9
+
10
+ <br/><br/>
11
+
12
+ ```yaml
13
+ System Specification:
14
+ Vision Core : MediaPipe Tasks Vision (21 3D Landmarks / Non-Blocking LIVE_STREAM Mode)
15
+ Frame Ingestion : Threaded OpenCV WebcamStream (720p, 60 FPS requested from the driver)
16
+ 3D Graphics : PyOpenGL Offscreen Framebuffer (FBO), fixed-function per-vertex lighting
17
+ Kinematics Filter : Positional EMA (α=0.35) & Normalised Quaternion LERP (QuatEMA α=0.25)
18
+ State Permutation : 18 standard 3x3 turns + M/E slice moves, with solved-state checking
19
+ Network Gateway : Flask Multi-Part MJPEG Video Streaming (/video_feed) & JSON Telemetry Sync
20
+ ```
21
+
22
+
23
+ <table>
24
+ <tr>
25
+ <td width="33%" valign="top">
26
+ <h4>Vision & Kinematics</h4>
27
+ <p>21 3D landmarks per hand via non-blocking <code>LIVE_STREAM</code> callbacks, EMA positional smoothing, and <code>QuatEMA</code> orientation tracking.</p>
28
+ </td>
29
+ <td width="33%" valign="top">
30
+ <h4>Threaded Concurrency</h4>
31
+ <p>Webcam I/O, MediaPipe inference, main-thread PyOpenGL FBO rendering, and Flask streaming all run without blocking one another.</p>
32
+ </td>
33
+ <td width="33%" valign="top">
34
+ <h4>3×3 Permutation Engine</h4>
35
+ <p>All 18 standard face moves plus M/E slices, pointer-projected layer selection via pinch-and-drag, and automatic solve verification.</p>
36
+ </td>
37
+ </tr>
38
+ </table>
39
+
40
+ </div>
41
+
42
+ ---
43
+
44
+ ## Overview
45
+
46
+ T-PERM bridges physical hand kinematics and virtual 3D cube manipulation without physical controllers or wearables. By capturing a camera feed, tracking 21 3D landmarks per hand asynchronously, and computing spatial transformations in real-time, the system maps hand movements directly into Rubik's Cube actions: spawning, 3D orientation tracking, layer turning via pinch-and-drag, cube repositioning, and solve-state evaluation.
47
+
48
+ Everything — tracking, 3D rendering, and compositing — happens in the Python backend. The browser is a thin client that displays the resulting MJPEG stream and polls telemetry.
49
+
50
+ ---
51
+
52
+ ## Backend System & Concurrency Pipeline
53
+
54
+ The backend runs across four threads so that inference never stalls the render loop.
55
+
56
+ ```mermaid
57
+ flowchart TD
58
+ subgraph CameraThread["Thread 1: Threaded Video Capture (WebcamStream)"]
59
+ CAM["Physical Camera Device"] -->|cv2.VideoCapture| CAP_POLL["Continuous Polling Loop\n(720p, 60 FPS requested)"]
60
+ CAP_POLL -->|Locked Swap| RAW_FRAME["Latest BGR Frame\n(frame_w x frame_h)"]
61
+ end
62
+
63
+ subgraph VisionThread["Thread 2: MediaPipe Asynchronous Worker"]
64
+ RAW_FRAME -.->|detect_async| DOWNSCALE["Downscale to 1/3 & BGR2RGB\n(9x fewer pixels)"]
65
+ DOWNSCALE --> MP_DETECTOR["MediaPipe HandLandmarker\nRunningMode.LIVE_STREAM"]
66
+ MP_DETECTOR -->|on_detection Callback| LM_EXTRACT["extract_hands()\n21 3D Landmark Points"]
67
+ LM_EXTRACT -->|Thread-Safe Generation Increment| LATEST_HANDS["Atomic Output Slot\n(_latest_hands / _latest_gen)"]
68
+ end
69
+
70
+ subgraph MainThread["Thread 0: Main Thread (PyOpenGL Context & Engine Loop)"]
71
+ LATEST_HANDS --> SYNC_CHECK{"New Detection\nGeneration?"}
72
+ SYNC_CHECK -->|Yes| STATE_EXEC["State Machine Tick\n(Idle / Spawn / Holding / Dragging / Completion)"]
73
+ SYNC_CHECK -->|No| RENDER_TICK["Skip gesture logic,\nstill render this frame"]
74
+ RENDER_TICK --> OGL_DRAW
75
+ STATE_EXEC --> EMA_UPDATE["Positional EMA (α=0.35)\n& QuatEMA (α=0.25)"]
76
+ EMA_UPDATE --> OGL_DRAW["PyOpenGL FBO Draw Call\n26 Cubies, 54 Stickers & Layer Animation"]
77
+ OGL_DRAW --> GL_READ["glReadPixels (Offscreen BGRA Buffer)"]
78
+ GL_READ --> COMPOSITE["Mask Composite over Raw BGR Frame"]
79
+ COMPOSITE --> CV_HUD["hud.py Overlay Drawing\n(Glass Pills, Spawn Rings, Confetti)"]
80
+ CV_HUD --> JPEG_ENC["cv2.imencode (.jpg, quality=65)"]
81
+ JPEG_ENC --> ACTIVE_BUF["Locked JPEG Swap (current_jpeg)\n+ frame_event.set()"]
82
+ end
83
+
84
+ subgraph WebServer["Thread 3: Flask Background Server"]
85
+ ACTIVE_BUF --> MJPEG_GEN["Generator: get_stream()\nMultipart Boundary Streaming"]
86
+ MJPEG_GEN --> STREAM_RESP["HTTP /video_feed (MJPEG Stream)"]
87
+ STATE_EXEC -.->|Telemetry Fields| API_STATUS["HTTP /api/status"]
88
+ API_RESET["HTTP /api/reset"] -.->|reset_requested Flag| STATE_EXEC
89
+ end
90
+ ```
91
+
92
+ ### Architectural Highlights
93
+ - **Threaded I/O Isolation**: The webcam capture loop runs continuously on `WebcamStream`, so the engine always reads the newest frame instead of blocking inside `cap.read()`.
94
+ - **Asynchronous Inference**: MediaPipe runs in `RunningMode.LIVE_STREAM`. Every frame is submitted without blocking; results arrive later via a thread-safe callback, and the mode drops submissions on its own while busy. The engine tracks a generation counter so gesture logic only runs on genuinely new detections.
95
+ - **Main-Thread OpenGL**: On Windows, PyOpenGL contexts bound via WGL must stay on the process's main thread, so the engine loop owns the main thread and Flask is relegated to a daemon thread.
96
+ - **Frame pacing**: The loop targets ~45 FPS rather than running flat out, deliberately leaving CPU headroom for MediaPipe inference.
97
+
98
+ ---
99
+
100
+ ## Gesture Kinematics & Interaction Flow
101
+
102
+ ```mermaid
103
+ flowchart TD
104
+ subgraph HandTracking["1. Landmark Coordinate Ingestion"]
105
+ LANDMARKS["21 3D Landmark Points per Hand\n(Wrist, MCP, PIP, DIP, Tips)"] --> NORM["Extract Hand Geometry\n(transforms.py & gesture_engine.py)"]
106
+ NORM --> V_NORMAL["Palm Normal Vector\n(Wrist->Index MCP x Wrist->Pinky MCP)"]
107
+ NORM --> V_FINGER["Finger Direction Vector (Wrist -> Middle MCP)"]
108
+ NORM --> D_PINCH["Thumb Tip to Index Tip Distance"]
109
+ NORM --> D_PALMS["Inter-Palm Centroid Distance (Both Hands)"]
110
+ NORM --> EXT_FINGERS["Extension Test\n(tip-to-wrist vs MCP-to-wrist distance)"]
111
+ end
112
+
113
+ subgraph GestureClassifiers["2. Kinematic Gesture Classifiers"]
114
+ D_PALMS --> COND_SPAWN{"Palms within 40% of\nframe width & both hands?"}
115
+ EXT_FINGERS --> COND_LOCK{"All 4 fingers extended\n& thumb spread > 0.08?"}
116
+ EXT_FINGERS --> COND_FIST{"3+ fingers curled\n(tip closer to wrist than MCP)?"}
117
+ V_NORMAL --> COND_SNAP{"Palm normal Y beyond ±0.6\n(sign depends on handedness)?"}
118
+ D_PINCH --> COND_PINCH{"Thumb-index distance\n< 0.06?"}
119
+ V_NORMAL & V_FINGER --> CALC_ROT["Wrist Delta Quaternion\n(align_vectors, >0.5 deg noise floor)"]
120
+ end
121
+
122
+ subgraph StateMachine["3. Interaction State Machine & Cube Action"]
123
+ COND_SPAWN -->|True| ACT_SPAWN["SPAWN_READY -> HOLDING\nEase cube in over 20 frames at hands' midpoint\nScale from inter-palm distance"]
124
+ COND_LOCK -->|True, 2+ hands| ACT_LOCK["Lock: freeze orientation,\nsuppress wrist tracking & fist drag"]
125
+ COND_SNAP -->|True| ACT_SNAP["Set orientation directly to the\nTop (U) or Bottom (D) view quaternion"]
126
+ CALC_ROT -->|Not locked, no palm snap| ACT_ORBIT["Accumulate delta onto cube_rotation\n(smoothed through QuatEMA)"]
127
+
128
+ COND_FIST -->|Near cube centre| ACT_MOVE["DRAGGING_CUBE\nTranslate cube with the fist"]
129
+
130
+ COND_PINCH -->|True| HIT_TEST{"Nearest projected pointer\nwithin 60 px of index tip?"}
131
+ HIT_TEST -->|Yes| ACT_DRAG["DRAGGING_SLICE\nLock slice axis (U/D/L/R/M/E)\nTrack drag along cube's screen axes"]
132
+ ACT_DRAG --> THRESH_CHECK{"Drag Distance\n> 15 Pixels?"}
133
+ THRESH_CHECK -->|Yes| COMMIT_MOVE["Snap to nearest 90 deg over 10 frames,\nthen apply move (rubiks.py)"]
134
+ end
135
+
136
+ subgraph FrontendSync["4. Telemetry & Web UI Sync"]
137
+ ACT_SPAWN & ACT_LOCK & ACT_SNAP & ACT_ORBIT & ACT_MOVE & COMMIT_MOVE --> TELEM_PACKET["Update Telemetry State"]
138
+ TELEM_PACKET --> UI_TELEMETRY["Browser HUD Counters\n(Mode, State, Hands, FPS)"]
139
+ end
140
+ ```
141
+
142
+ ---
143
+
144
+ ## Technical Deep Dive
145
+
146
+ ### Landmark Smoothing
147
+ Raw computer-vision landmarks jitter with lighting and auto-exposure changes. Two filters absorb that:
148
+
149
+ 1. **Positional EMA** — the 2D spawn midpoint is filtered with a single-pole exponential moving average:
150
+ $$P_t = \alpha \cdot X_t + (1 - \alpha) \cdot P_{t-1}, \quad \alpha = 0.35$$
151
+
152
+ 2. **Orientation QuatEMA** — hand rotation is derived as a quaternion from the palm normal and finger-direction vectors (`Rotation.align_vectors`, weighted 1.0 / 0.7), then smoothed by **normalised linear interpolation**: component-wise lerp followed by renormalisation, with a hemisphere check first so the quaternion double-cover never causes a flip.
153
+ $$Q_t = \frac{\alpha \cdot Q_{\text{target}} + (1 - \alpha) \cdot Q_{t-1}}{\lVert \alpha \cdot Q_{\text{target}} + (1 - \alpha) \cdot Q_{t-1} \rVert}, \quad \alpha = 0.25$$
154
+
155
+ Nlerp rather than true slerp: at these per-frame angles the two are visually indistinguishable, and nlerp costs a few multiplies instead of trigonometry.
156
+
157
+ ### Cube State & Permutation Engine
158
+ The cube state is a dict of 6 faces × 9 stickers, indexed row-major from the outside view (index 4 is the fixed centre). Every move is a pure function — deep-copy in, permuted state out, input untouched. Implemented: `U D L R F B` clockwise, counter-clockwise (`'`) and double (`2`), plus the `M` and `E` slice moves used when a drag starts on the cube's middle layer.
159
+
160
+ `test_rubiks.py` pins the engine down with the invariants that catch index bugs: the sticker census is preserved across a 50-move scramble, every quarter turn has order 4, each move cancels its inverse, doubles equal two quarters, and the sexy move `(R U R' U')` returns to solved after 6 repetitions.
161
+
162
+ ### Layer Selection
163
+ There is no ray cast. The renderer places 26 pointer nodes on a 3×3×3 lattice around the cube, projects each to screen space with `gluProject`, and returns the visible ones. On pinch, the engine picks the pointer nearest the index fingertip within 60 px and locks that layer.
164
+
165
+ The hit-test uses the *previous* frame's projected pointers rather than re-rendering. Rendering mid-tick would cost a second full FBO draw plus `glReadPixels` on every pinch frame and would advance the orientation filter twice in one tick; one frame of staleness at ~45 FPS is ~22 ms.
166
+
167
+ Drag direction then resolves the move: the swipe vector is projected onto the cube's own X and Y axes as they currently appear on screen, and whichever dominates picks a row (`U`/`E`/`D`) or column (`R`/`M`/`L`) turn. The angle eases to the nearest 90° over 10 frames before the permutation is committed.
168
+
169
+ ---
170
+
171
+ ## Gesture Control Reference
172
+
173
+ | Gesture | Kinematic Threshold | Functional Action |
174
+ |:---|:---|:---|
175
+ | **Both hands close together** | Palm centres within 40% of frame width | Spawns the cube between your hands; inter-palm distance sets its scale. |
176
+ | **Wrist rotation & tilt** | Palm normal + finger direction, >0.5° noise floor | Rotates the cube in 3D to inspect any face. |
177
+ | **Palm facing downward** | Palm normal Y beyond ±0.6 (sign by handedness) | Snaps the view to the Top (U) face. |
178
+ | **Palm facing upward** | Palm normal Y beyond ±0.6, opposite sign | Snaps the view to the Bottom (D) face. |
179
+ | **Pinch + drag** | Thumb-to-index distance < 0.06, within 60 px of a pointer | Selects that layer and turns it; commits past a 15 px drag. |
180
+ | **Fist near the cube** | 3+ fingers curled, fist within the cube's radius | Grabs and repositions the whole cube on screen. |
181
+ | **Open palm (show 5)** | All 4 fingers extended, thumb spread > 0.08, **two hands visible** | Locks orientation — pauses rotation and fist-drag. |
182
+ | **Hands removed from frame** | 48 consecutive empty detections (~1s) | Checks the cube and shows the solved banner + confetti, or a red flash. |
183
+
184
+ > The lock gesture is only evaluated when at least two hands are in frame — one open palm alone will not lock the cube.
185
+
186
+ ---
187
+
188
+ ## Project Structure
189
+
190
+ ```
191
+ ar-rubiks/
192
+ ├── backend/
193
+ │ ├── cube/
194
+ │ │ ├── renderer.py # PyOpenGL FBO pipeline, cubie & sticker geometry
195
+ │ │ └── rubiks.py # 3x3 permutation engine, move sets, solve check
196
+ │ ├── utils/
197
+ │ │ ├── smoothing.py # EMA and quaternion smoothing utilities
198
+ │ │ └── transforms.py # Vector geometry, screen axes, 90-degree snapping
199
+ │ ├── gesture_engine.py # Stateless gesture classifiers + absence detector
200
+ │ ├── hand_landmarker.task # MediaPipe Hand Landmarker pre-trained model
201
+ │ ├── hand_tracker.py # Threaded webcam capture and MediaPipe wrapper
202
+ │ ├── hud.py # HUD overlays, banners, and confetti particles
203
+ │ ├── requirements.txt # Python package dependencies
204
+ │ ├── server.py # Main OpenGL execution loop and Flask MJPEG server
205
+ │ └── test_rubiks.py # Self-check for the permutation engine
206
+ ├── bin/
207
+ │ └── t-perm.js # CLI runner: dependency check, launch, browser open
208
+ ├── frontend/
209
+ │ ├── css/
210
+ │ │ └── style.css # Dark console user interface styling
211
+ │ ├── js/
212
+ │ │ └── app.js # Stream mounting, telemetry polling, user actions
213
+ │ └── index.html # Main browser console interface
214
+ ├── assets/
215
+ │ └── poster.jpeg # Project graphic asset
216
+ ├── package.json # Node.js project manifest
217
+ └── README.md # System documentation
218
+ ```
219
+
220
+ ---
221
+
222
+ ## Getting Started
223
+
224
+ ### Prerequisites
225
+
226
+ - Python 3.9 or higher
227
+ - A working webcam
228
+ - Node.js (optional, for the CLI runner)
229
+ - Graphics driver with OpenGL 2.1+ support
230
+
231
+ ---
232
+
233
+ ### Method 1: One command, nothing to clone
234
+
235
+ ```bash
236
+ npx tperm-visor
237
+ ```
238
+
239
+ That is the whole install. The runner locates Python, installs the Python
240
+ dependencies **only if they are not already importable** (first run only), starts
241
+ the backend, waits for it to actually answer, then opens the console in your
242
+ browser. The MediaPipe model ships inside the package, so there is nothing else
243
+ to download.
244
+
245
+ Stop it with the **Stop server** button in the page, or `Ctrl+C` in the terminal —
246
+ either releases the camera and returns the terminal to a prompt.
247
+
248
+ Flags and environment:
249
+
250
+ | | |
251
+ |:--|:--|
252
+ | `npx tperm-visor --deps` | Force a dependency reinstall |
253
+ | `T_PERM_PORT=8080 npx tperm-visor` | Serve on a different port |
254
+
255
+ ---
256
+
257
+ ### Method 2: Manual Setup & Execution
258
+
259
+ #### 1. Set up the backend environment
260
+
261
+ ```bash
262
+ cd backend
263
+
264
+ # Windows:
265
+ python -m venv venv
266
+ venv\Scripts\activate
267
+
268
+ # Linux / macOS:
269
+ python3 -m venv venv
270
+ source venv/bin/activate
271
+
272
+ pip install -r requirements.txt
273
+ ```
274
+
275
+ #### 2. Start the server
276
+
277
+ ```bash
278
+ python server.py
279
+ ```
280
+
281
+ #### 3. Open the interface
282
+
283
+ `server.py` serves the frontend itself — just open:
284
+
285
+ ```
286
+ http://localhost:5000
287
+ ```
288
+
289
+ Then click **Engage camera**. No separate static file server is needed.
290
+
291
+ ---
292
+
293
+ ### Running the tests
294
+
295
+ ```bash
296
+ cd backend
297
+ python test_rubiks.py
298
+ ```
299
+
300
+ No test framework required — it is a plain script of assertions.
301
+
302
+ ---
303
+
304
+ ## API Endpoints & Telemetry Contract
305
+
306
+ | Endpoint | Method | Response Type | Description |
307
+ |:---|:---|:---|:---|
308
+ | `/` | `GET` | `text/html` | The web console (`frontend/index.html`). |
309
+ | `/video_feed` | `GET` | `multipart/x-mixed-replace` | MJPEG stream of composited camera + 3D frames. |
310
+ | `/health` | `GET` | `application/json` | Heartbeat: `{"status": "ok", "name": ..., "running": bool}`. |
311
+ | `/api/status` | `GET` | `application/json` | Telemetry: `{"running": bool, "state": "HOLDING", "hands": 2, "fps": 44}`. |
312
+ | `/api/reset` | `POST` / `GET` | `application/json` | Re-solves the cube and applies a new random 20-move scramble. |
313
+ | `/api/shutdown` | `POST` | `application/json` | Stops the engine and exits the process, releasing the camera and freeing the terminal. `POST` only, so a browser prefetch or reload cannot kill the app. |
314
+
315
+ ---
316
+
317
+ ## Known Limitations
318
+
319
+ - Slice selection uses the previous frame's projected pointers, so a very fast pinch onto a moving cube can miss by roughly one frame.
320
+ - The lock gesture requires two hands in frame (see the note above).
321
+ - Palm-up / palm-down snapping sets the orientation instantly rather than animating to it.
322
+ - Tuned against a 720p webcam at roughly arm's length; the pinch and spawn thresholds in `gesture_engine.py` are normalised but may still want adjusting for unusual camera placement or field of view.
File without changes