Desmond-Dong commited on
Commit
38eb708
·
1 Parent(s): 01429cd

手势识别改用纯OpenCV实现,无额外依赖

Browse files
pyproject.toml CHANGED
@@ -38,9 +38,6 @@ dependencies = [
38
  "supervision>=0.25.0",
39
  "huggingface_hub>=0.27.0",
40
 
41
- # Gesture detection (MediaPipe - runs locally)
42
- "mediapipe>=0.10.0",
43
-
44
  # Sendspin synchronized audio (optional, for multi-room playback)
45
  "aiosendspin>=2.0.1",
46
  ]
 
38
  "supervision>=0.25.0",
39
  "huggingface_hub>=0.27.0",
40
 
 
 
 
41
  # Sendspin synchronized audio (optional, for multi-room playback)
42
  "aiosendspin>=2.0.1",
43
  ]
reachy_mini_ha_voice/gesture_detector.py CHANGED
@@ -1,19 +1,23 @@
1
- """Gesture detection using MediaPipe Hands.
2
 
3
  Detects hand gestures for robot interaction:
4
- - Thumbs up: Confirmation/like
5
- - Open palm (stop): Stop speaking/cancel
 
 
 
 
6
 
7
- Uses MediaPipe which is lightweight and can run alongside YOLO face detection.
8
  """
9
 
10
  from __future__ import annotations
11
  import logging
12
  from enum import Enum
13
- from typing import Optional, Tuple, Callable, List
14
- import threading
15
  import time
16
 
 
17
  import numpy as np
18
  from numpy.typing import NDArray
19
 
@@ -23,43 +27,30 @@ logger = logging.getLogger(__name__)
23
  class Gesture(Enum):
24
  """Recognized gestures."""
25
  NONE = "none"
26
- THUMBS_UP = "thumbs_up" # 👍 Confirm/like
27
- THUMBS_DOWN = "thumbs_down" # 👎 Reject/dislike
28
- OPEN_PALM = "open_palm" # ✋ Stop
29
- FIST = "fist" # ✊ Pause/hold
30
- PEACE = "peace" # ✌️ Victory/peace sign
31
- OK = "ok" # 👌 OK sign
32
- POINTING_UP = "pointing_up" # ☝️ Attention/one
33
- WAVE = "wave" # 👋 Hello/goodbye (open palm moving)
34
 
35
 
36
  class GestureDetector:
37
- """Lightweight gesture detector using MediaPipe Hands.
38
-
39
- Designed to run alongside YOLO face detection with minimal overhead.
40
- """
41
 
42
  def __init__(
43
  self,
44
- min_detection_confidence: float = 0.6,
45
- min_tracking_confidence: float = 0.5,
46
- max_num_hands: int = 1,
47
  ) -> None:
48
  """Initialize gesture detector.
49
 
50
  Args:
51
- min_detection_confidence: Minimum confidence for hand detection
52
- min_tracking_confidence: Minimum confidence for hand tracking
53
- max_num_hands: Maximum number of hands to detect (1 is faster)
54
  """
55
- self._min_detection_confidence = min_detection_confidence
56
- self._min_tracking_confidence = min_tracking_confidence
57
- self._max_num_hands = max_num_hands
58
-
59
- self._hands = None
60
- self._mp_hands = None
61
- self._load_attempted = False
62
- self._load_error: Optional[str] = None
63
 
64
  # Gesture callbacks
65
  self._on_thumbs_up: Optional[Callable[[], None]] = None
@@ -67,58 +58,28 @@ class GestureDetector:
67
  self._on_open_palm: Optional[Callable[[], None]] = None
68
  self._on_fist: Optional[Callable[[], None]] = None
69
  self._on_peace: Optional[Callable[[], None]] = None
70
- self._on_ok: Optional[Callable[[], None]] = None
71
  self._on_pointing_up: Optional[Callable[[], None]] = None
72
 
73
- # Gesture state (for debouncing)
74
  self._last_gesture = Gesture.NONE
75
- self._current_gesture = Gesture.NONE # Currently active gesture (for HA entity)
76
  self._gesture_start_time: Optional[float] = None
77
- self._gesture_hold_threshold = 0.5 # Hold gesture for 0.5s to trigger
78
- self._gesture_cooldown = 1.5 # Cooldown between triggers
79
  self._last_trigger_time: float = 0
80
- self._gesture_clear_delay = 2.0 # Clear gesture after 2s of no detection
81
  self._last_gesture_time: float = 0
82
 
83
- # Load model
84
- self._load_model()
85
-
86
- def _load_model(self) -> None:
87
- """Load MediaPipe Hands model."""
88
- if self._load_attempted:
89
- return
90
-
91
- self._load_attempted = True
92
-
93
- try:
94
- import mediapipe as mp
95
-
96
- self._mp_hands = mp.solutions.hands
97
- self._hands = self._mp_hands.Hands(
98
- static_image_mode=False,
99
- max_num_hands=self._max_num_hands,
100
- min_detection_confidence=self._min_detection_confidence,
101
- min_tracking_confidence=self._min_tracking_confidence,
102
- )
103
- logger.info("MediaPipe Hands loaded for gesture detection")
104
- except ImportError as e:
105
- self._load_error = f"Missing mediapipe: {e}"
106
- logger.warning(
107
- "Gesture detection disabled - missing mediapipe. "
108
- "Install with: pip install mediapipe"
109
- )
110
- except Exception as e:
111
- self._load_error = str(e)
112
- logger.error("Failed to load MediaPipe Hands: %s", e)
113
 
114
  @property
115
  def is_available(self) -> bool:
116
- """Check if gesture detector is available."""
117
- return self._hands is not None
118
 
119
  @property
120
  def current_gesture(self) -> Gesture:
121
- """Get current detected gesture (for HA entity)."""
122
  return self._current_gesture
123
 
124
  def set_callbacks(
@@ -131,138 +92,158 @@ class GestureDetector:
131
  on_ok: Optional[Callable[[], None]] = None,
132
  on_pointing_up: Optional[Callable[[], None]] = None,
133
  ) -> None:
134
- """Set gesture callbacks.
135
-
136
- Args:
137
- on_thumbs_up: Called when thumbs up is detected (confirm/like)
138
- on_thumbs_down: Called when thumbs down is detected (reject)
139
- on_open_palm: Called when open palm is detected (stop)
140
- on_fist: Called when fist is detected (pause)
141
- on_peace: Called when peace sign is detected
142
- on_ok: Called when OK sign is detected
143
- on_pointing_up: Called when pointing up is detected
144
- """
145
  self._on_thumbs_up = on_thumbs_up
146
  self._on_thumbs_down = on_thumbs_down
147
  self._on_open_palm = on_open_palm
148
  self._on_fist = on_fist
149
  self._on_peace = on_peace
150
- self._on_ok = on_ok
151
  self._on_pointing_up = on_pointing_up
152
 
153
- def _get_landmark_coords(
154
- self, landmarks, idx: int
155
- ) -> Tuple[float, float, float]:
156
- """Get x, y, z coordinates for a landmark."""
157
- lm = landmarks.landmark[idx]
158
- return lm.x, lm.y, lm.z
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
- def _classify_gesture(self, hand_landmarks) -> Gesture:
161
- """Classify hand gesture from landmarks.
162
-
163
- Landmark indices (MediaPipe):
164
- - 0: Wrist
165
- - 4: Thumb tip
166
- - 8: Index finger tip
167
- - 12: Middle finger tip
168
- - 16: Ring finger tip
169
- - 20: Pinky tip
170
- - 2, 3: Thumb joints
171
- - 5, 6, 7: Index joints
172
- - etc.
173
  """
174
- # Get key landmarks
175
- wrist = self._get_landmark_coords(hand_landmarks, 0)
176
-
177
- # Thumb landmarks
178
- thumb_tip = self._get_landmark_coords(hand_landmarks, 4)
179
- thumb_ip = self._get_landmark_coords(hand_landmarks, 3)
180
- thumb_mcp = self._get_landmark_coords(hand_landmarks, 2)
181
- thumb_cmc = self._get_landmark_coords(hand_landmarks, 1)
182
-
183
- # Index finger landmarks
184
- index_tip = self._get_landmark_coords(hand_landmarks, 8)
185
- index_dip = self._get_landmark_coords(hand_landmarks, 7)
186
- index_pip = self._get_landmark_coords(hand_landmarks, 6)
187
- index_mcp = self._get_landmark_coords(hand_landmarks, 5)
188
-
189
- # Middle finger landmarks
190
- middle_tip = self._get_landmark_coords(hand_landmarks, 12)
191
- middle_dip = self._get_landmark_coords(hand_landmarks, 11)
192
- middle_pip = self._get_landmark_coords(hand_landmarks, 10)
193
- middle_mcp = self._get_landmark_coords(hand_landmarks, 9)
194
-
195
- # Ring finger landmarks
196
- ring_tip = self._get_landmark_coords(hand_landmarks, 16)
197
- ring_dip = self._get_landmark_coords(hand_landmarks, 15)
198
- ring_pip = self._get_landmark_coords(hand_landmarks, 14)
199
- ring_mcp = self._get_landmark_coords(hand_landmarks, 13)
200
-
201
- # Pinky landmarks
202
- pinky_tip = self._get_landmark_coords(hand_landmarks, 20)
203
- pinky_dip = self._get_landmark_coords(hand_landmarks, 19)
204
- pinky_pip = self._get_landmark_coords(hand_landmarks, 18)
205
- pinky_mcp = self._get_landmark_coords(hand_landmarks, 17)
206
-
207
- # Check if fingers are extended (tip above PIP in y, y increases downward)
208
- index_extended = index_tip[1] < index_pip[1]
209
- middle_extended = middle_tip[1] < middle_pip[1]
210
- ring_extended = ring_tip[1] < ring_pip[1]
211
- pinky_extended = pinky_tip[1] < pinky_pip[1]
212
-
213
- # Thumb extended check (horizontal distance from palm)
214
- thumb_extended = abs(thumb_tip[0] - index_mcp[0]) > 0.08
215
-
216
- # Thumb pointing direction
217
- thumb_pointing_up = thumb_tip[1] < thumb_mcp[1] - 0.05
218
- thumb_pointing_down = thumb_tip[1] > thumb_mcp[1] + 0.05
219
-
220
- # Count extended fingers (excluding thumb)
221
- extended_count = sum([index_extended, middle_extended, ring_extended, pinky_extended])
222
-
223
- # Fingers curled (tip below MCP)
224
- index_curled = index_tip[1] > index_mcp[1]
225
- middle_curled = middle_tip[1] > middle_mcp[1]
226
- ring_curled = ring_tip[1] > ring_mcp[1]
227
- pinky_curled = pinky_tip[1] > pinky_mcp[1]
228
- all_fingers_curled = index_curled and middle_curled and ring_curled and pinky_curled
229
-
230
- # ===== Gesture Classification =====
231
-
232
- # 👍 Thumbs up: thumb pointing up, all other fingers curled
233
- if thumb_pointing_up and thumb_extended and all_fingers_curled:
234
- return Gesture.THUMBS_UP
235
-
236
- # 👎 Thumbs down: thumb pointing down, all other fingers curled
237
- if thumb_pointing_down and thumb_extended and all_fingers_curled:
238
- return Gesture.THUMBS_DOWN
239
-
240
- # ✊ Fist: all fingers curled including thumb tucked
241
- if all_fingers_curled and not thumb_extended:
242
- return Gesture.FIST
243
 
244
- # ✌️ Peace: index and middle extended, others curled
245
- if index_extended and middle_extended and not ring_extended and not pinky_extended:
246
- return Gesture.PEACE
247
 
248
- # ☝️ Pointing up: only index extended
249
- if index_extended and not middle_extended and not ring_extended and not pinky_extended:
250
- return Gesture.POINTING_UP
251
 
252
- # 👌 OK sign: thumb and index tips close together, other fingers extended
253
- thumb_index_distance = self._distance(thumb_tip, index_tip)
254
- if thumb_index_distance < 0.05 and middle_extended and ring_extended and pinky_extended:
255
- return Gesture.OK
256
 
257
- # Open palm: all fingers extended
258
- if extended_count >= 4:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  return Gesture.OPEN_PALM
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
  return Gesture.NONE
262
-
263
- def _distance(self, p1: Tuple[float, float, float], p2: Tuple[float, float, float]) -> float:
264
- """Calculate Euclidean distance between two points."""
265
- return ((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2 + (p1[2] - p2[2])**2) ** 0.5
266
 
267
  def detect(self, frame: NDArray[np.uint8]) -> Gesture:
268
  """Detect gesture in frame.
@@ -271,44 +252,27 @@ class GestureDetector:
271
  frame: BGR image from camera
272
 
273
  Returns:
274
- Detected gesture (may be NONE)
275
  """
276
- if not self.is_available:
277
- return Gesture.NONE
278
-
279
  try:
280
- import cv2
281
-
282
- # Convert BGR to RGB for MediaPipe
283
- rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
284
 
285
- # Process frame
286
- results = self._hands.process(rgb_frame)
287
 
288
- if not results.multi_hand_landmarks:
289
  return Gesture.NONE
290
 
291
- # Use first detected hand
292
- hand_landmarks = results.multi_hand_landmarks[0]
293
- gesture = self._classify_gesture(hand_landmarks)
294
-
295
- return gesture
296
 
297
  except Exception as e:
298
  logger.debug("Gesture detection error: %s", e)
299
  return Gesture.NONE
300
 
301
  def process_frame(self, frame: NDArray[np.uint8]) -> Optional[Gesture]:
302
- """Process frame and trigger callbacks if gesture held.
303
-
304
- This method handles debouncing and cooldown logic.
305
-
306
- Args:
307
- frame: BGR image from camera
308
-
309
- Returns:
310
- Triggered gesture or None
311
- """
312
  current_gesture = self.detect(frame)
313
  current_time = time.time()
314
 
@@ -317,7 +281,6 @@ class GestureDetector:
317
  self._current_gesture = current_gesture
318
  self._last_gesture_time = current_time
319
  elif current_time - self._last_gesture_time > self._gesture_clear_delay:
320
- # Clear gesture after delay
321
  self._current_gesture = Gesture.NONE
322
 
323
  # Check cooldown
@@ -335,37 +298,32 @@ class GestureDetector:
335
  hold_duration = current_time - self._gesture_start_time
336
 
337
  if hold_duration >= self._gesture_hold_threshold:
338
- # Trigger callback
339
  self._last_trigger_time = current_time
340
- self._gesture_start_time = None # Reset to prevent re-trigger
341
 
342
- # Get callback for this gesture
343
  callback = self._get_callback_for_gesture(current_gesture)
344
  if callback:
345
  logger.info("Gesture triggered: %s", current_gesture.value)
346
  try:
347
  callback()
348
  except Exception as e:
349
- logger.error("Gesture callback error (%s): %s", current_gesture.value, e)
350
  return current_gesture
351
 
352
  return None
353
-
354
  def _get_callback_for_gesture(self, gesture: Gesture) -> Optional[Callable[[], None]]:
355
- """Get the callback function for a gesture."""
356
  callbacks = {
357
  Gesture.THUMBS_UP: self._on_thumbs_up,
358
  Gesture.THUMBS_DOWN: self._on_thumbs_down,
359
  Gesture.OPEN_PALM: self._on_open_palm,
360
  Gesture.FIST: self._on_fist,
361
  Gesture.PEACE: self._on_peace,
362
- Gesture.OK: self._on_ok,
363
  Gesture.POINTING_UP: self._on_pointing_up,
364
  }
365
  return callbacks.get(gesture)
366
 
367
  def close(self) -> None:
368
  """Release resources."""
369
- if self._hands is not None:
370
- self._hands.close()
371
- self._hands = None
 
1
+ """Gesture detection using OpenCV skin color detection and contour analysis.
2
 
3
  Detects hand gestures for robot interaction:
4
+ - thumbs_up: Confirmation/like
5
+ - thumbs_down: Reject/dislike
6
+ - open_palm: Stop speaking/cancel (5 fingers)
7
+ - fist: Pause/hold (0 fingers)
8
+ - peace: Victory sign (2 fingers)
9
+ - pointing_up: Attention (1 finger)
10
 
11
+ Uses pure OpenCV - no additional dependencies required.
12
  """
13
 
14
  from __future__ import annotations
15
  import logging
16
  from enum import Enum
17
+ from typing import Optional, Tuple, Callable
 
18
  import time
19
 
20
+ import cv2
21
  import numpy as np
22
  from numpy.typing import NDArray
23
 
 
27
  class Gesture(Enum):
28
  """Recognized gestures."""
29
  NONE = "none"
30
+ THUMBS_UP = "thumbs_up"
31
+ THUMBS_DOWN = "thumbs_down"
32
+ OPEN_PALM = "open_palm"
33
+ FIST = "fist"
34
+ PEACE = "peace"
35
+ POINTING_UP = "pointing_up"
 
 
36
 
37
 
38
  class GestureDetector:
39
+ """Gesture detector using OpenCV skin detection and convex hull analysis."""
 
 
 
40
 
41
  def __init__(
42
  self,
43
+ min_hand_area: int = 5000,
44
+ max_hand_area: int = 150000,
 
45
  ) -> None:
46
  """Initialize gesture detector.
47
 
48
  Args:
49
+ min_hand_area: Minimum contour area to consider as hand
50
+ max_hand_area: Maximum contour area to consider as hand
 
51
  """
52
+ self._min_hand_area = min_hand_area
53
+ self._max_hand_area = max_hand_area
 
 
 
 
 
 
54
 
55
  # Gesture callbacks
56
  self._on_thumbs_up: Optional[Callable[[], None]] = None
 
58
  self._on_open_palm: Optional[Callable[[], None]] = None
59
  self._on_fist: Optional[Callable[[], None]] = None
60
  self._on_peace: Optional[Callable[[], None]] = None
 
61
  self._on_pointing_up: Optional[Callable[[], None]] = None
62
 
63
+ # Gesture state
64
  self._last_gesture = Gesture.NONE
65
+ self._current_gesture = Gesture.NONE
66
  self._gesture_start_time: Optional[float] = None
67
+ self._gesture_hold_threshold = 0.5
68
+ self._gesture_cooldown = 1.5
69
  self._last_trigger_time: float = 0
70
+ self._gesture_clear_delay = 2.0
71
  self._last_gesture_time: float = 0
72
 
73
+ logger.info("OpenCV gesture detector initialized")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  @property
76
  def is_available(self) -> bool:
77
+ """Always available - uses OpenCV only."""
78
+ return True
79
 
80
  @property
81
  def current_gesture(self) -> Gesture:
82
+ """Get current detected gesture."""
83
  return self._current_gesture
84
 
85
  def set_callbacks(
 
92
  on_ok: Optional[Callable[[], None]] = None,
93
  on_pointing_up: Optional[Callable[[], None]] = None,
94
  ) -> None:
95
+ """Set gesture callbacks."""
 
 
 
 
 
 
 
 
 
 
96
  self._on_thumbs_up = on_thumbs_up
97
  self._on_thumbs_down = on_thumbs_down
98
  self._on_open_palm = on_open_palm
99
  self._on_fist = on_fist
100
  self._on_peace = on_peace
 
101
  self._on_pointing_up = on_pointing_up
102
 
103
+ def _detect_skin(self, frame: NDArray[np.uint8]) -> NDArray[np.uint8]:
104
+ """Detect skin regions using YCrCb color space."""
105
+ # Convert to YCrCb
106
+ ycrcb = cv2.cvtColor(frame, cv2.COLOR_BGR2YCrCb)
107
+
108
+ # Skin color range in YCrCb
109
+ lower = np.array([0, 133, 77], dtype=np.uint8)
110
+ upper = np.array([255, 173, 127], dtype=np.uint8)
111
+
112
+ # Create mask
113
+ mask = cv2.inRange(ycrcb, lower, upper)
114
+
115
+ # Morphological operations to clean up
116
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
117
+ mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
118
+ mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
119
+ mask = cv2.GaussianBlur(mask, (5, 5), 0)
120
+
121
+ return mask
122
+
123
+ def _find_hand_contour(self, mask: NDArray[np.uint8]) -> Optional[NDArray]:
124
+ """Find the largest hand contour in the mask."""
125
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
126
+
127
+ if not contours:
128
+ return None
129
+
130
+ # Find largest contour within size bounds
131
+ valid_contours = []
132
+ for cnt in contours:
133
+ area = cv2.contourArea(cnt)
134
+ if self._min_hand_area < area < self._max_hand_area:
135
+ valid_contours.append((area, cnt))
136
+
137
+ if not valid_contours:
138
+ return None
139
+
140
+ # Return largest valid contour
141
+ valid_contours.sort(key=lambda x: x[0], reverse=True)
142
+ return valid_contours[0][1]
143
 
144
+ def _count_fingers(self, contour: NDArray, frame_height: int) -> Tuple[int, bool, float]:
145
+ """Count extended fingers using convex hull defects.
146
+
147
+ Returns:
148
+ Tuple of (finger_count, is_thumb_extended, hand_center_y_ratio)
 
 
 
 
 
 
 
 
149
  """
150
+ # Get convex hull
151
+ hull = cv2.convexHull(contour, returnPoints=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
+ if len(hull) < 3:
154
+ return 0, False, 0.5
 
155
 
156
+ # Get convex hull points for centroid
157
+ hull_points = cv2.convexHull(contour)
 
158
 
159
+ # Calculate centroid
160
+ M = cv2.moments(contour)
161
+ if M["m00"] == 0:
162
+ return 0, False, 0.5
163
 
164
+ cx = int(M["m10"] / M["m00"])
165
+ cy = int(M["m01"] / M["m00"])
166
+ center_y_ratio = cy / frame_height
167
+
168
+ # Get bounding rect for reference
169
+ x, y, w, h = cv2.boundingRect(contour)
170
+
171
+ # Get convexity defects
172
+ try:
173
+ defects = cv2.convexityDefects(contour, hull)
174
+ except cv2.error:
175
+ return 0, False, center_y_ratio
176
+
177
+ if defects is None:
178
+ return 0, False, center_y_ratio
179
+
180
+ # Count fingers based on defects
181
+ finger_count = 0
182
+ thumb_extended = False
183
+
184
+ for i in range(defects.shape[0]):
185
+ s, e, f, d = defects[i, 0]
186
+ start = tuple(contour[s][0])
187
+ end = tuple(contour[e][0])
188
+ far = tuple(contour[f][0])
189
+
190
+ # Calculate distances
191
+ a = np.sqrt((end[0] - start[0])**2 + (end[1] - start[1])**2)
192
+ b = np.sqrt((far[0] - start[0])**2 + (far[1] - start[1])**2)
193
+ c = np.sqrt((end[0] - far[0])**2 + (end[1] - far[1])**2)
194
+
195
+ # Calculate angle using cosine rule
196
+ if b * c == 0:
197
+ continue
198
+ angle = np.arccos((b**2 + c**2 - a**2) / (2 * b * c))
199
+
200
+ # Finger detected if angle < 90 degrees and defect depth is significant
201
+ if angle <= np.pi / 2 and d > 10000:
202
+ finger_count += 1
203
+
204
+ # Check if this might be thumb (on side of hand)
205
+ if abs(start[0] - cx) > w * 0.3 or abs(end[0] - cx) > w * 0.3:
206
+ thumb_extended = True
207
+
208
+ # Add 1 because defects count spaces between fingers
209
+ if finger_count > 0:
210
+ finger_count += 1
211
+
212
+ return min(finger_count, 5), thumb_extended, center_y_ratio
213
+
214
+ def _classify_gesture(self, contour: NDArray, frame_height: int) -> Gesture:
215
+ """Classify gesture based on contour analysis."""
216
+ finger_count, thumb_extended, center_y = self._count_fingers(contour, frame_height)
217
+
218
+ # Get contour properties
219
+ x, y, w, h = cv2.boundingRect(contour)
220
+ aspect_ratio = float(w) / h if h > 0 else 1.0
221
+
222
+ # Get hull area ratio (solidity)
223
+ hull = cv2.convexHull(contour)
224
+ hull_area = cv2.contourArea(hull)
225
+ contour_area = cv2.contourArea(contour)
226
+ solidity = contour_area / hull_area if hull_area > 0 else 0
227
+
228
+ # Classify based on finger count and shape
229
+ if finger_count >= 4:
230
  return Gesture.OPEN_PALM
231
+ elif finger_count == 2:
232
+ return Gesture.PEACE
233
+ elif finger_count == 1:
234
+ # Check if pointing up or thumb gesture
235
+ if aspect_ratio < 0.7: # Tall and narrow = pointing up
236
+ return Gesture.POINTING_UP
237
+ elif thumb_extended:
238
+ # Check thumb direction based on position
239
+ if center_y < 0.5: # Hand in upper half
240
+ return Gesture.THUMBS_UP
241
+ else:
242
+ return Gesture.THUMBS_DOWN
243
+ elif finger_count == 0 and solidity > 0.8:
244
+ return Gesture.FIST
245
 
246
  return Gesture.NONE
 
 
 
 
247
 
248
  def detect(self, frame: NDArray[np.uint8]) -> Gesture:
249
  """Detect gesture in frame.
 
252
  frame: BGR image from camera
253
 
254
  Returns:
255
+ Detected gesture
256
  """
 
 
 
257
  try:
258
+ # Detect skin
259
+ mask = self._detect_skin(frame)
 
 
260
 
261
+ # Find hand contour
262
+ contour = self._find_hand_contour(mask)
263
 
264
+ if contour is None:
265
  return Gesture.NONE
266
 
267
+ # Classify gesture
268
+ return self._classify_gesture(contour, frame.shape[0])
 
 
 
269
 
270
  except Exception as e:
271
  logger.debug("Gesture detection error: %s", e)
272
  return Gesture.NONE
273
 
274
  def process_frame(self, frame: NDArray[np.uint8]) -> Optional[Gesture]:
275
+ """Process frame and trigger callbacks if gesture held."""
 
 
 
 
 
 
 
 
 
276
  current_gesture = self.detect(frame)
277
  current_time = time.time()
278
 
 
281
  self._current_gesture = current_gesture
282
  self._last_gesture_time = current_time
283
  elif current_time - self._last_gesture_time > self._gesture_clear_delay:
 
284
  self._current_gesture = Gesture.NONE
285
 
286
  # Check cooldown
 
298
  hold_duration = current_time - self._gesture_start_time
299
 
300
  if hold_duration >= self._gesture_hold_threshold:
 
301
  self._last_trigger_time = current_time
302
+ self._gesture_start_time = None
303
 
 
304
  callback = self._get_callback_for_gesture(current_gesture)
305
  if callback:
306
  logger.info("Gesture triggered: %s", current_gesture.value)
307
  try:
308
  callback()
309
  except Exception as e:
310
+ logger.error("Gesture callback error: %s", e)
311
  return current_gesture
312
 
313
  return None
314
+
315
  def _get_callback_for_gesture(self, gesture: Gesture) -> Optional[Callable[[], None]]:
316
+ """Get callback for gesture."""
317
  callbacks = {
318
  Gesture.THUMBS_UP: self._on_thumbs_up,
319
  Gesture.THUMBS_DOWN: self._on_thumbs_down,
320
  Gesture.OPEN_PALM: self._on_open_palm,
321
  Gesture.FIST: self._on_fist,
322
  Gesture.PEACE: self._on_peace,
 
323
  Gesture.POINTING_UP: self._on_pointing_up,
324
  }
325
  return callbacks.get(gesture)
326
 
327
  def close(self) -> None:
328
  """Release resources."""
329
+ pass