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.
@@ -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()