Skip to content

Commit 1a48e4b

Browse files
committed
feat: finalize hand_gesture_tracking brick
1 parent 1d66bbb commit 1a48e4b

File tree

4 files changed

+284
-17
lines changed

4 files changed

+284
-17
lines changed

src/arduino/app_bricks/hand_gesture_detection/__init__.py

Lines changed: 267 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,283 @@
22
#
33
# SPDX-License-Identifier: MPL-2.0
44

5-
from typing import Literal
5+
import asyncio
6+
import base64
7+
import json
8+
import queue
9+
import threading
10+
import time
11+
from typing import Callable, Literal
12+
13+
import numpy as np
14+
import websockets
615

716
from arduino.app_peripherals.camera import BaseCamera, Camera
17+
from arduino.app_utils import brick
18+
from arduino.app_utils.image.adjustments import compress_to_jpeg
19+
from arduino.app_internal.core.module import load_brick_compose_file, resolve_address
820

921

22+
@brick
1023
class HandGestureTracking:
1124
def __init__(self, camera: BaseCamera | None = None):
1225
if camera is None:
1326
camera = Camera(fps=30)
1427
self.camera = camera
28+
29+
# Callbacks
30+
self._gesture_callbacks = {} # {(gesture, hand): callback}
31+
self._enter_callback = None
32+
self._exit_callback = None
33+
self._frame_callback = None
34+
self._callbacks_lock = threading.Lock()
35+
36+
# State tracking
37+
self._had_hands = False
38+
self._running = False
39+
40+
self._camera_frame_queue = queue.Queue(maxsize=2)
41+
42+
# WebSocket endpoints
43+
infra = load_brick_compose_file(self.__class__)
44+
if infra is None or "services" not in infra:
45+
raise RuntimeError("Infrastructure configuration could not be loaded.")
46+
for k, _ in infra["services"].items():
47+
self._host = k
48+
break # Only one service is expected
49+
50+
self._host = resolve_address(self._host)
51+
if not self._host:
52+
raise RuntimeError("Host address could not be resolved. Please check your configuration.")
53+
54+
self._ws_send_url = f"ws://{self._host}:5050"
55+
self._ws_recv_url = f"ws://{self._host}:5051"
56+
57+
def start(self):
58+
"""Start the capture thread and asyncio event loop."""
59+
self._running = True
60+
61+
def stop(self):
62+
"""Stop all tracking and close connections."""
63+
self._running = False
64+
65+
def on_gesture(self, gesture: str, callback: Callable[[dict], None], hand: Literal["left", "right", "both"] = "both"):
66+
"""
67+
Register or unregister a gesture callback.
68+
69+
Args:
70+
gesture (str): The gesture name to detect
71+
callback (Callable[[dict], None]): Function to call when gesture is detected. None to unregister.
72+
The callback receives a metadata dictionary with details about the detection, including:
73+
- "hand": Which hand performed the gesture ("left" or "right")
74+
- "gesture": Name of the detected gesture
75+
- "confidence": Confidence score of the detection (0.0 to 1.0)
76+
- "landmarks": List of key points of the detected hand (in (x, y, z) format where
77+
x and y are pixel coordinates and z is normalized depth)
78+
- "bounding_box_xyxy": [x_min, y_min, x_max, y_max] of the detected hand bounding box
79+
hand (Literal["left", "right", "both"]): Which hand(s) to track
80+
81+
Raises:
82+
ValueError: If 'hand' argument is not valid
83+
"""
84+
if hand not in ("left", "right", "both"):
85+
raise ValueError("hand must be 'left', 'right', or 'both'")
86+
87+
with self._callbacks_lock:
88+
key = (gesture, hand)
89+
if callback is None:
90+
if key in self._gesture_callbacks:
91+
del self._gesture_callbacks[key]
92+
else:
93+
self._gesture_callbacks[key] = callback
94+
95+
def on_enter(self, callback: Callable[[], None]):
96+
"""
97+
Register a callback for when hands become visible.
98+
99+
Args:
100+
callback (Callable[[], None]): Function to call when at least one hand is detected
101+
"""
102+
with self._callbacks_lock:
103+
self._enter_callback = callback
15104

16-
def on_gesture(self, gesture, callback, hand: Literal["left", "right", "both"] = "both"):
17-
pass
105+
def on_exit(self, callback: Callable[[], None]):
106+
"""
107+
Register a callback for when hands are no longer visible.
108+
109+
Args:
110+
callback (Callable[[], None]): Function to call when no hands are detected anymore
111+
"""
112+
with self._callbacks_lock:
113+
self._exit_callback = callback
18114

19-
def on_enter(self, callback, hand: Literal["left", "right", "both"] = "both"):
20-
pass
115+
def on_frame(self, callback: Callable[[np.ndarray], None]):
116+
"""
117+
Register a callback that receives each camera frame.
118+
119+
Args:
120+
callback (Callable[[np.ndarray], None]): Function to call with camera frame data. None to unregister.
121+
"""
122+
with self._callbacks_lock:
123+
self._frame_callback = callback
124+
125+
@brick.loop
126+
def _capture_loop(self):
127+
"""Continuously capture frames from camera (runs in dedicated thread)."""
128+
try:
129+
frame = self.camera.capture()
130+
if frame is None:
131+
time.sleep(0.01)
132+
return
133+
134+
with self._callbacks_lock:
135+
frame_cb = self._frame_callback
136+
if frame_cb:
137+
try:
138+
frame_cb(frame)
139+
except Exception as e:
140+
print(f"Error in frame callback: {e}")
141+
142+
jpeg_frame = compress_to_jpeg(frame)
143+
if jpeg_frame is None:
144+
time.sleep(0.01)
145+
return
21146

22-
def on_exit(self, callback, hand: Literal["left", "right", "both"] = "both"):
23-
pass
147+
try:
148+
self._camera_frame_queue.put(jpeg_frame, block=False)
149+
except queue.Full:
150+
# Drop oldest frame and add new one
151+
try:
152+
self._camera_frame_queue.get_nowait()
153+
self._camera_frame_queue.put(jpeg_frame, block=False)
154+
except:
155+
pass
156+
157+
except Exception as e:
158+
if self._running:
159+
print(f"Error capturing frame: {e}")
160+
161+
@brick.execute
162+
def _send_receive_loop(self):
163+
"""Run the asyncio event loop in a dedicated thread."""
164+
loop = asyncio.new_event_loop()
165+
asyncio.set_event_loop(loop)
166+
167+
try:
168+
tasks = asyncio.gather(
169+
self._send_frames_task(),
170+
self._receive_detections_task(),
171+
return_exceptions=True
172+
)
173+
loop.run_until_complete(tasks)
24174

25-
def on_frame(self, callback):
26-
pass
175+
except Exception as e:
176+
print(f"Error in asyncio loop: {e}")
177+
finally:
178+
loop.close()
179+
180+
async def _send_frames_task(self):
181+
"""Send frames to the processing container via WebSocket."""
182+
while self._running:
183+
try:
184+
async with websockets.connect(self._ws_send_url) as ws:
185+
while self._running:
186+
try:
187+
frame = await asyncio.get_event_loop().run_in_executor(
188+
None, self._camera_frame_queue.get, True, 0.1
189+
)
190+
except queue.Empty:
191+
continue
192+
193+
b64_frame = base64.b64encode(frame.tobytes()).decode('utf-8')
194+
payload = {
195+
"frame": b64_frame,
196+
"width": 640,
197+
"height": 480
198+
}
199+
200+
await ws.send(json.dumps(payload))
201+
202+
except Exception as e:
203+
if self._running:
204+
print(f"Error in send frames task: {e}. Reconnecting...")
205+
await asyncio.sleep(3)
206+
207+
async def _receive_detections_task(self):
208+
"""Receive detection results and dispatch events."""
209+
while self._running:
210+
try:
211+
async with websockets.connect(self._ws_recv_url) as ws:
212+
while self._running:
213+
data = await ws.recv()
214+
detection = json.loads(data)
215+
216+
self._process_detection(detection.get('metadata', {}))
217+
218+
except Exception as e:
219+
if self._running:
220+
print(f"Error in receive detections task: {e}. Reconnecting...")
221+
await asyncio.sleep(3)
222+
223+
def _process_detection(self, metadata: dict):
224+
"""Process detection data and dispatch appropriate events."""
225+
hands_data = metadata.get('hands', [])
226+
has_hands = bool(hands_data)
227+
228+
# Dispatch hand enter/exit events
229+
if has_hands and not self._had_hands:
230+
self._dispatch_enter()
231+
elif self._had_hands and not has_hands:
232+
self._dispatch_exit()
233+
234+
self._had_hands = has_hands
235+
236+
# Dispatch hand gesture events
237+
for hand_data in hands_data:
238+
hand = hand_data.get('hand', '')
239+
gesture = hand_data.get('gesture', '')
240+
if hand in ('left', 'right') and gesture:
241+
self._dispatch_gesture(gesture, hand, metadata)
242+
243+
def _dispatch_enter(self):
244+
"""Dispatch hand enter event."""
245+
with self._callbacks_lock:
246+
callback = self._enter_callback
247+
248+
if callback:
249+
try:
250+
callback()
251+
except Exception as e:
252+
print(f"Error in enter callback: {e}")
253+
254+
def _dispatch_exit(self):
255+
"""Dispatch hand exit event."""
256+
with self._callbacks_lock:
257+
callback = self._exit_callback
258+
259+
if callback:
260+
try:
261+
callback()
262+
except Exception as e:
263+
print(f"Error in exit callback: {e}")
264+
265+
def _dispatch_gesture(self, gesture: str, hand: Literal["left", "right"], metadata: dict):
266+
"""Dispatch gesture event to registered callbacks."""
267+
callbacks_to_call = []
268+
269+
with self._callbacks_lock:
270+
# Check for exact hand match
271+
exact_key = (gesture, hand)
272+
if exact_key in self._gesture_callbacks:
273+
callbacks_to_call.append(self._gesture_callbacks[exact_key])
274+
275+
# Check for "both" wildcard
276+
both_key = (gesture, "both")
277+
if both_key in self._gesture_callbacks:
278+
callbacks_to_call.append(self._gesture_callbacks[both_key])
279+
280+
for callback in callbacks_to_call:
281+
try:
282+
callback(metadata)
283+
except Exception as e:
284+
print(f"Error in gesture callback: {e}")

src/arduino/app_bricks/hand_gesture_detection/examples/01_gesture.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
# EXAMPLE_REQUIRES = "Requires a connected camera"
77

88
from arduino.app_bricks.hand_gesture_detection import HandGestureTracking
9+
from arduino.app_peripherals.camera.websocket_camera import WebSocketCamera
910
from arduino.app_utils.app import App
1011

11-
pd = HandGestureTracking()
12-
pd.on_gesture("Victory", lambda: print("All your bases are belong to us"))
13-
pd.on_gesture("Open_Palm", lambda: print("Moving left!"), hand="left")
14-
pd.on_gesture("Open_Palm", lambda: print("Moving right!"), hand="right")
12+
camera = WebSocketCamera()
13+
camera.start()
14+
pd = HandGestureTracking(camera)
15+
pd.on_gesture("Victory", lambda meta: print("All your bases are belong to us"))
16+
pd.on_gesture("Open_Palm", lambda meta: print("Moving left!"), hand="left")
17+
pd.on_gesture("Open_Palm", lambda meta: print("Moving right!"), hand="right")
1518

1619
App.run()

src/arduino/app_bricks/hand_gesture_detection/examples/02_enter_exit.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
# EXAMPLE_REQUIRES = "Requires a connected camera"
77

88
from arduino.app_bricks.hand_gesture_detection import HandGestureTracking
9+
from arduino.app_peripherals.camera.websocket_camera import WebSocketCamera
910
from arduino.app_utils.app import App
1011

11-
pd = HandGestureTracking()
12+
camera = WebSocketCamera()
13+
camera.start()
14+
pd = HandGestureTracking(camera)
1215
pd.on_enter(lambda: print("Hi there!"))
1316
pd.on_exit(lambda: print("Goodbye!"))
1417

src/arduino/app_bricks/hand_gesture_detection/examples/03_confirm_deny.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66
# EXAMPLE_REQUIRES = "Requires a connected camera"
77

88
from arduino.app_bricks.hand_gesture_detection import HandGestureTracking
9+
from arduino.app_peripherals.camera.websocket_camera import WebSocketCamera
910
from arduino.app_utils.app import App
1011

11-
pd = HandGestureTracking()
12-
pd.on_gesture("Thumb_Up", lambda: print("Operation confirmed!"))
13-
pd.on_gesture("Thumb_Down", lambda: print("Operation denied!"))
12+
camera = WebSocketCamera()
13+
camera.start()
14+
pd = HandGestureTracking(camera)
15+
pd.on_gesture("Thumb_Up", lambda meta: print("Operation confirmed!"))
16+
pd.on_gesture("Thumb_Down", lambda meta: print("Operation denied!"))
1417

1518
App.run()

0 commit comments

Comments
 (0)