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,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gesture_engine.py
|
|
3
|
+
Stateless gesture classifiers + stateful detectors (throw, absent).
|
|
4
|
+
Pattern mirrors Gesture-Media-control baseline: deque buffers, EMA, cooldowns.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from hand_tracker import HandData
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ── Distance helpers ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
def _norm_dist(lms, i: int, j: int) -> float:
|
|
15
|
+
"""Euclidean distance between two normalized landmarks."""
|
|
16
|
+
a, b = lms[i], lms[j]
|
|
17
|
+
return float(np.sqrt((a.x - b.x)**2 + (a.y - b.y)**2 + (a.z - b.z)**2))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── Spawn / grab ──────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def get_spawn_distance(h1: HandData, h2: HandData) -> float:
|
|
23
|
+
return float(np.linalg.norm(h1.palm_center - h2.palm_center))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_cube_spawn_ready(hands: list[HandData], frame_w: int) -> bool:
|
|
27
|
+
if len(hands) != 2:
|
|
28
|
+
return False
|
|
29
|
+
dist = get_spawn_distance(hands[0], hands[1])
|
|
30
|
+
return dist <= frame_w * 0.40
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def midpoint(h1: HandData, h2: HandData) -> np.ndarray:
|
|
34
|
+
return (h1.palm_center + h2.palm_center) / 2.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── Fist / Pinch ──────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
def is_fist(hand: HandData) -> bool:
|
|
40
|
+
"""True if most fingers are tightly curled (fist)."""
|
|
41
|
+
curled_count = 0
|
|
42
|
+
# For each finger, if tip is closer to wrist than its MCP joint, it is curled.
|
|
43
|
+
for tip, mcp in [(8, 5), (12, 9), (16, 13), (20, 17)]:
|
|
44
|
+
d_tip = _norm_dist(hand.landmarks, tip, 0)
|
|
45
|
+
d_mcp = _norm_dist(hand.landmarks, mcp, 0)
|
|
46
|
+
if d_tip < d_mcp:
|
|
47
|
+
curled_count += 1
|
|
48
|
+
return curled_count >= 3
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_open_palm(hand: HandData) -> bool:
|
|
52
|
+
"""True if all four fingers are extended (showing 5 / stop hand).
|
|
53
|
+
Used as a 'lock' gesture — cube won't rotate while this hand is up."""
|
|
54
|
+
extended = 0
|
|
55
|
+
for tip, mcp in [(8, 5), (12, 9), (16, 13), (20, 17)]:
|
|
56
|
+
d_tip = _norm_dist(hand.landmarks, tip, 0)
|
|
57
|
+
d_mcp = _norm_dist(hand.landmarks, mcp, 0)
|
|
58
|
+
if d_tip > d_mcp:
|
|
59
|
+
extended += 1
|
|
60
|
+
# Also require thumb to be somewhat spread (not pinching)
|
|
61
|
+
thumb_idx_dist = _norm_dist(hand.landmarks, 4, 8)
|
|
62
|
+
return extended >= 4 and thumb_idx_dist > 0.08
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_pinch(hand: HandData) -> bool:
|
|
66
|
+
"""Thumb tip ↔ index tip closer than 0.06 normalised units."""
|
|
67
|
+
d = _norm_dist(hand.landmarks, 4, 8)
|
|
68
|
+
return d < 0.06
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── Palm orientation ──────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
def is_palm_facing_down(hand: HandData) -> bool:
|
|
74
|
+
if is_fist(hand) or is_pinch(hand):
|
|
75
|
+
return False
|
|
76
|
+
# Right hand palm down -> +Y, Left hand palm down -> -Y
|
|
77
|
+
return hand.palm_normal[1] > 0.6 if hand.label == 'Right' else hand.palm_normal[1] < -0.6
|
|
78
|
+
|
|
79
|
+
def is_palm_facing_up(hand: HandData) -> bool:
|
|
80
|
+
if is_fist(hand) or is_pinch(hand):
|
|
81
|
+
return False
|
|
82
|
+
# Right hand palm up -> -Y, Left hand palm up -> +Y
|
|
83
|
+
return hand.palm_normal[1] < -0.6 if hand.label == 'Right' else hand.palm_normal[1] > 0.6
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── Hands-absent detection ────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
class AbsentDetector:
|
|
89
|
+
"""
|
|
90
|
+
Frame-counter based. threshold_frames = 48 ≈ 0.8s @ 60fps.
|
|
91
|
+
Same philosophy as baseline's gesture cooldown but inverted (counting absence).
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, threshold_frames: int = 48):
|
|
95
|
+
self.threshold = threshold_frames
|
|
96
|
+
self.counter = 0
|
|
97
|
+
|
|
98
|
+
def update(self, hands: list[HandData]) -> bool:
|
|
99
|
+
if len(hands) == 0:
|
|
100
|
+
self.counter += 1
|
|
101
|
+
else:
|
|
102
|
+
self.counter = 0
|
|
103
|
+
return self.counter >= self.threshold
|
|
104
|
+
|
|
105
|
+
def reset(self):
|
|
106
|
+
self.counter = 0
|
|
Binary file
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hand_tracker.py
|
|
3
|
+
MediaPipe Hand Landmarker setup + skeleton draw + HandData extraction.
|
|
4
|
+
Threading model and CONNECTIONS list copied from Gesture-Media-control baseline.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
import threading
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import cv2
|
|
12
|
+
import mediapipe as mp
|
|
13
|
+
import numpy as np
|
|
14
|
+
from mediapipe.tasks import python
|
|
15
|
+
from mediapipe.tasks.python import vision
|
|
16
|
+
|
|
17
|
+
from utils.transforms import compute_palm_normal, compute_finger_direction
|
|
18
|
+
|
|
19
|
+
# ── Exact CONNECTIONS from baseline (pranavpant9916-ctrl/Gesture-Media-control) ──
|
|
20
|
+
CONNECTIONS = [
|
|
21
|
+
(0, 1), (1, 2), (2, 3), (3, 4),
|
|
22
|
+
(0, 5), (5, 6), (6, 7), (7, 8),
|
|
23
|
+
(5, 9), (9, 10), (10, 11), (11, 12),
|
|
24
|
+
(9, 13), (13, 14), (14, 15), (15, 16),
|
|
25
|
+
(13, 17), (17, 18), (18, 19), (19, 20),
|
|
26
|
+
(0, 17)
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Landmark roles
|
|
30
|
+
IDX_WRIST = 0
|
|
31
|
+
IDX_THUMB_TIP = 4
|
|
32
|
+
IDX_INDEX_TIP = 8
|
|
33
|
+
|
|
34
|
+
PALM_LANDMARKS = [0, 5, 9, 13, 17] # for palm center average
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── Threaded webcam stream ────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
class WebcamStream:
|
|
40
|
+
"""Runs the webcam on a separate thread to achieve 60 fps without lag."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, src: int = 0):
|
|
43
|
+
self.stream = cv2.VideoCapture(src, cv2.CAP_DSHOW)
|
|
44
|
+
if not self.stream.isOpened():
|
|
45
|
+
self.stream = cv2.VideoCapture(src)
|
|
46
|
+
# Request 720p 60fps
|
|
47
|
+
self.stream.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
|
|
48
|
+
self.stream.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
|
|
49
|
+
self.stream.set(cv2.CAP_PROP_FPS, 60)
|
|
50
|
+
self.stream.set(cv2.CAP_PROP_AUTOFOCUS, 1)
|
|
51
|
+
# Keep only the newest frame in the driver queue. Without this the camera
|
|
52
|
+
# buffers a backlog and read() hands frames back in order, so whenever
|
|
53
|
+
# this thread is briefly starved the queue fills, never drains, and the
|
|
54
|
+
# feed sits seconds behind reality. Ignored harmlessly by some backends.
|
|
55
|
+
self.stream.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
|
56
|
+
self.grabbed, self.frame = self.stream.read()
|
|
57
|
+
self.stopped = False
|
|
58
|
+
self._lock = threading.Lock()
|
|
59
|
+
self._thread = None # FIX: keep reference so we can join() on shutdown
|
|
60
|
+
|
|
61
|
+
def start(self):
|
|
62
|
+
self._thread = threading.Thread(target=self._update, daemon=True)
|
|
63
|
+
self._thread.start()
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def _update(self):
|
|
67
|
+
while not self.stopped:
|
|
68
|
+
grabbed, frame = self.stream.read()
|
|
69
|
+
# FIX: if camera returns bad frames (e.g. after release is called),
|
|
70
|
+
# sleep briefly instead of spinning at 100% CPU hammering a dead stream
|
|
71
|
+
if not grabbed or frame is None:
|
|
72
|
+
time.sleep(0.01)
|
|
73
|
+
continue
|
|
74
|
+
with self._lock:
|
|
75
|
+
self.grabbed = grabbed
|
|
76
|
+
self.frame = frame
|
|
77
|
+
|
|
78
|
+
def read(self):
|
|
79
|
+
with self._lock:
|
|
80
|
+
return self.grabbed, self.frame.copy() if self.frame is not None else None
|
|
81
|
+
|
|
82
|
+
def isOpened(self) -> bool:
|
|
83
|
+
return self.stream.isOpened()
|
|
84
|
+
|
|
85
|
+
def release(self):
|
|
86
|
+
self.stopped = True
|
|
87
|
+
# FIX: wait for the _update thread to finish its current stream.read() call
|
|
88
|
+
# before releasing the camera handle.
|
|
89
|
+
#
|
|
90
|
+
# Without this join(), stream.release() fires while the thread is still
|
|
91
|
+
# inside stream.read(). On Windows DirectShow (CAP_DSHOW), the camera
|
|
92
|
+
# handle is reference-counted at the driver level — it stays open until
|
|
93
|
+
# ALL threads exit their read() calls. A new process that opens the same
|
|
94
|
+
# camera then gets a contested, half-initialised handle → lag and jitter.
|
|
95
|
+
#
|
|
96
|
+
# join(timeout=2.0) gives the thread up to 2s to finish its current read
|
|
97
|
+
# (one frame at 60fps = ~16ms, so 2s is extremely conservative).
|
|
98
|
+
if self._thread is not None and self._thread.is_alive():
|
|
99
|
+
self._thread.join(timeout=2.0)
|
|
100
|
+
self.stream.release()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── HandData dataclass ────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class HandData:
|
|
107
|
+
label: str # "Left" or "Right"
|
|
108
|
+
landmarks: list # all 21 raw landmark objects (normalized .x .y .z)
|
|
109
|
+
palm_center: np.ndarray # pixel (x, y)
|
|
110
|
+
palm_normal: np.ndarray # 3-D unit vector
|
|
111
|
+
finger_direction: np.ndarray # 3-D unit vector (wrist → middle MCP)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── HandTracker ───────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
class HandTracker:
|
|
117
|
+
def __init__(self, model_path: str = 'hand_landmarker.task',
|
|
118
|
+
result_callback=None, num_hands: int = 2):
|
|
119
|
+
"""result_callback: callable(result, image, timestamp_ms), for LIVE_STREAM.
|
|
120
|
+
|
|
121
|
+
num_hands is the single biggest performance lever here. Whenever fewer
|
|
122
|
+
hands are visible than requested, MediaPipe re-runs its expensive palm
|
|
123
|
+
detector on EVERY frame looking for the ones it hasn't found. Measured on
|
|
124
|
+
a real frame with one hand up: num_hands=2 costs 143ms per detection,
|
|
125
|
+
num_hands=1 costs 66ms - a 2.18x difference. Input resolution, by
|
|
126
|
+
contrast, changes nothing (640x360 and 213x120 both land within 2ms),
|
|
127
|
+
because MediaPipe rescales to the model's own input size regardless.
|
|
128
|
+
"""
|
|
129
|
+
self.num_hands = num_hands
|
|
130
|
+
options = vision.HandLandmarkerOptions(
|
|
131
|
+
base_options=python.BaseOptions(model_asset_path=model_path),
|
|
132
|
+
running_mode=vision.RunningMode.LIVE_STREAM,
|
|
133
|
+
num_hands=num_hands,
|
|
134
|
+
min_hand_detection_confidence=0.4,
|
|
135
|
+
min_hand_presence_confidence=0.4,
|
|
136
|
+
min_tracking_confidence=0.4,
|
|
137
|
+
result_callback=result_callback,
|
|
138
|
+
)
|
|
139
|
+
self.detector = vision.HandLandmarker.create_from_options(options)
|
|
140
|
+
|
|
141
|
+
def detect_async(self, rgb_frame: np.ndarray, timestamp_ms: int):
|
|
142
|
+
"""Non-blocking detect — LIVE_STREAM mode. Result arrives via callback."""
|
|
143
|
+
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb_frame)
|
|
144
|
+
self.detector.detect_async(mp_image, timestamp_ms)
|
|
145
|
+
|
|
146
|
+
def extract_hands(self, result, frame_w: int, frame_h: int) -> list[HandData]:
|
|
147
|
+
hands = []
|
|
148
|
+
if not result.hand_landmarks:
|
|
149
|
+
return hands
|
|
150
|
+
|
|
151
|
+
for lms, handedness in zip(result.hand_landmarks, result.handedness):
|
|
152
|
+
palm_pixels = np.array(
|
|
153
|
+
[[lms[i].x * frame_w, lms[i].y * frame_h] for i in PALM_LANDMARKS]
|
|
154
|
+
)
|
|
155
|
+
hands.append(HandData(
|
|
156
|
+
label=handedness[0].category_name, # "Left" or "Right"
|
|
157
|
+
landmarks=lms,
|
|
158
|
+
palm_center=palm_pixels.mean(axis=0),
|
|
159
|
+
palm_normal=compute_palm_normal(lms),
|
|
160
|
+
finger_direction=compute_finger_direction(lms),
|
|
161
|
+
))
|
|
162
|
+
return hands
|
|
163
|
+
|
|
164
|
+
def draw_skeleton(self, frame: np.ndarray, result) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Draw the full MediaPipe skeleton overlay.
|
|
167
|
+
Stays visible in EVERY state as long as hands are in frame.
|
|
168
|
+
- Bones: cyan, thickness 2
|
|
169
|
+
- Joints: white, radius 4
|
|
170
|
+
- Index tip (8) & thumb tip (4): red, radius 7
|
|
171
|
+
- Wrist (0): amber, radius 8
|
|
172
|
+
Landmarks are normalised 0-1, so this scales correctly onto the full-size
|
|
173
|
+
frame even though detection ran on a 1/3-scale copy.
|
|
174
|
+
"""
|
|
175
|
+
if not result.hand_landmarks:
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
h, w = frame.shape[:2]
|
|
179
|
+
for lms in result.hand_landmarks:
|
|
180
|
+
# Bones
|
|
181
|
+
for s, e in CONNECTIONS:
|
|
182
|
+
sp = (int(lms[s].x * w), int(lms[s].y * h))
|
|
183
|
+
ep = (int(lms[e].x * w), int(lms[e].y * h))
|
|
184
|
+
cv2.line(frame, sp, ep, (0, 255, 255), 2, cv2.LINE_AA)
|
|
185
|
+
|
|
186
|
+
# Joints
|
|
187
|
+
for idx, lm in enumerate(lms):
|
|
188
|
+
px = (int(lm.x * w), int(lm.y * h))
|
|
189
|
+
if idx == IDX_WRIST:
|
|
190
|
+
cv2.circle(frame, px, 8, (0, 220, 255), -1, cv2.LINE_AA)
|
|
191
|
+
cv2.circle(frame, px, 9, (0, 0, 0), 1, cv2.LINE_AA)
|
|
192
|
+
elif idx in (IDX_THUMB_TIP, IDX_INDEX_TIP):
|
|
193
|
+
cv2.circle(frame, px, 7, (0, 0, 255), -1, cv2.LINE_AA)
|
|
194
|
+
cv2.circle(frame, px, 8, (0, 0, 0), 1, cv2.LINE_AA)
|
|
195
|
+
else:
|
|
196
|
+
cv2.circle(frame, px, 4, (255, 255, 255), -1, cv2.LINE_AA)
|
|
197
|
+
|
|
198
|
+
def close(self):
|
|
199
|
+
self.detector.close()
|
package/backend/hud.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hud.py
|
|
3
|
+
All HUD overlays — glassmorphism pill style copied from Gesture-Media-control baseline.
|
|
4
|
+
Confetti, state label, solved/unsolved banner, spawn ring.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import random
|
|
8
|
+
|
|
9
|
+
import cv2
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── Glassmorphism helpers (from baseline) ─────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
def _glass_pill(frame: np.ndarray, cx: int, cy: int, text: str,
|
|
16
|
+
font_scale: float = 0.8, thickness: int = 2,
|
|
17
|
+
alpha: float = 1.0, text_color=(255, 255, 255)):
|
|
18
|
+
"""Lightweight pill overlay — no blur, no full-frame copy."""
|
|
19
|
+
if alpha < 0.01:
|
|
20
|
+
return
|
|
21
|
+
h, w = frame.shape[:2]
|
|
22
|
+
font = cv2.FONT_HERSHEY_SIMPLEX
|
|
23
|
+
text_size, _ = cv2.getTextSize(text, font, font_scale, thickness)
|
|
24
|
+
px, py = 30, 15
|
|
25
|
+
pill_w = text_size[0] + px * 2
|
|
26
|
+
pill_h = text_size[1] + py * 2
|
|
27
|
+
x1 = cx - pill_w // 2
|
|
28
|
+
y1 = cy - pill_h // 2
|
|
29
|
+
x2, y2 = x1 + pill_w, y1 + pill_h
|
|
30
|
+
if x1 < 0 or y1 < 0 or x2 > w or y2 > h:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
# Draw a dark semi-transparent rounded rectangle (ROI-only, no full copy)
|
|
34
|
+
overlay = frame[y1:y2, x1:x2].copy()
|
|
35
|
+
dark = np.full_like(overlay, (18, 20, 24))
|
|
36
|
+
blended = cv2.addWeighted(dark, 0.55 * alpha, overlay, 1.0 - 0.55 * alpha, 0)
|
|
37
|
+
frame[y1:y2, x1:x2] = blended
|
|
38
|
+
|
|
39
|
+
# Border
|
|
40
|
+
cv2.rectangle(frame, (x1, y1), (x2, y2), (180, 180, 180), 1)
|
|
41
|
+
|
|
42
|
+
tx = cx - text_size[0] // 2
|
|
43
|
+
ty = cy + text_size[1] // 2
|
|
44
|
+
cv2.putText(frame, text, (tx + 1, ty + 1), font, font_scale, (0, 0, 0), thickness + 1)
|
|
45
|
+
cv2.putText(frame, text, (tx, ty), font, font_scale, text_color, thickness)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── State label (bottom centre) ───────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
STATE_LABELS = {
|
|
51
|
+
'IDLE': 'Show both hands to spawn cube',
|
|
52
|
+
'SPAWN_READY': 'Spawning...',
|
|
53
|
+
'HOLDING': 'Twist wrist to rotate • Pinch to turn face',
|
|
54
|
+
'DRAGGING_CUBE': 'Moving cube...',
|
|
55
|
+
'DRAGGING_SLICE': 'Turning layer...',
|
|
56
|
+
'COMPLETION_CHECK': 'Checking...',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
def draw_state_label(frame: np.ndarray, state: str, alpha: float = 1.0):
|
|
60
|
+
h, w = frame.shape[:2]
|
|
61
|
+
label = STATE_LABELS.get(state, state)
|
|
62
|
+
_glass_pill(frame, w // 2, h - 55, label, font_scale=0.6, alpha=alpha)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Spawn pulsing ring ────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def draw_spawn_ring(frame: np.ndarray, cx: int, cy: int, progress: float):
|
|
68
|
+
"""progress 0→1 drives scale-in animation."""
|
|
69
|
+
r = int(60 * progress)
|
|
70
|
+
if r < 2:
|
|
71
|
+
return
|
|
72
|
+
alpha_ring = max(0.0, 1.0 - progress * 0.5)
|
|
73
|
+
color = (0, int(255 * progress), 255)
|
|
74
|
+
overlay = frame.copy()
|
|
75
|
+
cv2.circle(overlay, (cx, cy), r, color, 2, cv2.LINE_AA)
|
|
76
|
+
cv2.addWeighted(overlay, alpha_ring, frame, 1 - alpha_ring, 0, frame)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Solved / unsolved banner ──────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def draw_solved_banner(frame: np.ndarray, solved: bool, alpha: float = 1.0):
|
|
82
|
+
h, w = frame.shape[:2]
|
|
83
|
+
if solved:
|
|
84
|
+
text, color = 'SOLVED!', (0, 255, 100)
|
|
85
|
+
else:
|
|
86
|
+
text, color = 'NOT YET — KEEP GOING', (80, 80, 255)
|
|
87
|
+
_glass_pill(frame, w // 2, h // 2, text,
|
|
88
|
+
font_scale=1.2, thickness=3, alpha=alpha, text_color=color)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── Red border flash (unsolved) ───────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def draw_fail_border(frame: np.ndarray, alpha: float = 0.6):
|
|
94
|
+
h, w = frame.shape[:2]
|
|
95
|
+
overlay = frame.copy()
|
|
96
|
+
cv2.rectangle(overlay, (0, 0), (w - 1, h - 1), (0, 0, 220), 12)
|
|
97
|
+
cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0, frame)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── Confetti ──────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
_confetti_particles = []
|
|
103
|
+
|
|
104
|
+
def reset_confetti(frame_w: int, n: int = 120):
|
|
105
|
+
"""Seed particles just above the top edge; they fall into view from there."""
|
|
106
|
+
global _confetti_particles
|
|
107
|
+
_confetti_particles = []
|
|
108
|
+
colors = [(255, 80, 80), (80, 255, 80), (80, 80, 255),
|
|
109
|
+
(255, 255, 80), (255, 80, 255), (80, 255, 255)]
|
|
110
|
+
for _ in range(n):
|
|
111
|
+
_confetti_particles.append({
|
|
112
|
+
'x': random.randint(0, frame_w),
|
|
113
|
+
'y': random.randint(-60, 0),
|
|
114
|
+
'vx': random.uniform(-2, 2),
|
|
115
|
+
'vy': random.uniform(3, 9),
|
|
116
|
+
'color': random.choice(colors),
|
|
117
|
+
'size': random.randint(6, 16),
|
|
118
|
+
'rot': random.uniform(0, 360),
|
|
119
|
+
'rot_vel': random.uniform(-5, 5),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def draw_confetti(frame: np.ndarray):
|
|
124
|
+
h, w = frame.shape[:2]
|
|
125
|
+
alive = []
|
|
126
|
+
for p in _confetti_particles:
|
|
127
|
+
p['x'] += p['vx']
|
|
128
|
+
p['y'] += p['vy']
|
|
129
|
+
p['rot'] += p['rot_vel']
|
|
130
|
+
if p['y'] < h + 20:
|
|
131
|
+
alive.append(p)
|
|
132
|
+
s = p['size']
|
|
133
|
+
cx, cy = int(p['x']), int(p['y'])
|
|
134
|
+
cv2.rectangle(frame, (cx - s//2, cy - s//4),
|
|
135
|
+
(cx + s//2, cy + s//4), p['color'], -1)
|
|
136
|
+
_confetti_particles[:] = alive
|