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 +21 -0
- package/README.md +322 -0
- package/backend/cube/__init__.py +0 -0
- package/backend/cube/renderer.py +397 -0
- package/backend/cube/rubiks.py +260 -0
- package/backend/gesture_engine.py +106 -0
- package/backend/hand_landmarker.task +0 -0
- package/backend/hand_tracker.py +199 -0
- package/backend/hud.py +136 -0
- package/backend/requirements.txt +9 -0
- package/backend/server.py +864 -0
- package/backend/utils/__init__.py +0 -0
- package/backend/utils/smoothing.py +40 -0
- package/backend/utils/transforms.py +148 -0
- package/bin/t-perm.js +148 -0
- package/frontend/css/style.css +330 -0
- package/frontend/index.html +96 -0
- package/frontend/js/app.js +105 -0
- package/package.json +49 -0
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
"""
|
|
2
|
+
server.py — AR Rubik's Cube Flask Server (Main Thread OpenGL Engine)
|
|
3
|
+
Runs PyOpenGL + MediaPipe AR Engine on the Main Thread (required for Windows WGL/Pyglet)
|
|
4
|
+
and Flask server on a background thread.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
import threading
|
|
9
|
+
import signal
|
|
10
|
+
import socket
|
|
11
|
+
from enum import Enum, auto
|
|
12
|
+
import cv2
|
|
13
|
+
import numpy as np
|
|
14
|
+
import os
|
|
15
|
+
from flask import Flask, Response, jsonify, request, send_from_directory
|
|
16
|
+
from flask_cors import CORS
|
|
17
|
+
|
|
18
|
+
import pyglet
|
|
19
|
+
from pyglet.gl import *
|
|
20
|
+
|
|
21
|
+
from hand_tracker import HandTracker, WebcamStream
|
|
22
|
+
from gesture_engine import (
|
|
23
|
+
AbsentDetector,
|
|
24
|
+
get_spawn_distance, is_cube_spawn_ready,
|
|
25
|
+
midpoint, is_pinch, is_fist, is_open_palm,
|
|
26
|
+
is_palm_facing_down, is_palm_facing_up
|
|
27
|
+
)
|
|
28
|
+
from cube.rubiks import solved_state, scramble, is_solved, apply_move
|
|
29
|
+
from cube.renderer import CubeRenderer, LAYER_TURN_MOVE
|
|
30
|
+
from utils.smoothing import EMA, QuatEMA
|
|
31
|
+
from utils.transforms import (quat_multiply, quat_conjugate, cube_axes_on_screen,
|
|
32
|
+
snap_to_nearest_90, hand_orientation_quat)
|
|
33
|
+
import hud
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Anchor every asset path to this file, never to the working directory. Under
|
|
37
|
+
# `npx t-perm` the process is spawned from wherever the user happens to be.
|
|
38
|
+
BACKEND_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
39
|
+
MODEL_PATH = os.path.join(BACKEND_DIR, 'hand_landmarker.task')
|
|
40
|
+
|
|
41
|
+
app = Flask(__name__)
|
|
42
|
+
CORS(app, resources={r"/*": {"origins": "*"}})
|
|
43
|
+
|
|
44
|
+
# ── Tuning knobs ─────────────────────────────────────────────────────────────
|
|
45
|
+
# Render/stream ceiling, and the main tuning knob for this app.
|
|
46
|
+
#
|
|
47
|
+
# The render loop and MediaPipe compete for CPU and for the GIL. Raise for a
|
|
48
|
+
# smoother picture; lower to hand time back to detection. Tracking lag equals one
|
|
49
|
+
# detection period, so TRACK FPS is what determines how well the skeleton sticks
|
|
50
|
+
# to your hand. Adjustable at runtime: GET/POST /api/config?target_fps=30
|
|
51
|
+
TARGET_FPS = 45
|
|
52
|
+
# JPEG quality for the MJPEG stream. 65 encodes roughly 2x faster than 80.
|
|
53
|
+
JPEG_QUALITY = 65
|
|
54
|
+
# Port to serve on. The CLI passes T_PERM_PORT so `npx t-perm` and the browser
|
|
55
|
+
# it opens always agree, even if the default is already taken.
|
|
56
|
+
PORT = int(os.environ.get('T_PERM_PORT', 5000))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class State(Enum):
|
|
60
|
+
IDLE = auto()
|
|
61
|
+
SPAWN_READY = auto()
|
|
62
|
+
HOLDING = auto()
|
|
63
|
+
DRAGGING_CUBE = auto()
|
|
64
|
+
DRAGGING_SLICE = auto()
|
|
65
|
+
COMPLETION_CHECK = auto()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ease_out_cubic(t: float) -> float:
|
|
69
|
+
t = max(0.0, min(1.0, t))
|
|
70
|
+
return 1.0 - (1.0 - t) ** 3
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AREngine:
|
|
74
|
+
def __init__(self):
|
|
75
|
+
self.lock = threading.Lock()
|
|
76
|
+
self.frame_event = threading.Event()
|
|
77
|
+
self.running = False
|
|
78
|
+
self.state = State.IDLE
|
|
79
|
+
self.hand_count = 0
|
|
80
|
+
self.fps = 0
|
|
81
|
+
self.det_fps = 0
|
|
82
|
+
self.lag_ms = 0
|
|
83
|
+
self.target_fps = TARGET_FPS
|
|
84
|
+
self.num_hands = 2 # which detector is live right now
|
|
85
|
+
self._diag_frame = None # recent raw frame, for /api/diagnose
|
|
86
|
+
self.reset_requested = False
|
|
87
|
+
self.current_jpeg = None
|
|
88
|
+
self._perf_start = time.perf_counter() # wall-clock base for MediaPipe timestamps
|
|
89
|
+
|
|
90
|
+
def reset_cube(self):
|
|
91
|
+
with self.lock:
|
|
92
|
+
self.reset_requested = True
|
|
93
|
+
|
|
94
|
+
def run_main_loop(self):
|
|
95
|
+
"""Main thread loop for Windows Pyglet / OpenGL compatibility."""
|
|
96
|
+
print("Initializing Pyglet OpenGL Context on Main Thread...")
|
|
97
|
+
try:
|
|
98
|
+
config = pyglet.gl.Config(double_buffer=False, depth_size=24, alpha_size=8)
|
|
99
|
+
gl_window = pyglet.window.Window(width=1, height=1, visible=False, config=config)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
print(f"Config fallback: {e}")
|
|
102
|
+
gl_window = pyglet.window.Window(width=1, height=1, visible=False)
|
|
103
|
+
|
|
104
|
+
gl_window.switch_to()
|
|
105
|
+
|
|
106
|
+
print("Starting camera thread...")
|
|
107
|
+
cap = WebcamStream(0).start()
|
|
108
|
+
time.sleep(1.5) # FIX: was 0.8 — give camera driver time to settle
|
|
109
|
+
if not cap.isOpened():
|
|
110
|
+
print("ERROR: Cannot open camera.")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# FIX: Discard first 30 frames so camera auto-exposure/focus can stabilise.
|
|
114
|
+
# These frames are always garbage (dark, over-exposed, blurry) and are the
|
|
115
|
+
# #1 cause of jitter/lag on manual restart. Without this, MediaPipe fires on
|
|
116
|
+
# bad frames and produces noisy detections for the first few seconds.
|
|
117
|
+
print("Warming up camera (30 frames)...")
|
|
118
|
+
frame = None
|
|
119
|
+
for _ in range(30):
|
|
120
|
+
ret, frame = cap.read()
|
|
121
|
+
if ret and frame is not None:
|
|
122
|
+
time.sleep(0.02) # ~20ms gap — don't hammer the driver
|
|
123
|
+
if frame is None:
|
|
124
|
+
print("ERROR: No frames from camera.")
|
|
125
|
+
cap.release()
|
|
126
|
+
return
|
|
127
|
+
frame_h, frame_w = frame.shape[:2]
|
|
128
|
+
print(f"Camera ready: {frame_w}x{frame_h}")
|
|
129
|
+
|
|
130
|
+
renderer = CubeRenderer(frame_w, frame_h)
|
|
131
|
+
renderer.init_gl()
|
|
132
|
+
# ── Async detection via LIVE_STREAM callback ─────────────────────
|
|
133
|
+
_cb_lock = threading.Lock()
|
|
134
|
+
_latest_hands = [[]]
|
|
135
|
+
_latest_result = [None]
|
|
136
|
+
_latest_gen = [0]
|
|
137
|
+
_det_times = [] # callback arrival times, for detection-FPS
|
|
138
|
+
_sent_at = {} # timestamp_ms -> perf_counter when submitted
|
|
139
|
+
_lags = [] # submit -> landmarks-in-hand, milliseconds
|
|
140
|
+
|
|
141
|
+
def _on_detection(result, image, timestamp_ms):
|
|
142
|
+
"""Called by MediaPipe on its own thread whenever results are ready."""
|
|
143
|
+
arrived = time.perf_counter()
|
|
144
|
+
try:
|
|
145
|
+
hands = tracker.extract_hands(result, frame_w, frame_h)
|
|
146
|
+
except Exception:
|
|
147
|
+
hands = []
|
|
148
|
+
with _cb_lock:
|
|
149
|
+
_latest_hands[0] = hands
|
|
150
|
+
_latest_result[0] = result
|
|
151
|
+
_latest_gen[0] += 1
|
|
152
|
+
_det_times.append(arrived)
|
|
153
|
+
if len(_det_times) > 30:
|
|
154
|
+
del _det_times[0]
|
|
155
|
+
# How stale the skeleton we're about to draw actually is. Frames
|
|
156
|
+
# the flow limiter dropped never call back, so their entries get
|
|
157
|
+
# pruned by age rather than popped.
|
|
158
|
+
started = _sent_at.pop(timestamp_ms, None)
|
|
159
|
+
if started is not None:
|
|
160
|
+
_lags.append((arrived - started) * 1000.0)
|
|
161
|
+
if len(_lags) > 30:
|
|
162
|
+
del _lags[0]
|
|
163
|
+
|
|
164
|
+
# Two detectors, differing only in num_hands, both live.
|
|
165
|
+
#
|
|
166
|
+
# Measured on a real frame with ONE hand up: num_hands=2 takes 143ms per
|
|
167
|
+
# detection, num_hands=1 takes 66ms. The gap is the palm detector, which
|
|
168
|
+
# MediaPipe re-runs every frame while short of the hand count it was
|
|
169
|
+
# asked for. Input resolution changes nothing (640x360 vs 213x120 land
|
|
170
|
+
# within 2ms) - only the hand count does.
|
|
171
|
+
#
|
|
172
|
+
# Building a detector costs a few hundred ms of model load, so make both
|
|
173
|
+
# up front and switch, rather than rebuilding on transitions.
|
|
174
|
+
tracker = HandTracker(MODEL_PATH, result_callback=_on_detection,
|
|
175
|
+
num_hands=2)
|
|
176
|
+
tracker_solo = HandTracker(MODEL_PATH, result_callback=_on_detection,
|
|
177
|
+
num_hands=1)
|
|
178
|
+
# Which detector to run is decided by what is actually in frame, not by
|
|
179
|
+
# which state we are in.
|
|
180
|
+
#
|
|
181
|
+
# num_hands=2 is only slow when it is SHORT of hands: MediaPipe re-runs
|
|
182
|
+
# the palm detector every frame hunting for the one it cannot find. Once
|
|
183
|
+
# both hands are visible it has nothing left to search for and costs
|
|
184
|
+
# about the same as num_hands=1. So the expensive case is exactly "asked
|
|
185
|
+
# for two, can see one".
|
|
186
|
+
#
|
|
187
|
+
# Rule: run the cheap solo detector while one hand is up, but re-probe
|
|
188
|
+
# with the two-hand detector every PROBE_EVERY frames so a second hand
|
|
189
|
+
# entering the frame is still noticed within a few hundred ms. Once two
|
|
190
|
+
# hands are seen, stay on the two-hand detector until one leaves.
|
|
191
|
+
# Probe every 6th frame: a second hand entering the frame is picked up
|
|
192
|
+
# within ~20-70ms, while only ~18% of lone-hand frames pay the slower
|
|
193
|
+
# two-hand detector. Probing every 12th halves that cost but lets the
|
|
194
|
+
# gap stretch to ~156ms, which is noticeable on the spawn gesture.
|
|
195
|
+
PROBE_EVERY = 6
|
|
196
|
+
SOLO_GRACE = 8 # detections of 0-1 hands before dropping to solo
|
|
197
|
+
two_hand_mode = True # start wide so the spawn gesture is available
|
|
198
|
+
solo_streak = 0
|
|
199
|
+
probe_tick = 0
|
|
200
|
+
last_processed_gen = 0
|
|
201
|
+
# ────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
cube_state = solved_state()
|
|
204
|
+
cube_state, _ = scramble(cube_state, n=20)
|
|
205
|
+
|
|
206
|
+
state = State.IDLE
|
|
207
|
+
cube_pos = np.array([frame_w / 2.0, frame_h / 2.0])
|
|
208
|
+
cube_rotation = np.array([0.0, 0.0, 0.0, 1.0])
|
|
209
|
+
cube_scale = 0.25
|
|
210
|
+
spawn_scale = 0.25
|
|
211
|
+
|
|
212
|
+
pos_ema = EMA(alpha=0.35)
|
|
213
|
+
rot_ema = QuatEMA(alpha=0.25)
|
|
214
|
+
|
|
215
|
+
spawn_frame_count = 0
|
|
216
|
+
SPAWN_FRAMES = 20
|
|
217
|
+
|
|
218
|
+
face_rot_angle = 0.0
|
|
219
|
+
face_rot_face = None
|
|
220
|
+
SNAP_FRAMES = 10
|
|
221
|
+
snap_frame = 0
|
|
222
|
+
snap_start_angle = 0.0
|
|
223
|
+
snap_target_angle = 0.0
|
|
224
|
+
snapping = False
|
|
225
|
+
active_pointer_3d = None
|
|
226
|
+
drag_start_pos = None
|
|
227
|
+
drag_direction = None
|
|
228
|
+
DRAG_LOCK_THRESHOLD = 15
|
|
229
|
+
pinch_released = True
|
|
230
|
+
|
|
231
|
+
prev_hands = {}
|
|
232
|
+
# Offset between the driving hand's orientation and the cube's, captured
|
|
233
|
+
# when that hand takes control. None means "re-acquire on next frame".
|
|
234
|
+
grab_offset_q = None
|
|
235
|
+
grab_hand_label = None
|
|
236
|
+
snap_latched = False # palm-flip snap fires once per flip, not per frame
|
|
237
|
+
absent_detector = AbsentDetector(threshold_frames=48)
|
|
238
|
+
completion_start_time = 0.0
|
|
239
|
+
completion_solved = False
|
|
240
|
+
banner_alpha = 0.0
|
|
241
|
+
pointers_2d = {}
|
|
242
|
+
|
|
243
|
+
confetti_active = False
|
|
244
|
+
frame_times = []
|
|
245
|
+
frames_seen = 0
|
|
246
|
+
self.running = True
|
|
247
|
+
print(">>> AR Engine Ready and Processing Frames! <<<")
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
while self.running:
|
|
251
|
+
# perf_counter, not time(): the pacing and FPS maths below
|
|
252
|
+
# subtract from this, and mixing the two clocks' epochs is nonsense.
|
|
253
|
+
t_start = time.perf_counter()
|
|
254
|
+
with self.lock:
|
|
255
|
+
if self.reset_requested:
|
|
256
|
+
cube_state = solved_state()
|
|
257
|
+
cube_state, _ = scramble(cube_state, n=20)
|
|
258
|
+
cube_rotation = np.array([0.0, 0.0, 0.0, 1.0])
|
|
259
|
+
rot_ema.reset()
|
|
260
|
+
grab_offset_q = None
|
|
261
|
+
self.reset_requested = False
|
|
262
|
+
|
|
263
|
+
ret, frame = cap.read()
|
|
264
|
+
if not ret or frame is None:
|
|
265
|
+
time.sleep(0.01)
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
frame = cv2.flip(frame, 1)
|
|
269
|
+
frame_h, frame_w = frame.shape[:2]
|
|
270
|
+
|
|
271
|
+
# Pick the detector from what was in frame last tick (updated
|
|
272
|
+
# further down, once this frame's results have been read).
|
|
273
|
+
# Timestamps come from one monotonic clock, so each detector
|
|
274
|
+
# still sees a strictly increasing sequence even though it only
|
|
275
|
+
# receives some of the frames.
|
|
276
|
+
probe_tick += 1
|
|
277
|
+
probing = (not two_hand_mode) and (probe_tick % PROBE_EVERY == 0)
|
|
278
|
+
active = tracker if (two_hand_mode or probing) else tracker_solo
|
|
279
|
+
self.num_hands = active.num_hands
|
|
280
|
+
|
|
281
|
+
# Submit every frame and let MediaPipe's flow limiter drop the
|
|
282
|
+
# excess. Do NOT gate submissions on a detection being in flight:
|
|
283
|
+
# measured, that roughly HALVES the detection rate (13.2 -> 7.6
|
|
284
|
+
# fps), because it idles the detector until the next iteration
|
|
285
|
+
# instead of keeping its pipeline fed.
|
|
286
|
+
#
|
|
287
|
+
# The 1/3 downscale shrinks the mp.Image copy. It does NOT speed
|
|
288
|
+
# up inference - MediaPipe rescales to the model's input size
|
|
289
|
+
# regardless, and 640x360 vs 213x120 measured within 2ms.
|
|
290
|
+
submit_t = time.perf_counter()
|
|
291
|
+
timestamp_ms = int((submit_t - self._perf_start) * 1000)
|
|
292
|
+
small = cv2.resize(frame, (frame_w // 3, frame_h // 3))
|
|
293
|
+
rgb_small = cv2.cvtColor(small, cv2.COLOR_BGR2RGB)
|
|
294
|
+
with _cb_lock:
|
|
295
|
+
_sent_at[timestamp_ms] = submit_t
|
|
296
|
+
if len(_sent_at) > 120: # dropped frames never call back
|
|
297
|
+
for k in sorted(_sent_at)[:60]:
|
|
298
|
+
del _sent_at[k]
|
|
299
|
+
try:
|
|
300
|
+
active.detect_async(rgb_small, timestamp_ms)
|
|
301
|
+
except ValueError:
|
|
302
|
+
# timestamp not monotonically increasing — skip this frame
|
|
303
|
+
with _cb_lock:
|
|
304
|
+
_sent_at.pop(timestamp_ms, None)
|
|
305
|
+
|
|
306
|
+
# Read latest results from callback
|
|
307
|
+
with _cb_lock:
|
|
308
|
+
hands = _latest_hands[0]
|
|
309
|
+
result = _latest_result[0]
|
|
310
|
+
cur_gen = _latest_gen[0]
|
|
311
|
+
|
|
312
|
+
has_new_detection = (cur_gen != last_processed_gen)
|
|
313
|
+
if has_new_detection:
|
|
314
|
+
last_processed_gen = cur_gen
|
|
315
|
+
|
|
316
|
+
if result is not None:
|
|
317
|
+
tracker.draw_skeleton(frame, result)
|
|
318
|
+
|
|
319
|
+
self.hand_count = len(hands)
|
|
320
|
+
|
|
321
|
+
# Latch onto two-hand mode the moment a second hand appears, and
|
|
322
|
+
# only fall back after SOLO_GRACE consecutive lone-hand results,
|
|
323
|
+
# so one missed detection cannot drop a two-hand gesture.
|
|
324
|
+
if has_new_detection:
|
|
325
|
+
if len(hands) >= 2:
|
|
326
|
+
solo_streak = 0
|
|
327
|
+
two_hand_mode = True
|
|
328
|
+
else:
|
|
329
|
+
solo_streak += 1
|
|
330
|
+
if solo_streak >= SOLO_GRACE:
|
|
331
|
+
two_hand_mode = False
|
|
332
|
+
|
|
333
|
+
# State machine — only update gestures when detection has new results
|
|
334
|
+
# On stale frames, we still render the cube but skip gesture logic
|
|
335
|
+
if has_new_detection:
|
|
336
|
+
if state == State.IDLE:
|
|
337
|
+
absent_detector.reset()
|
|
338
|
+
spawn_frame_count = 0
|
|
339
|
+
prev_hands.clear()
|
|
340
|
+
if is_cube_spawn_ready(hands, frame_w):
|
|
341
|
+
state = State.SPAWN_READY
|
|
342
|
+
|
|
343
|
+
elif state == State.SPAWN_READY:
|
|
344
|
+
if len(hands) == 2:
|
|
345
|
+
mid = midpoint(hands[0], hands[1])
|
|
346
|
+
cube_pos = pos_ema.update(mid)
|
|
347
|
+
dist = get_spawn_distance(hands[0], hands[1])
|
|
348
|
+
spawn_scale = np.clip(dist / frame_w * 2.5, 0.4, 1.2)
|
|
349
|
+
|
|
350
|
+
spawn_frame_count += 1
|
|
351
|
+
progress = spawn_frame_count / SPAWN_FRAMES
|
|
352
|
+
cube_scale = spawn_scale * ease_out_cubic(progress)
|
|
353
|
+
cx, cy = int(cube_pos[0]), int(cube_pos[1])
|
|
354
|
+
hud.draw_spawn_ring(frame, cx, cy, ease_out_cubic(progress))
|
|
355
|
+
|
|
356
|
+
if spawn_frame_count >= SPAWN_FRAMES:
|
|
357
|
+
cube_scale = spawn_scale
|
|
358
|
+
state = State.HOLDING
|
|
359
|
+
else:
|
|
360
|
+
state = State.IDLE
|
|
361
|
+
|
|
362
|
+
elif state == State.HOLDING:
|
|
363
|
+
if absent_detector.update(hands):
|
|
364
|
+
state = State.COMPLETION_CHECK
|
|
365
|
+
completion_start_time = time.time()
|
|
366
|
+
completion_solved = is_solved(cube_state)
|
|
367
|
+
if completion_solved:
|
|
368
|
+
hud.reset_confetti(frame_w)
|
|
369
|
+
confetti_active = True
|
|
370
|
+
absent_detector.reset()
|
|
371
|
+
|
|
372
|
+
lock_hand = None
|
|
373
|
+
if len(hands) >= 2:
|
|
374
|
+
lock_hand = next((h for h in hands if is_open_palm(h)), None)
|
|
375
|
+
if lock_hand or not hands:
|
|
376
|
+
# locked or hand gone: drop the offset so the cube is
|
|
377
|
+
# picked up from its current pose next time, instead
|
|
378
|
+
# of snapping back to where the hand left off
|
|
379
|
+
grab_offset_q = None
|
|
380
|
+
|
|
381
|
+
if not lock_hand:
|
|
382
|
+
# Drive the cube from where the hand actually points.
|
|
383
|
+
#
|
|
384
|
+
# This used to snap to two hard-coded quaternions the
|
|
385
|
+
# moment the palm tilted past a threshold, and
|
|
386
|
+
# otherwise integrate frame-to-frame deltas. Both
|
|
387
|
+
# fought the hand: the snaps threw the measured angle
|
|
388
|
+
# away and jumped to a canned pose, and the deltas
|
|
389
|
+
# accumulated their own error until the cube no
|
|
390
|
+
# longer corresponded to the hand at all.
|
|
391
|
+
#
|
|
392
|
+
# Instead: read the hand's ABSOLUTE orientation, and
|
|
393
|
+
# remember the offset between it and the cube at the
|
|
394
|
+
# moment control was taken. The cube is then always
|
|
395
|
+
# exactly that offset from the live hand - it tracks
|
|
396
|
+
# 1:1, holds still when the hand holds still, and
|
|
397
|
+
# cannot drift.
|
|
398
|
+
drive_hand = next(
|
|
399
|
+
(h for h in hands if not is_pinch(h) and not is_fist(h)),
|
|
400
|
+
None)
|
|
401
|
+
|
|
402
|
+
# Deliberate palm flip still snaps to the Top/Bottom
|
|
403
|
+
# face, but as a RE-GRAB rather than a hard pose: the
|
|
404
|
+
# cube is set to the face you asked for, then the
|
|
405
|
+
# offset is re-taken so continuous tracking carries
|
|
406
|
+
# on from there instead of sticking at a fixed pose.
|
|
407
|
+
if drive_hand is not None and not snap_latched:
|
|
408
|
+
if is_palm_facing_down(drive_hand):
|
|
409
|
+
cube_rotation = np.array([0.7071, 0.0, 0.0, 0.7071])
|
|
410
|
+
grab_offset_q = None
|
|
411
|
+
snap_latched = True
|
|
412
|
+
elif is_palm_facing_up(drive_hand):
|
|
413
|
+
cube_rotation = np.array([-0.7071, 0.0, 0.0, 0.7071])
|
|
414
|
+
grab_offset_q = None
|
|
415
|
+
snap_latched = True
|
|
416
|
+
if drive_hand is not None and snap_latched:
|
|
417
|
+
# only re-arm once the palm leaves the snap zone,
|
|
418
|
+
# so holding it there does not freeze the cube
|
|
419
|
+
if not (is_palm_facing_down(drive_hand)
|
|
420
|
+
or is_palm_facing_up(drive_hand)):
|
|
421
|
+
snap_latched = False
|
|
422
|
+
|
|
423
|
+
if drive_hand is not None:
|
|
424
|
+
hand_q = hand_orientation_quat(
|
|
425
|
+
drive_hand.palm_normal, drive_hand.finger_direction)
|
|
426
|
+
if grab_offset_q is None or grab_hand_label != drive_hand.label:
|
|
427
|
+
# Take control without teleporting the cube:
|
|
428
|
+
# offset = current cube orientation relative
|
|
429
|
+
# to the hand right now.
|
|
430
|
+
grab_offset_q = quat_multiply(
|
|
431
|
+
cube_rotation, quat_conjugate(hand_q))
|
|
432
|
+
grab_hand_label = drive_hand.label
|
|
433
|
+
cube_rotation = quat_multiply(grab_offset_q, hand_q)
|
|
434
|
+
norm = np.linalg.norm(cube_rotation)
|
|
435
|
+
if norm > 1e-6:
|
|
436
|
+
cube_rotation /= norm
|
|
437
|
+
else:
|
|
438
|
+
# pinching or fisted: that hand is doing something
|
|
439
|
+
# else, so re-acquire the offset when it returns
|
|
440
|
+
grab_offset_q = None
|
|
441
|
+
|
|
442
|
+
pinching_hand = next((h for h in hands if is_pinch(h)), None)
|
|
443
|
+
fist_hand = next((h for h in hands if is_fist(h)), None)
|
|
444
|
+
|
|
445
|
+
if not pinching_hand:
|
|
446
|
+
pinch_released = True
|
|
447
|
+
|
|
448
|
+
if fist_hand and not lock_hand:
|
|
449
|
+
px, py = fist_hand.palm_center
|
|
450
|
+
dist_to_center = np.hypot(px - cube_pos[0], py - cube_pos[1])
|
|
451
|
+
if dist_to_center < 180 * cube_scale / 0.25:
|
|
452
|
+
drag_start_pos = (px, py)
|
|
453
|
+
state = State.DRAGGING_CUBE
|
|
454
|
+
elif pinching_hand and not snapping and pinch_released:
|
|
455
|
+
px = pinching_hand.landmarks[8].x * frame_w
|
|
456
|
+
py = pinching_hand.landmarks[8].y * frame_h
|
|
457
|
+
# Hit-test against the PREVIOUS frame's projected pointers.
|
|
458
|
+
# Rendering here just to refresh them would cost a second full
|
|
459
|
+
# FBO draw + glReadPixels every pinch frame, and would advance
|
|
460
|
+
# rot_ema twice in one tick (making the smoothing jerk on pinch).
|
|
461
|
+
# One frame of staleness at ~45fps is ~22ms — imperceptible.
|
|
462
|
+
closest_p3d = None
|
|
463
|
+
closest_dist = float('inf')
|
|
464
|
+
for p3d, (cx, cy) in pointers_2d.items():
|
|
465
|
+
dist = np.hypot(px - cx, py - cy)
|
|
466
|
+
if dist < closest_dist:
|
|
467
|
+
closest_dist = dist
|
|
468
|
+
closest_p3d = p3d
|
|
469
|
+
|
|
470
|
+
if closest_dist < 60:
|
|
471
|
+
active_pointer_3d = closest_p3d
|
|
472
|
+
drag_start_pos = (px, py)
|
|
473
|
+
face_rot_angle = 0.0
|
|
474
|
+
pinch_released = False
|
|
475
|
+
state = State.DRAGGING_SLICE
|
|
476
|
+
|
|
477
|
+
prev_hands = {h.label: h for h in hands}
|
|
478
|
+
|
|
479
|
+
elif state == State.DRAGGING_CUBE:
|
|
480
|
+
fist_hand = next((h for h in hands if is_fist(h)), None)
|
|
481
|
+
|
|
482
|
+
if fist_hand:
|
|
483
|
+
px, py = fist_hand.palm_center
|
|
484
|
+
dx = px - drag_start_pos[0]
|
|
485
|
+
dy = py - drag_start_pos[1]
|
|
486
|
+
cube_pos[0] += dx
|
|
487
|
+
cube_pos[1] += dy
|
|
488
|
+
drag_start_pos = (px, py)
|
|
489
|
+
else:
|
|
490
|
+
pos_ema.value = cube_pos.copy()
|
|
491
|
+
state = State.HOLDING
|
|
492
|
+
|
|
493
|
+
prev_hands = {h.label: h for h in hands}
|
|
494
|
+
|
|
495
|
+
elif state == State.DRAGGING_SLICE:
|
|
496
|
+
pinching_hand = next((h for h in hands if is_pinch(h)), None)
|
|
497
|
+
if pinching_hand and not snapping:
|
|
498
|
+
px = pinching_hand.landmarks[8].x * frame_w
|
|
499
|
+
py = pinching_hand.landmarks[8].y * frame_h
|
|
500
|
+
dx = px - drag_start_pos[0]
|
|
501
|
+
dy = py - drag_start_pos[1]
|
|
502
|
+
|
|
503
|
+
if drag_direction is None:
|
|
504
|
+
smooth_q = rot_ema.update(cube_rotation)
|
|
505
|
+
if abs(dx) > DRAG_LOCK_THRESHOLD or abs(dy) > DRAG_LOCK_THRESHOLD:
|
|
506
|
+
screen_x, screen_y = cube_axes_on_screen(smooth_q)
|
|
507
|
+
swipe = np.array([dx, dy])
|
|
508
|
+
proj_x = abs(np.dot(swipe, screen_x))
|
|
509
|
+
proj_y = abs(np.dot(swipe, screen_y))
|
|
510
|
+
if proj_x > proj_y:
|
|
511
|
+
drag_direction = 'ROW'
|
|
512
|
+
if active_pointer_3d[1] > 0.1: face_rot_face = 'U'
|
|
513
|
+
elif active_pointer_3d[1] < -0.1: face_rot_face = 'D'
|
|
514
|
+
else: face_rot_face = 'E'
|
|
515
|
+
else:
|
|
516
|
+
drag_direction = 'COL'
|
|
517
|
+
if active_pointer_3d[0] > 0.1: face_rot_face = 'R'
|
|
518
|
+
elif active_pointer_3d[0] < -0.1: face_rot_face = 'L'
|
|
519
|
+
else: face_rot_face = 'M'
|
|
520
|
+
|
|
521
|
+
if drag_direction is not None and face_rot_face is not None:
|
|
522
|
+
smooth_q = rot_ema.update(cube_rotation)
|
|
523
|
+
screen_x, screen_y = cube_axes_on_screen(smooth_q)
|
|
524
|
+
swipe = np.array([dx, dy])
|
|
525
|
+
if drag_direction == 'ROW':
|
|
526
|
+
proj = np.dot(swipe, screen_x)
|
|
527
|
+
sign = -1.0 if face_rot_face in ('U', 'E') else 1.0
|
|
528
|
+
face_rot_angle = sign * proj / 2.0
|
|
529
|
+
elif drag_direction == 'COL':
|
|
530
|
+
proj = np.dot(swipe, screen_y)
|
|
531
|
+
sign = -1.0 if face_rot_face in ('R', 'M') else 1.0
|
|
532
|
+
face_rot_angle = sign * proj / 2.0
|
|
533
|
+
elif not snapping:
|
|
534
|
+
snap_target_angle = snap_to_nearest_90(face_rot_angle)
|
|
535
|
+
snap_start_angle = face_rot_angle
|
|
536
|
+
snap_frame = 0
|
|
537
|
+
snapping = True
|
|
538
|
+
|
|
539
|
+
prev_hands = {h.label: h for h in hands}
|
|
540
|
+
|
|
541
|
+
elif state == State.COMPLETION_CHECK:
|
|
542
|
+
elapsed = time.time() - completion_start_time
|
|
543
|
+
banner_alpha = min(elapsed / 0.4, 1.0)
|
|
544
|
+
|
|
545
|
+
if not completion_solved:
|
|
546
|
+
flash_alpha = max(0.0, 1.0 - elapsed / 0.8)
|
|
547
|
+
if flash_alpha > 0.01:
|
|
548
|
+
hud.draw_fail_border(frame, alpha=flash_alpha * 0.6)
|
|
549
|
+
|
|
550
|
+
duration = 3.0 if completion_solved else 2.0
|
|
551
|
+
if elapsed >= duration:
|
|
552
|
+
if completion_solved:
|
|
553
|
+
cube_state = solved_state()
|
|
554
|
+
cube_state, _ = scramble(cube_state, n=20)
|
|
555
|
+
cube_rotation = np.array([0.0, 0.0, 0.0, 1.0])
|
|
556
|
+
rot_ema.reset()
|
|
557
|
+
confetti_active = False
|
|
558
|
+
state = State.IDLE
|
|
559
|
+
|
|
560
|
+
# ── Snap animation runs every frame (time-based, not detection-based) ──
|
|
561
|
+
if state == State.DRAGGING_SLICE and snapping:
|
|
562
|
+
snap_frame += 1
|
|
563
|
+
t = min(snap_frame / SNAP_FRAMES, 1.0)
|
|
564
|
+
t_ease = ease_out_cubic(t)
|
|
565
|
+
face_rot_angle = snap_start_angle + (snap_target_angle - snap_start_angle) * t_ease
|
|
566
|
+
|
|
567
|
+
if snap_frame >= SNAP_FRAMES:
|
|
568
|
+
if face_rot_face:
|
|
569
|
+
# LAYER_TURN_MOVE is the move equal to ONE +90 step of
|
|
570
|
+
# the renderer's rotation for this layer, so the state
|
|
571
|
+
# always ends up matching what was just animated.
|
|
572
|
+
# turns is taken mod 4, so a -90 drag becomes three
|
|
573
|
+
# +90 moves - same result, no sign handling needed.
|
|
574
|
+
turns = int(round(snap_target_angle / 90.0)) % 4
|
|
575
|
+
move = LAYER_TURN_MOVE[face_rot_face]
|
|
576
|
+
for _ in range(turns):
|
|
577
|
+
cube_state = apply_move(cube_state, move)
|
|
578
|
+
|
|
579
|
+
face_rot_face = None
|
|
580
|
+
face_rot_angle = 0.0
|
|
581
|
+
snapping = False
|
|
582
|
+
active_pointer_3d = None
|
|
583
|
+
drag_direction = None
|
|
584
|
+
state = State.HOLDING
|
|
585
|
+
|
|
586
|
+
# ── Always render the cube at current state ──
|
|
587
|
+
if state in (State.HOLDING, State.DRAGGING_CUBE, State.DRAGGING_SLICE):
|
|
588
|
+
smooth_q = rot_ema.update(cube_rotation)
|
|
589
|
+
frame, pointers_2d = renderer.render(
|
|
590
|
+
frame, cube_state, cube_pos, smooth_q,
|
|
591
|
+
cube_scale=cube_scale,
|
|
592
|
+
face_rotating=(face_rot_face, face_rot_angle) if face_rot_face else None,
|
|
593
|
+
highlighted_pointer=active_pointer_3d,
|
|
594
|
+
)
|
|
595
|
+
elif state == State.COMPLETION_CHECK:
|
|
596
|
+
frame, _ = renderer.render(
|
|
597
|
+
frame, cube_state, cube_pos, rot_ema.value,
|
|
598
|
+
cube_scale=cube_scale,
|
|
599
|
+
)
|
|
600
|
+
if confetti_active:
|
|
601
|
+
hud.draw_confetti(frame)
|
|
602
|
+
hud.draw_solved_banner(frame, completion_solved, alpha=banner_alpha if has_new_detection else 1.0)
|
|
603
|
+
|
|
604
|
+
if state != State.COMPLETION_CHECK:
|
|
605
|
+
hud.draw_state_label(frame, state.name)
|
|
606
|
+
|
|
607
|
+
self.state = state
|
|
608
|
+
|
|
609
|
+
ok, jpeg = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY])
|
|
610
|
+
if ok:
|
|
611
|
+
with self.lock:
|
|
612
|
+
self.current_jpeg = jpeg.tobytes()
|
|
613
|
+
self.frame_event.set()
|
|
614
|
+
|
|
615
|
+
# Frame pacing. Always yield at least a sliver: if the loop can't
|
|
616
|
+
# hit the target the sleep would otherwise never fire, and the
|
|
617
|
+
# main thread would spin without ever handing the GIL to the
|
|
618
|
+
# MediaPipe callback that has to deliver the landmarks.
|
|
619
|
+
elapsed = time.perf_counter() - t_start
|
|
620
|
+
target = 1.0 / max(self.target_fps, 1)
|
|
621
|
+
time.sleep(max(target - elapsed, 0.001))
|
|
622
|
+
|
|
623
|
+
# Render FPS - measured across the FULL period including the
|
|
624
|
+
# sleep. Timing only the work before it reports how fast a frame
|
|
625
|
+
# could have been built, not how many actually ship.
|
|
626
|
+
frame_times.append(time.perf_counter() - t_start)
|
|
627
|
+
if len(frame_times) > 30:
|
|
628
|
+
frame_times.pop(0)
|
|
629
|
+
avg = sum(frame_times) / len(frame_times)
|
|
630
|
+
self.fps = int(1.0 / avg) if avg > 0 else 0
|
|
631
|
+
|
|
632
|
+
# Detection FPS and skeleton staleness - the numbers that decide
|
|
633
|
+
# how well tracking sticks to the hand.
|
|
634
|
+
with _cb_lock:
|
|
635
|
+
span = _det_times[-1] - _det_times[0] if len(_det_times) > 1 else 0.0
|
|
636
|
+
n_det = len(_det_times)
|
|
637
|
+
lag = sorted(_lags)[len(_lags) // 2] if _lags else 0.0
|
|
638
|
+
self.det_fps = int((n_det - 1) / span) if span > 0 else 0
|
|
639
|
+
self.lag_ms = int(lag)
|
|
640
|
+
|
|
641
|
+
finally:
|
|
642
|
+
self.running = False
|
|
643
|
+
renderer.cleanup()
|
|
644
|
+
tracker.close()
|
|
645
|
+
tracker_solo.close()
|
|
646
|
+
cap.release()
|
|
647
|
+
gl_window.close()
|
|
648
|
+
print("Engine stopped cleanly.")
|
|
649
|
+
|
|
650
|
+
def get_stream(self):
|
|
651
|
+
while True:
|
|
652
|
+
jpeg_bytes = None
|
|
653
|
+
with self.lock:
|
|
654
|
+
jpeg_bytes = self.current_jpeg
|
|
655
|
+
|
|
656
|
+
if jpeg_bytes is not None:
|
|
657
|
+
header = (
|
|
658
|
+
b'--frame\r\n'
|
|
659
|
+
b'Content-Type: image/jpeg\r\n'
|
|
660
|
+
b'Content-Length: ' + str(len(jpeg_bytes)).encode('ascii') + b'\r\n\r\n'
|
|
661
|
+
)
|
|
662
|
+
yield header + jpeg_bytes + b'\r\n'
|
|
663
|
+
# Wait for the next frame instead of fixed sleep
|
|
664
|
+
self.frame_event.wait(timeout=0.05)
|
|
665
|
+
self.frame_event.clear()
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
engine = AREngine()
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
@app.route('/health')
|
|
672
|
+
def health():
|
|
673
|
+
return jsonify({
|
|
674
|
+
"status": "ok",
|
|
675
|
+
"name": "AR Rubiks Cube Backend Server",
|
|
676
|
+
"running": engine.running
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
FRONTEND_DIR = os.path.join(BACKEND_DIR, '..', 'frontend')
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@app.route('/')
|
|
684
|
+
def frontend_index():
|
|
685
|
+
return send_from_directory(FRONTEND_DIR, 'index.html')
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
@app.route('/css/<path:filename>')
|
|
689
|
+
def frontend_css(filename):
|
|
690
|
+
return send_from_directory(os.path.join(FRONTEND_DIR, 'css'), filename)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
@app.route('/js/<path:filename>')
|
|
694
|
+
def frontend_js(filename):
|
|
695
|
+
return send_from_directory(os.path.join(FRONTEND_DIR, 'js'), filename)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
@app.route('/api/status')
|
|
699
|
+
def status():
|
|
700
|
+
return jsonify({
|
|
701
|
+
"running": engine.running,
|
|
702
|
+
"state": engine.state.name,
|
|
703
|
+
"hands": engine.hand_count,
|
|
704
|
+
"fps": engine.fps,
|
|
705
|
+
"det_fps": engine.det_fps,
|
|
706
|
+
"lag_ms": engine.lag_ms,
|
|
707
|
+
"target_fps": engine.target_fps,
|
|
708
|
+
"num_hands": engine.num_hands,
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@app.route('/api/diagnose')
|
|
713
|
+
def diagnose():
|
|
714
|
+
"""Benchmark MediaPipe on a real frame from THIS camera, with YOUR hand in it.
|
|
715
|
+
|
|
716
|
+
Visit with a hand held up; takes ~10s and eats CPU while it runs.
|
|
717
|
+
|
|
718
|
+
hands_found matters: a config that finds no hand is meaningless, because
|
|
719
|
+
MediaPipe returns early without running the landmark model at all and looks
|
|
720
|
+
about 10x faster than it really is.
|
|
721
|
+
"""
|
|
722
|
+
import statistics
|
|
723
|
+
import mediapipe as mp
|
|
724
|
+
from mediapipe.tasks import python as mp_python
|
|
725
|
+
from mediapipe.tasks.python import vision as mp_vision
|
|
726
|
+
|
|
727
|
+
frame = engine._diag_frame
|
|
728
|
+
if frame is None:
|
|
729
|
+
return jsonify({"error": "no frame captured yet - is the engine running?"}), 503
|
|
730
|
+
|
|
731
|
+
h, w = frame.shape[:2]
|
|
732
|
+
|
|
733
|
+
def bench(num_hands, divisor, reps=12):
|
|
734
|
+
small = cv2.resize(frame, (w // divisor, h // divisor))
|
|
735
|
+
rgb = cv2.cvtColor(small, cv2.COLOR_BGR2RGB)
|
|
736
|
+
img = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
|
|
737
|
+
det = mp_vision.HandLandmarker.create_from_options(
|
|
738
|
+
mp_vision.HandLandmarkerOptions(
|
|
739
|
+
base_options=mp_python.BaseOptions(model_asset_path=MODEL_PATH),
|
|
740
|
+
running_mode=mp_vision.RunningMode.VIDEO, num_hands=num_hands,
|
|
741
|
+
min_hand_detection_confidence=0.4, min_hand_presence_confidence=0.4,
|
|
742
|
+
min_tracking_confidence=0.4))
|
|
743
|
+
ts = 0
|
|
744
|
+
for _ in range(4):
|
|
745
|
+
ts += 33
|
|
746
|
+
det.detect_for_video(img, ts)
|
|
747
|
+
times, found = [], 0
|
|
748
|
+
for _ in range(reps):
|
|
749
|
+
ts += 33
|
|
750
|
+
t0 = time.perf_counter()
|
|
751
|
+
r = det.detect_for_video(img, ts)
|
|
752
|
+
times.append((time.perf_counter() - t0) * 1000.0)
|
|
753
|
+
found += len(r.hand_landmarks)
|
|
754
|
+
det.close()
|
|
755
|
+
return {
|
|
756
|
+
"num_hands": num_hands,
|
|
757
|
+
"input": "%dx%d" % (small.shape[1], small.shape[0]),
|
|
758
|
+
"ms_per_detect": round(statistics.median(times), 1),
|
|
759
|
+
"detects_per_sec": round(1000.0 / statistics.median(times), 1),
|
|
760
|
+
"hands_found": round(found / reps, 2),
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
runs = [bench(n, d) for n, d in ((2, 3), (1, 3), (2, 2), (2, 6), (1, 6))]
|
|
764
|
+
usable = [r for r in runs if r["hands_found"] > 0]
|
|
765
|
+
best = min(usable, key=lambda r: r["ms_per_detect"]) if usable else None
|
|
766
|
+
current = runs[0]
|
|
767
|
+
return jsonify({
|
|
768
|
+
"note": "ignore any row with hands_found = 0; it never ran the landmark model",
|
|
769
|
+
"source_frame": "%dx%d" % (w, h),
|
|
770
|
+
"current_setting": current,
|
|
771
|
+
"fastest_usable": best,
|
|
772
|
+
"speedup_available": (round(current["ms_per_detect"] / best["ms_per_detect"], 2)
|
|
773
|
+
if best else None),
|
|
774
|
+
"results": runs,
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
@app.route('/api/config', methods=['GET', 'POST'])
|
|
779
|
+
def config():
|
|
780
|
+
"""Live-tune the render cap without a restart: /api/config?target_fps=30
|
|
781
|
+
|
|
782
|
+
Render rate and tracking rate trade against each other - they share the CPU
|
|
783
|
+
and the GIL. Sweep this while watching TRACK FPS to find the knee.
|
|
784
|
+
"""
|
|
785
|
+
raw = request.args.get('target_fps')
|
|
786
|
+
if raw is not None:
|
|
787
|
+
try:
|
|
788
|
+
engine.target_fps = max(1, min(240, int(raw)))
|
|
789
|
+
except ValueError:
|
|
790
|
+
return jsonify({"error": "target_fps must be an integer"}), 400
|
|
791
|
+
return jsonify({"target_fps": engine.target_fps})
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
@app.route('/api/shutdown', methods=['POST'])
|
|
795
|
+
def shutdown():
|
|
796
|
+
"""Stop the engine and exit the process, so the terminal returns to a prompt.
|
|
797
|
+
|
|
798
|
+
POST only: a GET here would let a stray browser prefetch or a page reload
|
|
799
|
+
kill the app. Clearing engine.running lets the main loop fall out of its
|
|
800
|
+
`while` and run its finally block, which releases the camera and GL context
|
|
801
|
+
cleanly. The exit itself is deferred to a daemon thread so this request can
|
|
802
|
+
still return 200 before the interpreter goes away.
|
|
803
|
+
"""
|
|
804
|
+
engine.running = False
|
|
805
|
+
|
|
806
|
+
def _exit_soon():
|
|
807
|
+
time.sleep(0.6) # let the main loop finish its cleanup first
|
|
808
|
+
os._exit(0) # hard exit: Flask runs on a daemon thread
|
|
809
|
+
|
|
810
|
+
threading.Thread(target=_exit_soon, daemon=True).start()
|
|
811
|
+
return jsonify({"status": "shutting down"})
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
@app.route('/api/reset', methods=['POST', 'GET'])
|
|
815
|
+
def reset():
|
|
816
|
+
engine.reset_cube()
|
|
817
|
+
return jsonify({"status": "reset requested"})
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
@app.route('/video_feed')
|
|
821
|
+
def video_feed():
|
|
822
|
+
res = Response(
|
|
823
|
+
engine.get_stream(),
|
|
824
|
+
mimetype='multipart/x-mixed-replace; boundary=frame'
|
|
825
|
+
)
|
|
826
|
+
res.headers['Access-Control-Allow-Origin'] = '*'
|
|
827
|
+
return res
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
# FIX: Use werkzeug make_server directly so we can set SO_REUSEADDR.
|
|
831
|
+
# Without this, the OS holds the port in TIME_WAIT after shutdown and
|
|
832
|
+
# a quick manual restart fails to bind — causing a startup delay or crash.
|
|
833
|
+
def run_flask():
|
|
834
|
+
from werkzeug.serving import make_server
|
|
835
|
+
srv = make_server('0.0.0.0', PORT, app, threaded=True)
|
|
836
|
+
srv.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
837
|
+
srv.serve_forever()
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
# FIX: Catch Ctrl+C via signal instead of KeyboardInterrupt.
|
|
841
|
+
# KeyboardInterrupt fires mid-frame and can leave the camera/MediaPipe in a
|
|
842
|
+
# dirty state. This handler sets running=False so the main loop exits at the
|
|
843
|
+
# top of its next iteration and the finally block runs cleanly every time.
|
|
844
|
+
def _on_signal(sig, frame):
|
|
845
|
+
print("\nShutdown signal received — stopping engine cleanly...")
|
|
846
|
+
engine.running = False
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
if __name__ == '__main__':
|
|
850
|
+
signal.signal(signal.SIGINT, _on_signal)
|
|
851
|
+
signal.signal(signal.SIGTERM, _on_signal)
|
|
852
|
+
|
|
853
|
+
if not os.path.exists(MODEL_PATH):
|
|
854
|
+
print(f"ERROR: model not found at {MODEL_PATH}")
|
|
855
|
+
raise SystemExit(1)
|
|
856
|
+
|
|
857
|
+
print("Starting Flask Server in Background Thread...")
|
|
858
|
+
flask_thread = threading.Thread(target=run_flask, daemon=True)
|
|
859
|
+
flask_thread.start()
|
|
860
|
+
time.sleep(0.5)
|
|
861
|
+
|
|
862
|
+
print("Starting AR Engine on Main Thread...")
|
|
863
|
+
print(f"\n T-PERM running at http://localhost:{PORT}\n")
|
|
864
|
+
engine.run_main_loop()
|