Deepak Roshan commited on
Commit
9519bd2
·
1 Parent(s): 4c2f59c

perf: fix lag — smaller frames 160x120@35%, flow control, nginx 512k body buffer, 320x240 output

Browse files
Files changed (3) hide show
  1. isl_detection.py +333 -270
  2. nginx.conf +8 -0
  3. templates/dashboard.html +19 -8
isl_detection.py CHANGED
@@ -1,24 +1,24 @@
1
- # isl_detection.py — CLOUD PRODUCTION VERSION (HF SPACES COMPATIBLE)
2
  #
3
  # ════════════════════════════════════════════════════════════════════════════
4
- # KEY FIXES IN THIS VERSION:
5
  #
6
- # FIX A — SharedState uses queue.Queue (no Manager, no IPC crash)
7
- # FIX B — _cloud_frame is now a dict {"data": ..., "ts": ...} with a lock
8
- # so the detection loop and socket handler never race
9
- # FIX C — Frame decode is hardened: strips header prefix safely every time
10
- # FIX D — Added detailed per-stage debug prints so you can see exactly
11
- # where the pipeline breaks from HF Logs tab
12
- # FIX E — mediapipe hand + face results checked for None before use
13
- # FIX F — model input shape validated before predict (guards wrong .h5)
14
  #
15
- # All original features preserved:
 
 
 
 
16
  # ✅ Emotion detection v3 (happy/sad/angry/surprise/neutral)
17
  # ✅ Letter hold timer, same-letter cooldown, confidence buffer
18
- # ✅ CLOUD mode browser frame ingestion
19
- # ✅ gTTS speak button (disabled in cloud)
 
20
  # ✅ Word suggestions, auto-TTS, reset, backspace, accept_suggestion
21
- # ✅ TARGET_FPS=15, EMOTION_PROCESS_INTERVAL=5
22
  # ════════════════════════════════════════════════════════════════════════════
23
 
24
  import cv2
@@ -33,27 +33,15 @@ import base64
33
  import platform
34
  import threading
35
  from collections import deque, defaultdict, Counter
36
- from multiprocessing import Value
37
  import ctypes
38
- import queue as queue_module
39
 
40
- os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
41
- os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
42
- os.environ["MEDIAPIPE_DISABLE_GPU"] = "1"
43
 
44
  from tensorflow.keras.models import load_model
45
-
46
- try:
47
- import pygame
48
- _PYGAME_AVAILABLE = True
49
- except ImportError:
50
- _PYGAME_AVAILABLE = False
51
-
52
- try:
53
- from gtts import gTTS
54
- _GTTS_AVAILABLE = True
55
- except ImportError:
56
- _GTTS_AVAILABLE = False
57
 
58
  try:
59
  import translation
@@ -64,20 +52,22 @@ except ImportError:
64
  # ─────────────────────────────────────────────────────────────
65
  # CONFIG
66
  # ─────────────────────────────────────────────────────────────
67
- RUN_MODE = os.getenv("RUN_MODE", "CLOUD")
68
  ISL_MODEL_PATH = "model.h5"
69
  CAM_INDEX = 0
70
 
 
71
  LETTER_HOLD_SEC = 0.9
72
  SAME_LETTER_COOLDOWN_SEC = 1.2
73
 
 
74
  SMOOTHING_FRAMES = 2
75
  MIN_CONF_TO_SHOW = 0.06
76
  EMOTIONS = ["happy", "sad", "angry", "surprise", "neutral"]
77
 
78
  CONF_THRESHOLD = 0.32
79
  SPACE_THRESHOLD = 18
80
- ENABLE_AUTO_TTS = False # disabled in cloud; browser uses SpeechSynthesis
81
  TTS_LANGUAGE = "en"
82
 
83
  SHOW_LETTER_OVERLAY = True
@@ -98,46 +88,28 @@ CAMERA_RESOLUTION = (640, 480)
98
  EMOTION_PROCESS_INTERVAL = 5
99
  FRAME_W, FRAME_H = 640, 480
100
 
 
101
  # ─────────────────────────────────────────────────────────────
102
  # SHARED STATE
103
- # Uses stdlib queue.Queue — no Manager(), no IPC, no crashes.
104
- # _cloud_frame_lock guards the raw frame slot so the eventlet
105
- # greenlet (on_browser_frame) and the detection thread never race.
106
  # ─────────────────────────────────────────────────────────────
107
  class SharedState:
108
  def __init__(self):
109
- self.ui_queue = queue_module.Queue(maxsize=200)
110
- self.command_queue = queue_module.Queue(maxsize=20)
111
- self.processing_fps = Value(ctypes.c_double, 0.0)
112
- self._cloud_frame = None # raw base64 string (header stripped)
113
- self._cloud_frame_lock = threading.Lock()
114
- self._frame_count_in = 0 # debug: total frames received
115
- self._frame_count_proc = 0 # debug: total frames processed
116
-
117
- def put_frame(self, b64_data: str):
118
- """Called by socket handler — strips header and stores atomically."""
119
- if not b64_data:
120
- return
121
- # Strip data URI header if present (e.g. "data:image/jpeg;base64,...")
122
- if "," in b64_data:
123
- b64_data = b64_data.split(",", 1)[1]
124
- with self._cloud_frame_lock:
125
- self._cloud_frame = b64_data
126
- self._frame_count_in += 1
127
-
128
- def take_frame(self):
129
- """Called by detection thread — returns base64 string or None."""
130
- with self._cloud_frame_lock:
131
- data = self._cloud_frame
132
- self._cloud_frame = None
133
- return data
134
 
135
 
136
  # ─────────────────────────────────────────────────────────────
137
- # TTS ENGINE (LOCAL only)
 
 
 
 
 
138
  # ─────────────────────────────────────────────────────────────
139
  TTS_ENABLED = False
140
- if RUN_MODE == "LOCAL" and _PYGAME_AVAILABLE and _GTTS_AVAILABLE:
141
  try:
142
  pygame.mixer.init()
143
  TTS_ENABLED = True
@@ -145,7 +117,7 @@ if RUN_MODE == "LOCAL" and _PYGAME_AVAILABLE and _GTTS_AVAILABLE:
145
  except Exception as e:
146
  print(f"⚠️ TTS init warning: {e}")
147
  else:
148
- print("ℹ️ TTS disabled (CLOUD/headless mode)")
149
 
150
  speaking_word = ""
151
  pause_audio = False
@@ -153,6 +125,8 @@ pause_audio = False
153
 
154
  def speak_text(text, lang=TTS_LANGUAGE):
155
  global speaking_word
 
 
156
  if not TTS_ENABLED or not text or not text.strip():
157
  return
158
 
@@ -189,22 +163,6 @@ def speak_text(text, lang=TTS_LANGUAGE):
189
  threading.Thread(target=_speak, daemon=True).start()
190
 
191
 
192
- # ─────────────────────────────────────────────────────────────
193
- # FRAME DECODE HELPER
194
- # Accepts raw base64 (no header). Returns BGR numpy array or None.
195
- # ─────────────────────────────────────────────────────────────
196
- def decode_b64_frame(b64_str: str):
197
- """Decode a base64 JPEG string to a BGR OpenCV image."""
198
- try:
199
- raw_bytes = base64.b64decode(b64_str)
200
- np_arr = np.frombuffer(raw_bytes, dtype=np.uint8)
201
- frame = cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
202
- return frame # None if decode failed
203
- except Exception as e:
204
- print(f"⚠️ Frame decode error: {e}", flush=True)
205
- return None
206
-
207
-
208
  # ─────────────────────────────────────────────────────────────
209
  # LANDMARK UTILITIES
210
  # ─────────────────────────────────────────────────────────────
@@ -228,6 +186,7 @@ def pre_process_landmark(landmark_list):
228
 
229
 
230
  def apply_clahe(frame):
 
231
  try:
232
  lab = cv2.cvtColor(frame, cv2.COLOR_BGR2LAB)
233
  l, a, b = cv2.split(lab)
@@ -238,25 +197,55 @@ def apply_clahe(frame):
238
 
239
 
240
  # ───────────────────────────────────────────────────��─────────
241
- # EMOTION DETECTION v3
 
 
242
  # ─────────────────────────────────────────────────────────────
 
243
  def _dist(a, b):
 
244
  return float(np.linalg.norm(
245
  np.array(a, dtype=np.float32) - np.array(b, dtype=np.float32)
246
  ))
247
 
248
 
249
  def detect_emotion_from_landmarks(face_landmarks, img_shape):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  try:
251
  pts = np.array(face_landmarks, dtype=np.float32)
252
  face_w = max(float(np.max(pts[:, 0]) - np.min(pts[:, 0])), 1.0)
253
  face_h = max(float(np.max(pts[:, 1]) - np.min(pts[:, 1])), 1.0)
254
 
255
- def p(i): return pts[i]
256
- def nw(a,b): return _dist(p(a), p(b)) / face_w
257
- def nh(a,b): return _dist(p(a), p(b)) / face_h
258
- def clamp01(v): return max(0.0, min(1.0, float(v)))
 
 
 
 
 
 
 
259
 
 
260
  lc, rc = p(61), p(291)
261
  tl, bl = p(13), p(14)
262
  ut, lb = p(0), p(17)
@@ -268,6 +257,7 @@ def detect_emotion_from_landmarks(face_landmarks, img_shape):
268
  mouth_w = _dist(lc, rc) / face_w
269
  o_ratio = (mouth_open / (mouth_w + 1e-6)) if mouth_w > 0.05 else 0.0
270
 
 
271
  leu, led = p(159), p(145)
272
  reu, red = p(386), p(374)
273
  lel, ler = p(33), p(133)
@@ -283,6 +273,7 @@ def detect_emotion_from_landmarks(face_landmarks, img_shape):
283
  avg_ear = (l_ear + r_ear) / 2.0
284
  eye_open = ((l_eye_h + r_eye_h) / 2.0) / face_h
285
 
 
286
  lib, rib = p(70), p(300)
287
  lob, rob = p(105), p(334)
288
  lab, rab = p(52), p(282)
@@ -302,13 +293,15 @@ def detect_emotion_from_landmarks(face_landmarks, img_shape):
302
  r_arch_h = (reu[1] - rab[1]) / face_h
303
  avg_arch_h = (l_arch_h + r_arch_h) / 2.0
304
 
 
305
  nostril_w = nw(129, 358)
306
  nose_flare = max(0.0, nostril_w - 0.26)
307
 
 
308
  lch, rch, nb = p(50), p(280), p(4)
309
  cheek_raise = ((nb[1] - lch[1]) + (nb[1] - rch[1])) / (2.0 * face_h)
310
 
311
- # HAPPY
312
  happy = 0.0
313
  if mouth_curve < -0.003:
314
  happy += clamp01(abs(mouth_curve) / 0.018) * 0.55
@@ -318,12 +311,12 @@ def detect_emotion_from_landmarks(face_landmarks, img_shape):
318
  happy += clamp01(cheek_raise / 0.018) * 0.26
319
  if avg_ear < 0.27 and eye_open > 0.04:
320
  happy += 0.12
321
- if mouth_curve > 0.004: happy *= 0.15
322
- if avg_brow_in_h < -0.05: happy *= 0.40
323
- if mouth_open > 0.08: happy *= 0.55
324
  happy = clamp01(happy * 1.30)
325
 
326
- # SAD v3
327
  sad = 0.0
328
  if brow_oblique > 0.02:
329
  sad += clamp01((brow_oblique - 0.02) / 0.055) * 0.45
@@ -338,13 +331,13 @@ def detect_emotion_from_landmarks(face_landmarks, img_shape):
338
  sad += clamp01((0.36 - mouth_w) / 0.055) * 0.18
339
  if avg_brow_in_h > 0.04:
340
  sad += clamp01((avg_brow_in_h - 0.04) / 0.040) * 0.20
341
- if mouth_curve < -0.004: sad *= 0.08
342
- if cheek_raise > 0.014: sad *= 0.20
343
- if avg_ear > 0.30: sad *= 0.25
344
  if avg_brow_in_h < -0.06 and brow_inner_gap < 0.14: sad *= 0.15
345
  sad = clamp01(sad * 1.50)
346
 
347
- # ANGRY v3
348
  angry = 0.0
349
  if nose_flare > 0.005:
350
  angry += clamp01(nose_flare / 0.055) * 0.35
@@ -358,15 +351,16 @@ def detect_emotion_from_landmarks(face_landmarks, img_shape):
358
  angry += clamp01((0.055 - eye_open) / 0.025) * 0.25
359
  if inner_gap < 0.030:
360
  angry += clamp01((0.030 - inner_gap) / 0.025) * 0.22
361
- if brow_oblique < 0.015: angry += 0.08
362
- if mouth_curve < -0.005: angry *= 0.15
363
- if cheek_raise > 0.014: angry *= 0.20
364
- if avg_brow_in_h > -0.01: angry *= 0.20
365
- if mouth_open > 0.07: angry *= 0.40
366
- if brow_oblique > 0.04: angry *= 0.35
 
367
  angry = clamp01(angry * 1.45)
368
 
369
- # SURPRISE v3
370
  surprise = 0.0
371
  if 0.40 < o_ratio < 1.60:
372
  peak_dist = abs(o_ratio - 0.90)
@@ -383,16 +377,18 @@ def detect_emotion_from_landmarks(face_landmarks, img_shape):
383
  surprise += clamp01((avg_arch_h - 0.05) / 0.055) * 0.35
384
  if avg_brow_in_h > 0.04 and avg_brow_out_h > 0.02:
385
  surprise += 0.15
386
- if abs(brow_oblique) < 0.025: surprise += 0.08
387
- if mouth_curve < -0.005: surprise *= 0.30
388
- if avg_brow_in_h < -0.03: surprise *= 0.20
389
- if avg_ear < 0.18: surprise *= 0.25
390
- if brow_inner_gap < 0.13: surprise *= 0.25
391
- if mouth_w > 0.44: surprise *= 0.25
 
392
  if mouth_open > 0.12 and o_ratio < 0.40:
393
  surprise *= 0.30
394
  surprise = clamp01(surprise * 1.35)
395
 
 
396
  neutral = max(0.0, 1.0 - (happy + sad + angry + surprise) * 1.05)
397
 
398
  total = happy + sad + angry + surprise + neutral
@@ -407,12 +403,12 @@ def detect_emotion_from_landmarks(face_landmarks, img_shape):
407
  for k, v in zip(EMOTIONS, [happy, sad, angry, surprise, neutral])}
408
 
409
  except Exception as ex:
410
- print(f"⚠️ Emotion detection error: {ex}", flush=True)
411
  return {e: (1.0 if e == "neutral" else 0.0) for e in EMOTIONS}
412
 
413
 
414
  # ─────────────────────────────────────────────────────────────
415
- # DISPLAY / SUGGESTION HELPERS
416
  # ─────────────────────────────────────────────────────────────
417
  def get_word_suggestions(partial, word_freq):
418
  if not partial:
@@ -462,38 +458,49 @@ def draw_letter_overlay(frame, letter, confidence):
462
  # CORE PROCESSING ENGINE
463
  # ─────────────────────────────────────────────────────────────
464
  def core_processing_engine(shared_state, shm_name=None, frame_lock_val=None, frame_seq=None):
465
- print("=" * 70, flush=True)
466
- print("🚀 ISL Detection Engine Starting… (v4 — hardened frame pipeline)", flush=True)
467
- print(f" RUN_MODE = {RUN_MODE}", flush=True)
468
- print(f" CONF_THRESHOLD = {CONF_THRESHOLD}", flush=True)
469
- print(f" LETTER_HOLD = {LETTER_HOLD_SEC}s", flush=True)
470
- print(f" Thread = {threading.current_thread().name}", flush=True)
471
- print("=" * 70, flush=True)
472
-
473
- # ── Load model ────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  try:
475
  model = load_model(ISL_MODEL_PATH)
476
- print(f"✅ ISL model loaded: input={model.input_shape} output={model.output_shape}", flush=True)
 
 
477
  except Exception as e:
478
- print(f"❌ FATAL: Failed to load model: {e}", flush=True)
 
 
479
  return
480
 
481
- # Validate model expects 42 features (21 landmarks × 2)
482
- expected_features = model.input_shape[-1]
483
- if expected_features != 42:
484
- print(f"⚠️ Model expects {expected_features} features (expected 42). Results may be wrong.", flush=True)
485
-
486
  try:
487
- model.predict(np.zeros((1, expected_features), np.float32), verbose=0)
488
- print("✅ Model warmed up", flush=True)
489
  except Exception as e:
490
- print(f"⚠️ Warmup error: {e}", flush=True)
491
 
492
  alphabet = list(string.ascii_uppercase) + [str(i) for i in range(1, 10)]
493
- n_classes = model.output_shape[1]
494
- print(f" Alphabet: A-Z letters only (model outputs {n_classes} classes, digits ignored)", flush=True)
495
 
496
- # ── State ─────────────────────────────────────────────────
497
  current_word = []
498
  sentence = []
499
  no_hand_frames = 0
@@ -516,7 +523,6 @@ def core_processing_engine(shared_state, shm_name=None, frame_lock_val=None, fra
516
 
517
  frame_count = 0
518
  fps_frame_times = deque(maxlen=30)
519
- last_log_time = time.time()
520
 
521
  stats = {
522
  "letters_detected": 0,
@@ -526,40 +532,69 @@ def core_processing_engine(shared_state, shm_name=None, frame_lock_val=None, fra
526
  "session_start": time.time(),
527
  }
528
 
529
- # ── Camera (LOCAL only) ───────────────────────────────────
530
- cap = None
 
 
 
531
  if RUN_MODE == "LOCAL":
532
- print("🎥 Initializing local camera…", flush=True)
533
  if platform.system() == "Windows":
534
  cap = cv2.VideoCapture(CAM_INDEX, cv2.CAP_DSHOW)
535
  else:
536
- cap = cv2.VideoCapture(CAM_INDEX)
 
537
  if not cap.isOpened():
538
- print("❌ Camera not found", flush=True)
 
 
 
 
539
  return
 
 
 
 
 
540
  if platform.system() == "Windows":
541
- cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc('M','J','P','G'))
 
542
  cap.set(cv2.CAP_PROP_FRAME_WIDTH, CAMERA_RESOLUTION[0])
543
  cap.set(cv2.CAP_PROP_FRAME_HEIGHT, CAMERA_RESOLUTION[1])
544
  cap.set(cv2.CAP_PROP_FPS, TARGET_FPS)
 
 
 
545
  cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
 
 
 
 
546
  for _ in range(30):
547
  cap.read()
548
- ret, _ = cap.read()
549
- if not ret:
550
- print("❌ Camera flush failed", flush=True)
 
 
551
  cap.release()
 
 
552
  return
553
- print("✅ Local camera ready", flush=True)
 
 
 
 
554
  else:
555
- print("☁️ CLOUD mode — waiting for browser frames via socket", flush=True)
 
 
 
556
 
557
- # ── MediaPipe ─────────────────────────────────────────────
558
  mp_hands = mp.solutions.hands
559
  mp_face = mp.solutions.face_mesh
560
 
561
- print("⏳ Initializing MediaPipe…", flush=True)
562
-
563
  with mp_hands.Hands(
564
  static_image_mode=False,
565
  model_complexity=0,
@@ -575,82 +610,92 @@ def core_processing_engine(shared_state, shm_name=None, frame_lock_val=None, fra
575
  min_tracking_confidence=0.6,
576
  ) as face_mesh:
577
 
578
- print("✅ MediaPipe ready — entering main loop", flush=True)
579
 
580
  while True:
581
  t0 = time.time()
582
  now = t0
583
 
584
- # ── Commands ──────────────────────────────────────
585
  try:
586
- while not shared_state.command_queue.empty():
587
- cmd = shared_state.command_queue.get_nowait()
588
  action = cmd.get("action", "")
589
 
590
  if action == "reset":
591
- current_word = []; sentence = []
592
- no_hand_frames = 0
593
- candidate_letter = ""; candidate_since = 0.0
594
- last_accepted_letter = ""; last_accepted_time = 0.0
 
 
 
595
  letter_confidence_buffer.clear()
596
- current_detected_letter = ""; current_confidence = 0.0
597
- print("🔄 Reset", flush=True)
 
598
 
599
  elif action == "backspace":
600
- if current_word: current_word.pop()
601
- elif sentence: sentence.pop()
 
 
602
 
603
  elif action == "accept_suggestion":
604
  w = cmd.get("word", "")
605
- if w: current_word = list(w)
 
606
 
607
  elif action == "speak":
608
  txt = (cmd.get("text", "")
609
  or (" ".join(sentence) if sentence
610
  else "".join(current_word)))
611
- if txt: speak_text(txt)
 
612
 
613
  elif action == "stop":
614
- if cap: cap.release()
 
 
 
615
  return
616
  except Exception:
617
  pass
618
 
619
- # ── Capture frame ─────────────────────────────────
620
- raw = None
621
  if RUN_MODE == "LOCAL":
622
  ret, raw = cap.read()
623
  if not ret or raw is None:
 
624
  time.sleep(0.02)
625
  continue
626
  else:
627
- b64 = shared_state.take_frame()
628
- if b64 is None:
629
  time.sleep(0.03)
630
  continue
631
- raw = decode_b64_frame(b64)
632
- if raw is None:
633
- print("⚠️ Frame decode returned None — skipping", flush=True)
 
 
 
 
 
634
  continue
635
- shared_state._frame_count_proc += 1
636
-
637
- # ── Pre-process ───────────────────────────────────
638
- try:
639
- frame = cv2.resize(raw, (FRAME_W, FRAME_H))
640
- frame = cv2.flip(frame, 1)
641
- frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
642
- except Exception as e:
643
- print(f"⚠️ Frame pre-process error: {e}", flush=True)
644
- continue
645
 
 
 
 
646
  frame_rgb.flags.writeable = False
 
 
647
  hand_results = hands.process(frame_rgb)
648
  frame_rgb.flags.writeable = True
649
 
650
  detected_letter = ""
651
  hand_detected = False
652
 
653
- # ── Hand detection ────────────────────────────────
654
  if hand_results and hand_results.multi_hand_landmarks:
655
  hand_detected = True
656
  no_hand_frames = 0
@@ -661,67 +706,59 @@ def core_processing_engine(shared_state, shm_name=None, frame_lock_val=None, fra
661
  lm_list = calc_landmark_list(frame, hlm)
662
  pre_list = pre_process_landmark(lm_list)
663
 
664
- if len(pre_list) != expected_features:
665
- print(f"⚠️ Landmark length {len(pre_list)} ≠ model expects {expected_features}", flush=True)
666
- continue
667
-
668
- features = np.array(pre_list, dtype=np.float32).reshape(1, expected_features)
669
- pred = model.predict(features, verbose=0)
670
- prob = float(np.max(pred))
671
- idx = int(np.argmax(pred))
672
- idx = min(idx, len(alphabet) - 1)
673
- raw_letter = alphabet[idx]
674
-
675
- # Skip number predictions — only A-Z letters
676
- if raw_letter.isdigit():
677
- current_detected_letter = ""
678
- current_confidence = 0.0
679
- continue
680
-
681
- current_detected_letter = raw_letter
682
- current_confidence = prob
683
-
684
- # Log every 30 frames so you can see detections in HF Logs
685
- if frame_count % 30 == 0:
686
- print(f"🔍 DEBUG → letter={raw_letter} conf={prob:.4f} "
687
- f"frames_in={shared_state._frame_count_in} "
688
- f"frames_proc={shared_state._frame_count_proc}", flush=True)
689
-
690
- if prob >= CONF_THRESHOLD:
691
- if raw_letter == candidate_letter:
692
- letter_confidence_buffer.append(prob)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
693
  else:
694
- candidate_letter = raw_letter
695
- candidate_since = now
696
  letter_confidence_buffer.clear()
697
- letter_confidence_buffer.append(prob)
698
-
699
- avg_conf = float(np.mean(letter_confidence_buffer))
700
- hold_time = now - candidate_since
701
-
702
- if hold_time >= LETTER_HOLD_SEC and avg_conf >= CONF_THRESHOLD:
703
- same_letter_ok = (
704
- raw_letter != last_accepted_letter or
705
- (now - last_accepted_time) >= SAME_LETTER_COOLDOWN_SEC
706
- )
707
- if same_letter_ok:
708
- detected_letter = raw_letter
709
- last_accepted_letter = raw_letter
710
- last_accepted_time = now
711
- current_word.append(raw_letter)
712
- stats["letters_detected"] += 1
713
- print(f"✍️ LETTER ACCEPTED: {raw_letter} "
714
- f"(held {hold_time:.2f}s, conf {avg_conf:.3f})", flush=True)
715
- candidate_letter = ""
716
- candidate_since = 0.0
717
- letter_confidence_buffer.clear()
718
- else:
719
- candidate_letter = ""
720
- candidate_since = 0.0
721
- letter_confidence_buffer.clear()
722
 
723
  except Exception as e:
724
- print(f"⚠️ Prediction error: {e}", flush=True)
 
725
  else:
726
  no_hand_frames += 1
727
  letter_confidence_buffer.clear()
@@ -735,9 +772,9 @@ def core_processing_engine(shared_state, shm_name=None, frame_lock_val=None, fra
735
  sentence.append(word)
736
  word_frequency[word] += 1
737
  stats["words_formed"] += 1
738
- print(f"📝 Word committed: {word}", flush=True)
739
  if ENABLE_AUTO_TTS:
740
- speak_text(word)
741
  current_word = []
742
  last_accepted_letter = ""
743
  last_accepted_time = 0.0
@@ -745,7 +782,7 @@ def core_processing_engine(shared_state, shm_name=None, frame_lock_val=None, fra
745
 
746
  frame = draw_letter_overlay(frame, current_detected_letter, current_confidence)
747
 
748
- # ── Emotion (every N frames) ──────────────────────
749
  emotion_frame_counter += 1
750
  if emotion_frame_counter % EMOTION_PROCESS_INTERVAL == 0:
751
  ns = {e: (1.0 if e == "neutral" else 0.0) for e in EMOTIONS}
@@ -758,10 +795,8 @@ def core_processing_engine(shared_state, shm_name=None, frame_lock_val=None, fra
758
  ns = detect_emotion_from_landmarks(
759
  calc_landmark_list(frame, mr.multi_face_landmarks[0]),
760
  frame.shape)
761
- if emotion_frame_counter % 50 == 0:
762
- print(f"😊 Emotion scores: { {k: f'{v:.3f}' for k,v in ns.items()} }", flush=True)
763
- except Exception as e:
764
- print(f"⚠️ Emotion frame error: {e}", flush=True)
765
 
766
  emotion_history.append(ns)
767
  avg_e = defaultdict(float)
@@ -777,7 +812,11 @@ def core_processing_engine(shared_state, shm_name=None, frame_lock_val=None, fra
777
 
778
  if current_emotion != dominant:
779
  stats["emotion_changes"] += 1
780
- print(f"😊 Emotion changed: {current_emotion} → {dominant}", flush=True)
 
 
 
 
781
 
782
  current_emotion = dominant
783
  emotion_scores = dict(avg_e)
@@ -787,7 +826,7 @@ def core_processing_engine(shared_state, shm_name=None, frame_lock_val=None, fra
787
  "scores": dict(avg_e),
788
  })
789
 
790
- # ── FPS ───────────────────────────────────────────
791
  frame_count += 1
792
  fps_frame_times.append(time.time())
793
  if len(fps_frame_times) > 1:
@@ -795,27 +834,26 @@ def core_processing_engine(shared_state, shm_name=None, frame_lock_val=None, fra
795
  if td > 0:
796
  shared_state.processing_fps.value = (len(fps_frame_times) - 1) / td
797
 
798
- # ── Periodic heartbeat log ────────────────────────
799
- now_t = time.time()
800
- if now_t - last_log_time >= 10.0:
801
- last_log_time = now_t
802
- print(f"💓 Heartbeat | frame#{frame_count} | "
803
- f"fps={shared_state.processing_fps.value:.1f} | "
804
- f"in={shared_state._frame_count_in} | "
805
- f"proc={shared_state._frame_count_proc} | "
806
- f"emotion={current_emotion} | "
807
- f"word={''.join(current_word) or '(none)'}", flush=True)
808
-
809
- # ── Build state packet (base64 frame for UI) ──────
810
- try:
811
  _, buffer_img = cv2.imencode(
812
- ".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 60])
813
  frame_base64 = base64.b64encode(buffer_img.tobytes()).decode("utf-8")
814
- except Exception:
815
- frame_base64 = ""
816
 
 
817
  state_packet = {
818
- "frame": frame_base64,
819
  "current_word": "".join(current_word),
820
  "sentence": " ".join(sentence),
821
  "suggestions": get_word_suggestions(
@@ -837,29 +875,54 @@ def core_processing_engine(shared_state, shm_name=None, frame_lock_val=None, fra
837
  }
838
 
839
  try:
840
- shared_state.ui_queue.put_nowait(state_packet)
841
- except queue_module.Full:
842
  try:
843
- shared_state.ui_queue.get_nowait()
844
- shared_state.ui_queue.put_nowait(state_packet)
845
  except Exception:
846
  pass
847
 
848
  if frame_count == 1:
849
- print("📤 First state packet sent to UI queue", flush=True)
 
 
850
 
 
851
  wait = (1.0 / TARGET_FPS) - (time.time() - t0)
852
  if wait > 0:
853
  time.sleep(wait)
854
 
855
  if cap:
856
  cap.release()
857
- print("✅ Detection engine stopped", flush=True)
 
 
 
858
 
859
 
860
  # ─────────────────────────────────────────────────────────────
861
  # STANDALONE ENTRY POINT
862
  # ─────────────────────────────────────────────────────────────
863
  if __name__ == "__main__":
864
- ss = SharedState()
865
- core_processing_engine(ss)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # isl_detection.py — CLOUD PRODUCTION VERSION
2
  #
3
  # ════════════════════════════════════════════════════════════════════════════
4
+ # CLOUD FIXES APPLIED (3 changes only — everything else identical):
5
  #
6
+ # FIX 1 — pygame TTS: init only in LOCAL mode (crashes on headless server)
7
+ # FIX 2 — cv2.CAP_DSHOW: Windows-only flag removed for Linux cloud server
8
+ # FIX 3 — SharedMemory cleanup on startup (prevents crash on restart)
 
 
 
 
 
9
  #
10
+ # LOCAL CAMERA FIXES (built-in laptop webcam static/noise):
11
+ # FIX 4 — MJPG codec + 30-frame flush to clear DirectShow buffer on Windows
12
+ # FIX 5 — CAP_PROP_BUFFERSIZE set before flush (not after)
13
+ #
14
+ # ALL original features preserved:
15
  # ✅ Emotion detection v3 (happy/sad/angry/surprise/neutral)
16
  # ✅ Letter hold timer, same-letter cooldown, confidence buffer
17
+ # ✅ SharedMemory zero-copy frame transfer
18
+ # ✅ CLOUD mode browser frame ingestion (_cloud_frame)
19
+ # ✅ gTTS speak button (disabled silently in cloud — browser TTS handles it)
20
  # ✅ Word suggestions, auto-TTS, reset, backspace, accept_suggestion
21
+ # ✅ Oracle Free Tier safe: TARGET_FPS=15, EMOTION_PROCESS_INTERVAL=5
22
  # ════════════════════════════════════════════════════════════════════════════
23
 
24
  import cv2
 
33
  import platform
34
  import threading
35
  from collections import deque, defaultdict, Counter
36
+ from multiprocessing import Manager, Value, shared_memory
37
  import ctypes
 
38
 
39
+ os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
40
+ os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
 
41
 
42
  from tensorflow.keras.models import load_model
43
+ from gtts import gTTS
44
+ import pygame
 
 
 
 
 
 
 
 
 
 
45
 
46
  try:
47
  import translation
 
52
  # ─────────────────────────────────────────────────────────────
53
  # CONFIG
54
  # ─────────────────────────────────────────────────────────────
55
+ RUN_MODE = os.getenv("RUN_MODE", "LOCAL")
56
  ISL_MODEL_PATH = "model.h5"
57
  CAM_INDEX = 0
58
 
59
+ # ── Letter timing ──────────────────────────────────────────────────────────
60
  LETTER_HOLD_SEC = 0.9
61
  SAME_LETTER_COOLDOWN_SEC = 1.2
62
 
63
+ # ── Emotion thresholds ────────────────────────────────────────────────────
64
  SMOOTHING_FRAMES = 2
65
  MIN_CONF_TO_SHOW = 0.06
66
  EMOTIONS = ["happy", "sad", "angry", "surprise", "neutral"]
67
 
68
  CONF_THRESHOLD = 0.32
69
  SPACE_THRESHOLD = 18
70
+ ENABLE_AUTO_TTS = True
71
  TTS_LANGUAGE = "en"
72
 
73
  SHOW_LETTER_OVERLAY = True
 
88
  EMOTION_PROCESS_INTERVAL = 5
89
  FRAME_W, FRAME_H = 640, 480
90
 
91
+
92
  # ─────────────────────────────────────────────────────────────
93
  # SHARED STATE
 
 
 
94
  # ─────────────────────────────────────────────────────────────
95
  class SharedState:
96
  def __init__(self):
97
+ manager = Manager()
98
+ self.ui_queue = manager.Queue(200)
99
+ self.command_queue = manager.Queue(20)
100
+ self.processing_fps = Value(ctypes.c_double, 0.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
 
103
  # ─────────────────────────────────────────────────────────────
104
+ # TTS ENGINE
105
+ # FIX 1: pygame.mixer.init() only runs in LOCAL mode.
106
+ # On cloud (headless Linux) there is no audio device —
107
+ # init() would crash the entire process at import time.
108
+ # In CLOUD mode TTS is silently disabled here; the browser
109
+ # handles speech via the Web Speech API on the client side.
110
  # ─────────────────────────────────────────────────────────────
111
  TTS_ENABLED = False
112
+ if RUN_MODE == "LOCAL":
113
  try:
114
  pygame.mixer.init()
115
  TTS_ENABLED = True
 
117
  except Exception as e:
118
  print(f"⚠️ TTS init warning: {e}")
119
  else:
120
+ print("ℹ️ TTS disabled (CLOUD/headless mode) — browser handles speech")
121
 
122
  speaking_word = ""
123
  pause_audio = False
 
125
 
126
  def speak_text(text, lang=TTS_LANGUAGE):
127
  global speaking_word
128
+
129
+ # FIX 1 continued: guard every call — silently skip on cloud
130
  if not TTS_ENABLED or not text or not text.strip():
131
  return
132
 
 
163
  threading.Thread(target=_speak, daemon=True).start()
164
 
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  # ─────────────────────────────────────────────────────────────
167
  # LANDMARK UTILITIES
168
  # ─────────────────────────────────────────────────────────────
 
186
 
187
 
188
  def apply_clahe(frame):
189
+ """Enhance low-light / low-contrast frames for mobile cameras."""
190
  try:
191
  lab = cv2.cvtColor(frame, cv2.COLOR_BGR2LAB)
192
  l, a, b = cv2.split(lab)
 
197
 
198
 
199
  # ───────────────────────────────────────────────────��─────────
200
+ # EMOTION DETECTION — v3
201
+ # Redesigned SAD / ANGRY / SURPRISE with external facial cues.
202
+ # HAPPY unchanged.
203
  # ─────────────────────────────────────────────────────────────
204
+
205
  def _dist(a, b):
206
+ """Euclidean distance between two 2-D landmark points."""
207
  return float(np.linalg.norm(
208
  np.array(a, dtype=np.float32) - np.array(b, dtype=np.float32)
209
  ))
210
 
211
 
212
  def detect_emotion_from_landmarks(face_landmarks, img_shape):
213
+ """
214
+ Derive emotion scores from 468 MediaPipe Face Mesh landmarks.
215
+ Returns {emotion: float} summing to 1.0.
216
+
217
+ Key landmark reference:
218
+ Mouth corners : 61 (L), 291 (R)
219
+ Lip inner : 13 (top), 14 (bottom)
220
+ Lip outer : 0 (top), 17 (bottom)
221
+ L eye lids : 159 (upper), 145 (lower)
222
+ R eye lids : 386 (upper), 374 (lower)
223
+ L eye corners : 33 (outer), 133 (inner)
224
+ R eye corners : 362 (outer), 263 (inner)
225
+ L brow : 70 (inner), 105 (outer), 52 (arch)
226
+ R brow : 300 (inner), 334 (outer), 282 (arch)
227
+ Nose tip : 4
228
+ Nostrils : 129 (L ala), 358 (R ala)
229
+ Cheeks : 50 (L), 280 (R)
230
+ """
231
  try:
232
  pts = np.array(face_landmarks, dtype=np.float32)
233
  face_w = max(float(np.max(pts[:, 0]) - np.min(pts[:, 0])), 1.0)
234
  face_h = max(float(np.max(pts[:, 1]) - np.min(pts[:, 1])), 1.0)
235
 
236
+ def p(i):
237
+ return pts[i]
238
+
239
+ def nw(a, b):
240
+ return _dist(p(a), p(b)) / face_w
241
+
242
+ def nh(a, b):
243
+ return _dist(p(a), p(b)) / face_h
244
+
245
+ def clamp01(v):
246
+ return max(0.0, min(1.0, float(v)))
247
 
248
+ # ── Mouth ────────────────────────────────────────
249
  lc, rc = p(61), p(291)
250
  tl, bl = p(13), p(14)
251
  ut, lb = p(0), p(17)
 
257
  mouth_w = _dist(lc, rc) / face_w
258
  o_ratio = (mouth_open / (mouth_w + 1e-6)) if mouth_w > 0.05 else 0.0
259
 
260
+ # ── Eyes ─────────────────────────────────────────
261
  leu, led = p(159), p(145)
262
  reu, red = p(386), p(374)
263
  lel, ler = p(33), p(133)
 
273
  avg_ear = (l_ear + r_ear) / 2.0
274
  eye_open = ((l_eye_h + r_eye_h) / 2.0) / face_h
275
 
276
+ # ── Eyebrows ──────────────────────────────────────
277
  lib, rib = p(70), p(300)
278
  lob, rob = p(105), p(334)
279
  lab, rab = p(52), p(282)
 
293
  r_arch_h = (reu[1] - rab[1]) / face_h
294
  avg_arch_h = (l_arch_h + r_arch_h) / 2.0
295
 
296
+ # ── Nose ─────────────────────────────────────────
297
  nostril_w = nw(129, 358)
298
  nose_flare = max(0.0, nostril_w - 0.26)
299
 
300
+ # ── Cheeks ───────────────────────────────────────
301
  lch, rch, nb = p(50), p(280), p(4)
302
  cheek_raise = ((nb[1] - lch[1]) + (nb[1] - rch[1])) / (2.0 * face_h)
303
 
304
+ # ── HAPPY ────────────────────────────────────────
305
  happy = 0.0
306
  if mouth_curve < -0.003:
307
  happy += clamp01(abs(mouth_curve) / 0.018) * 0.55
 
311
  happy += clamp01(cheek_raise / 0.018) * 0.26
312
  if avg_ear < 0.27 and eye_open > 0.04:
313
  happy += 0.12
314
+ if mouth_curve > 0.004: happy *= 0.15
315
+ if avg_brow_in_h < -0.05: happy *= 0.40
316
+ if mouth_open > 0.08: happy *= 0.55
317
  happy = clamp01(happy * 1.30)
318
 
319
+ # ── SAD — v3 ──────────────────────────────────────
320
  sad = 0.0
321
  if brow_oblique > 0.02:
322
  sad += clamp01((brow_oblique - 0.02) / 0.055) * 0.45
 
331
  sad += clamp01((0.36 - mouth_w) / 0.055) * 0.18
332
  if avg_brow_in_h > 0.04:
333
  sad += clamp01((avg_brow_in_h - 0.04) / 0.040) * 0.20
334
+ if mouth_curve < -0.004: sad *= 0.08
335
+ if cheek_raise > 0.014: sad *= 0.20
336
+ if avg_ear > 0.30: sad *= 0.25
337
  if avg_brow_in_h < -0.06 and brow_inner_gap < 0.14: sad *= 0.15
338
  sad = clamp01(sad * 1.50)
339
 
340
+ # ── ANGRY — v3 ────────────────────────────────────
341
  angry = 0.0
342
  if nose_flare > 0.005:
343
  angry += clamp01(nose_flare / 0.055) * 0.35
 
351
  angry += clamp01((0.055 - eye_open) / 0.025) * 0.25
352
  if inner_gap < 0.030:
353
  angry += clamp01((0.030 - inner_gap) / 0.025) * 0.22
354
+ if brow_oblique < 0.015:
355
+ angry += 0.08
356
+ if mouth_curve < -0.005: angry *= 0.15
357
+ if cheek_raise > 0.014: angry *= 0.20
358
+ if avg_brow_in_h > -0.01: angry *= 0.20
359
+ if mouth_open > 0.07: angry *= 0.40
360
+ if brow_oblique > 0.04: angry *= 0.35
361
  angry = clamp01(angry * 1.45)
362
 
363
+ # ── SURPRISE — v3 ─────────────────────────────────
364
  surprise = 0.0
365
  if 0.40 < o_ratio < 1.60:
366
  peak_dist = abs(o_ratio - 0.90)
 
377
  surprise += clamp01((avg_arch_h - 0.05) / 0.055) * 0.35
378
  if avg_brow_in_h > 0.04 and avg_brow_out_h > 0.02:
379
  surprise += 0.15
380
+ if abs(brow_oblique) < 0.025:
381
+ surprise += 0.08
382
+ if mouth_curve < -0.005: surprise *= 0.30
383
+ if avg_brow_in_h < -0.03: surprise *= 0.20
384
+ if avg_ear < 0.18: surprise *= 0.25
385
+ if brow_inner_gap < 0.13: surprise *= 0.25
386
+ if mouth_w > 0.44: surprise *= 0.25
387
  if mouth_open > 0.12 and o_ratio < 0.40:
388
  surprise *= 0.30
389
  surprise = clamp01(surprise * 1.35)
390
 
391
+ # ── NEUTRAL ───────────────────────────────────────
392
  neutral = max(0.0, 1.0 - (happy + sad + angry + surprise) * 1.05)
393
 
394
  total = happy + sad + angry + surprise + neutral
 
403
  for k, v in zip(EMOTIONS, [happy, sad, angry, surprise, neutral])}
404
 
405
  except Exception as ex:
406
+ print(f"⚠️ Emotion detection error: {ex}")
407
  return {e: (1.0 if e == "neutral" else 0.0) for e in EMOTIONS}
408
 
409
 
410
  # ─────────────────────────────────────────────────────────────
411
+ # DISPLAY HELPERS
412
  # ─────────────────────────────────────────────────────────────
413
  def get_word_suggestions(partial, word_freq):
414
  if not partial:
 
458
  # CORE PROCESSING ENGINE
459
  # ─────────────────────────────────────────────────────────────
460
  def core_processing_engine(shared_state, shm_name=None, frame_lock_val=None, frame_seq=None):
461
+ print("=" * 70)
462
+ print("🚀 ISL Detection System Starting… (emotion engine v3)")
463
+ print(f" RUN_MODE = {RUN_MODE}")
464
+ print(f" CONF_THRESHOLD = {CONF_THRESHOLD}")
465
+ print(f" LETTER_HOLD_SEC = {LETTER_HOLD_SEC}s")
466
+ print(f" SharedMemory = {'enabled' if shm_name else 'disabled (base64 fallback)'}")
467
+ print("=" * 70)
468
+
469
+ # ── SharedMemory init ──────────────────────────────────────────────────
470
+ shm = None
471
+ shm_array = None
472
+
473
+ if shm_name is not None:
474
+ try:
475
+ shm = shared_memory.SharedMemory(name=shm_name)
476
+ shm_array = np.ndarray((480, 640, 3), dtype=np.uint8, buffer=shm.buf)
477
+ print("✅ SharedMemory connected (Zero-copy mode)")
478
+ except Exception as e:
479
+ print(f"❌ SharedMemory init failed: {e}")
480
+ return
481
+
482
+ # ── Load ISL model ─────────────────────────────────────────────────────
483
  try:
484
  model = load_model(ISL_MODEL_PATH)
485
+ print("✅ ISL model loaded")
486
+ print(f" Input shape : {model.input_shape}")
487
+ print(f" Output shape : {model.output_shape}")
488
  except Exception as e:
489
+ print(f"❌ Failed to load model: {e}")
490
+ if shm:
491
+ shm.close()
492
  return
493
 
 
 
 
 
 
494
  try:
495
+ model.predict(np.zeros((1, 42), np.float32), verbose=0)
496
+ print("✅ Model warmed up")
497
  except Exception as e:
498
+ print(f"⚠️ Warmup: {e}")
499
 
500
  alphabet = list(string.ascii_uppercase) + [str(i) for i in range(1, 10)]
501
+ print(f" Alphabet : A-Z letters only (model outputs {model.output_shape[1]} classes, digits ignored)")
 
502
 
503
+ # ── Detection state ────────────────────────────────────────────────────
504
  current_word = []
505
  sentence = []
506
  no_hand_frames = 0
 
523
 
524
  frame_count = 0
525
  fps_frame_times = deque(maxlen=30)
 
526
 
527
  stats = {
528
  "letters_detected": 0,
 
532
  "session_start": time.time(),
533
  }
534
 
535
+ cmd_queue = shared_state.command_queue
536
+ ui_queue = shared_state.ui_queue
537
+
538
+ # ── Camera init ────────────────────────────────────────────────────────
539
+ print("🎥 Initializing camera…")
540
  if RUN_MODE == "LOCAL":
541
+ # FIX 2: cv2.CAP_DSHOW is Windows-only — use platform check
542
  if platform.system() == "Windows":
543
  cap = cv2.VideoCapture(CAM_INDEX, cv2.CAP_DSHOW)
544
  else:
545
+ cap = cv2.VideoCapture(CAM_INDEX) # Linux/Mac — no CAP_DSHOW
546
+
547
  if not cap.isOpened():
548
+ cap = cv2.VideoCapture(CAM_INDEX) # fallback without backend flag
549
+ if not cap.isOpened():
550
+ print("❌ Camera not found — is another app using it?")
551
+ if shm:
552
+ shm.close()
553
  return
554
+
555
+ # FIX 4: Set MJPG codec BEFORE resolution — forces hardware MJPEG path
556
+ # on built-in laptop webcams, avoiding the raw uncompressed buffer that
557
+ # causes the horizontal static/noise stripes seen in the MJPEG stream.
558
+ # Must be set before width/height or it has no effect on some drivers.
559
  if platform.system() == "Windows":
560
+ cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc('M', 'J', 'P', 'G'))
561
+
562
  cap.set(cv2.CAP_PROP_FRAME_WIDTH, CAMERA_RESOLUTION[0])
563
  cap.set(cv2.CAP_PROP_FRAME_HEIGHT, CAMERA_RESOLUTION[1])
564
  cap.set(cv2.CAP_PROP_FPS, TARGET_FPS)
565
+
566
+ # FIX 5: Buffer size must be 1 to prevent stale frame accumulation.
567
+ # Set AFTER resolution so the driver doesn't reset it.
568
  cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
569
+
570
+ # FIX 4 continued: Flush 30 frames (was 10) to drain the DirectShow
571
+ # ring buffer. Built-in webcams pre-fill ~20 frames before stabilizing.
572
+ print("🎥 Flushing camera buffer (30 frames)…")
573
  for _ in range(30):
574
  cap.read()
575
+
576
+ # Verify the camera is actually producing valid frames
577
+ ret, test_frame = cap.read()
578
+ if not ret or test_frame is None:
579
+ print("❌ Camera flush failed — no valid frame received")
580
  cap.release()
581
+ if shm:
582
+ shm.close()
583
  return
584
+
585
+ actual_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
586
+ actual_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
587
+ print(f"✅ Camera: {actual_w}x{actual_h}")
588
+ print("✅ Camera ready!")
589
  else:
590
+ cap = None
591
+ print("☁️ CLOUD mode — waiting for browser frames via socket")
592
+
593
+ print("=" * 70)
594
 
 
595
  mp_hands = mp.solutions.hands
596
  mp_face = mp.solutions.face_mesh
597
 
 
 
598
  with mp_hands.Hands(
599
  static_image_mode=False,
600
  model_complexity=0,
 
610
  min_tracking_confidence=0.6,
611
  ) as face_mesh:
612
 
613
+ print("✅ MediaPipe initialized — entering main loop")
614
 
615
  while True:
616
  t0 = time.time()
617
  now = t0
618
 
619
+ # ── Commands ───────────────────────────────────────────────────
620
  try:
621
+ while not cmd_queue.empty():
622
+ cmd = cmd_queue.get_nowait()
623
  action = cmd.get("action", "")
624
 
625
  if action == "reset":
626
+ current_word = []
627
+ sentence = []
628
+ no_hand_frames = 0
629
+ candidate_letter = ""
630
+ candidate_since = 0.0
631
+ last_accepted_letter = ""
632
+ last_accepted_time = 0.0
633
  letter_confidence_buffer.clear()
634
+ current_detected_letter = ""
635
+ current_confidence = 0.0
636
+ print("🔄 Reset")
637
 
638
  elif action == "backspace":
639
+ if current_word:
640
+ current_word.pop()
641
+ elif sentence:
642
+ sentence.pop()
643
 
644
  elif action == "accept_suggestion":
645
  w = cmd.get("word", "")
646
+ if w:
647
+ current_word = list(w)
648
 
649
  elif action == "speak":
650
  txt = (cmd.get("text", "")
651
  or (" ".join(sentence) if sentence
652
  else "".join(current_word)))
653
+ if txt:
654
+ speak_text(txt) # silently skipped in CLOUD mode
655
 
656
  elif action == "stop":
657
+ if cap:
658
+ cap.release()
659
+ if shm:
660
+ shm.close()
661
  return
662
  except Exception:
663
  pass
664
 
665
+ # ── Capture ────────────────────────────────────────────────────
 
666
  if RUN_MODE == "LOCAL":
667
  ret, raw = cap.read()
668
  if not ret or raw is None:
669
+ print("⚠️ Camera read failed — retrying")
670
  time.sleep(0.02)
671
  continue
672
  else:
673
+ fd = getattr(shared_state, "_cloud_frame", None)
674
+ if fd is None:
675
  time.sleep(0.03)
676
  continue
677
+ shared_state._cloud_frame = None
678
+ try:
679
+ raw = cv2.imdecode(
680
+ np.frombuffer(base64.b64decode(fd), np.uint8),
681
+ cv2.IMREAD_COLOR)
682
+ if raw is None:
683
+ continue
684
+ except Exception:
685
  continue
 
 
 
 
 
 
 
 
 
 
686
 
687
+ frame = cv2.resize(raw, (FRAME_W, FRAME_H))
688
+ frame = cv2.flip(frame, 1)
689
+ frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
690
  frame_rgb.flags.writeable = False
691
+
692
+ # ── Hand detection ─────────────────────────────────────────────
693
  hand_results = hands.process(frame_rgb)
694
  frame_rgb.flags.writeable = True
695
 
696
  detected_letter = ""
697
  hand_detected = False
698
 
 
699
  if hand_results and hand_results.multi_hand_landmarks:
700
  hand_detected = True
701
  no_hand_frames = 0
 
706
  lm_list = calc_landmark_list(frame, hlm)
707
  pre_list = pre_process_landmark(lm_list)
708
 
709
+ if model is not None and len(pre_list) == 42:
710
+ features = np.array(pre_list, dtype=np.float32).reshape(1, 42)
711
+ pred = model.predict(features, verbose=0)
712
+ prob = float(np.max(pred))
713
+ raw_letter = alphabet[int(np.argmax(pred))]
714
+
715
+ # Skip number predictions — only A-Z letters
716
+ if raw_letter.isdigit():
717
+ current_detected_letter = ""
718
+ current_confidence = 0.0
719
+ continue
720
+
721
+ current_detected_letter = raw_letter
722
+ current_confidence = prob
723
+
724
+ print(f"DEBUG → {raw_letter} {prob:.4f}")
725
+
726
+ if prob >= CONF_THRESHOLD:
727
+ if raw_letter == candidate_letter:
728
+ letter_confidence_buffer.append(prob)
729
+ else:
730
+ candidate_letter = raw_letter
731
+ candidate_since = now
732
+ letter_confidence_buffer.clear()
733
+ letter_confidence_buffer.append(prob)
734
+
735
+ avg_conf = float(np.mean(letter_confidence_buffer))
736
+ hold_time = now - candidate_since
737
+
738
+ if hold_time >= LETTER_HOLD_SEC and avg_conf >= CONF_THRESHOLD:
739
+ same_letter_ok = (
740
+ raw_letter != last_accepted_letter or
741
+ (now - last_accepted_time) >= SAME_LETTER_COOLDOWN_SEC
742
+ )
743
+ if same_letter_ok:
744
+ detected_letter = raw_letter
745
+ last_accepted_letter = raw_letter
746
+ last_accepted_time = now
747
+ current_word.append(raw_letter)
748
+ stats["letters_detected"] += 1
749
+ print(f"✍️ Letter: {raw_letter} "
750
+ f"(held {hold_time:.2f}s, conf {avg_conf:.3f})")
751
+ candidate_letter = ""
752
+ candidate_since = 0.0
753
+ letter_confidence_buffer.clear()
754
  else:
755
+ candidate_letter = ""
756
+ candidate_since = 0.0
757
  letter_confidence_buffer.clear()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
758
 
759
  except Exception as e:
760
+ print(f"⚠️ Prediction error: {e}")
761
+
762
  else:
763
  no_hand_frames += 1
764
  letter_confidence_buffer.clear()
 
772
  sentence.append(word)
773
  word_frequency[word] += 1
774
  stats["words_formed"] += 1
775
+ print(f"📝 Word committed: {word}")
776
  if ENABLE_AUTO_TTS:
777
+ speak_text(word) # silently skipped in CLOUD mode
778
  current_word = []
779
  last_accepted_letter = ""
780
  last_accepted_time = 0.0
 
782
 
783
  frame = draw_letter_overlay(frame, current_detected_letter, current_confidence)
784
 
785
+ # ── Emotion (every N frames) ───────────────────────────────────
786
  emotion_frame_counter += 1
787
  if emotion_frame_counter % EMOTION_PROCESS_INTERVAL == 0:
788
  ns = {e: (1.0 if e == "neutral" else 0.0) for e in EMOTIONS}
 
795
  ns = detect_emotion_from_landmarks(
796
  calc_landmark_list(frame, mr.multi_face_landmarks[0]),
797
  frame.shape)
798
+ except Exception:
799
+ pass
 
 
800
 
801
  emotion_history.append(ns)
802
  avg_e = defaultdict(float)
 
812
 
813
  if current_emotion != dominant:
814
  stats["emotion_changes"] += 1
815
+ scores_str = " | ".join(
816
+ [f"{k.upper()}:{avg_e[k]:.3f}" for k in EMOTIONS]
817
+ )
818
+ print(f"😊 Emotion: {current_emotion.upper()} → {dominant.upper()}")
819
+ print(f" Scores : {scores_str}")
820
 
821
  current_emotion = dominant
822
  emotion_scores = dict(avg_e)
 
826
  "scores": dict(avg_e),
827
  })
828
 
829
+ # ── FPS ────────────────────────────────────────────────────────
830
  frame_count += 1
831
  fps_frame_times.append(time.time())
832
  if len(fps_frame_times) > 1:
 
834
  if td > 0:
835
  shared_state.processing_fps.value = (len(fps_frame_times) - 1) / td
836
 
837
+ # ── Frame output ───────────────────────────────────────────────
838
+ if shm_array is not None:
839
+ shm_array[:] = frame
840
+ if frame_lock_val is not None:
841
+ frame_lock_val.value = 1
842
+ if frame_seq is not None:
843
+ frame_seq.value += 1
844
+ frame_base64 = None
845
+ else:
846
+ # PERF: Resize to 320×240 before encoding to halve payload size.
847
+ # 640×480 @ q65 ≈ 40KB; 320×240 @ q70 ≈ 12KB — 3× smaller.
848
+ # Browser renders it full-width so it still looks fine.
849
+ display = cv2.resize(frame, (320, 240))
850
  _, buffer_img = cv2.imencode(
851
+ ".jpg", display, [cv2.IMWRITE_JPEG_QUALITY, 70])
852
  frame_base64 = base64.b64encode(buffer_img.tobytes()).decode("utf-8")
 
 
853
 
854
+ # ── State packet ───────────────────────────────────────────────
855
  state_packet = {
856
+ "frame": frame_base64 if frame_base64 else "",
857
  "current_word": "".join(current_word),
858
  "sentence": " ".join(sentence),
859
  "suggestions": get_word_suggestions(
 
875
  }
876
 
877
  try:
878
+ ui_queue.put_nowait(state_packet)
879
+ except Exception:
880
  try:
881
+ ui_queue.get_nowait()
882
+ ui_queue.put_nowait(state_packet)
883
  except Exception:
884
  pass
885
 
886
  if frame_count == 1:
887
+ print("📤 First frame sent — dashboard can read now")
888
+ if shm_array is not None:
889
+ print("📤 SharedMemory active — zero-copy mode")
890
 
891
+ # ── Frame-rate limiter ─────────────────────────────────────────
892
  wait = (1.0 / TARGET_FPS) - (time.time() - t0)
893
  if wait > 0:
894
  time.sleep(wait)
895
 
896
  if cap:
897
  cap.release()
898
+ if shm is not None:
899
+ shm.close()
900
+
901
+ print("✅ System stopped")
902
 
903
 
904
  # ─────────────────────────────────────────────────────────────
905
  # STANDALONE ENTRY POINT
906
  # ─────────────────────────────────────────────────────────────
907
  if __name__ == "__main__":
908
+ FRAME_BYTES = FRAME_W * FRAME_H * 3
909
+
910
+ # FIX 3: Clean up leftover shared memory from a previous crash
911
+ # Without this, restarting after a crash raises FileExistsError
912
+ try:
913
+ _old_shm = shared_memory.SharedMemory(name="isl_frame_shm")
914
+ _old_shm.close()
915
+ _old_shm.unlink()
916
+ print("🧹 Cleaned up leftover shared memory from previous run")
917
+ except FileNotFoundError:
918
+ pass # normal — no leftover
919
+
920
+ shm = shared_memory.SharedMemory(create=True, size=FRAME_BYTES, name="isl_frame_shm")
921
+ seq = Value(ctypes.c_uint64, 0)
922
+ lock = Value(ctypes.c_bool, False)
923
+ ss = SharedState()
924
+ try:
925
+ core_processing_engine(ss, shm_name=shm.name, frame_lock_val=lock, frame_seq=seq)
926
+ finally:
927
+ shm.close()
928
+ shm.unlink()
nginx.conf CHANGED
@@ -23,6 +23,14 @@ http {
23
  uwsgi_temp_path /tmp/nginx_uwsgi;
24
  scgi_temp_path /tmp/nginx_scgi;
25
 
 
 
 
 
 
 
 
 
26
  sendfile on;
27
  keepalive_timeout 65;
28
 
 
23
  uwsgi_temp_path /tmp/nginx_uwsgi;
24
  scgi_temp_path /tmp/nginx_scgi;
25
 
26
+ # ── KEY PERFORMANCE FIX ──────────────────────────────────────
27
+ # Keep camera frame POST bodies in RAM (not disk).
28
+ # Default 8KB caused every ~10KB base64 frame to be written to
29
+ # /tmp/nginx_client_body — adding huge disk I/O latency per frame.
30
+ client_body_buffer_size 512k;
31
+ client_max_body_size 5M;
32
+ # ────────────────────────────────────────────────────────────
33
+
34
  sendfile on;
35
  keepalive_timeout 65;
36
 
templates/dashboard.html CHANGED
@@ -942,8 +942,12 @@
942
  });
943
 
944
  /* ── Browser Camera Streaming ──────────────────────────── */
 
 
 
945
  let cameraStream = null;
946
  let videoElement = null;
 
947
  const captureCanvas = document.createElement('canvas');
948
  const captureCtx = captureCanvas.getContext('2d');
949
 
@@ -955,7 +959,7 @@
955
  }
956
  try {
957
  cameraStream = await navigator.mediaDevices.getUserMedia({
958
- video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' },
959
  audio: false
960
  });
961
  videoElement = document.createElement('video');
@@ -966,22 +970,27 @@
966
  videoElement.style.position = 'absolute';
967
  videoElement.style.top = '-10000px';
968
  videoElement.style.left = '-10000px';
969
- videoElement.style.width = '320px';
970
- videoElement.style.height = '240px';
971
  videoElement.style.pointerEvents = 'none';
972
  document.body.appendChild(videoElement);
973
 
974
  videoElement.addEventListener('loadeddata', () => {
975
- captureCanvas.width = 320;
976
- captureCanvas.height = 240;
977
  if (!window.cameraInterval) {
978
  window.cameraInterval = setInterval(() => {
 
 
979
  if (document.visibilityState === 'visible' && socket && socket.connected) {
980
- captureCtx.drawImage(videoElement, 0, 0, 320, 240);
981
- const frame = captureCanvas.toDataURL('image/jpeg', 0.6);
 
982
  socket.emit('browser_frame', { frame: frame.split(',')[1] });
 
 
983
  }
984
- }, 65); // ~15 FPS
985
  }
986
  });
987
  } catch (err) {
@@ -1033,6 +1042,8 @@
1033
  });
1034
 
1035
  socket.on('state_update', (data) => {
 
 
1036
  try {
1037
  if (data.frame) {
1038
  document.getElementById('camera-feed').src = 'data:image/jpeg;base64,' + data.frame;
 
942
  });
943
 
944
  /* ── Browser Camera Streaming ──────────────────────────── */
945
+ // PERF: 160×120 @ quality 0.35 ≈ 6–8 KB per frame (was 320×240 @ 0.6 ≈ 50 KB).
946
+ // Sending at 4 fps (250 ms) avoids nginx disk-buffering warnings and the
947
+ // engineio "Too many packets in payload" error that was causing hard lag.
948
  let cameraStream = null;
949
  let videoElement = null;
950
+ let _frameInFlight = false; // flow control — one frame at a time
951
  const captureCanvas = document.createElement('canvas');
952
  const captureCtx = captureCanvas.getContext('2d');
953
 
 
959
  }
960
  try {
961
  cameraStream = await navigator.mediaDevices.getUserMedia({
962
+ video: { width: { ideal: 320 }, height: { ideal: 240 }, facingMode: 'user' },
963
  audio: false
964
  });
965
  videoElement = document.createElement('video');
 
970
  videoElement.style.position = 'absolute';
971
  videoElement.style.top = '-10000px';
972
  videoElement.style.left = '-10000px';
973
+ videoElement.style.width = '160px';
974
+ videoElement.style.height = '120px';
975
  videoElement.style.pointerEvents = 'none';
976
  document.body.appendChild(videoElement);
977
 
978
  videoElement.addEventListener('loadeddata', () => {
979
+ captureCanvas.width = 160;
980
+ captureCanvas.height = 120;
981
  if (!window.cameraInterval) {
982
  window.cameraInterval = setInterval(() => {
983
+ // Skip if a frame is still being processed by server
984
+ if (_frameInFlight) return;
985
  if (document.visibilityState === 'visible' && socket && socket.connected) {
986
+ captureCtx.drawImage(videoElement, 0, 0, 160, 120);
987
+ const frame = captureCanvas.toDataURL('image/jpeg', 0.35);
988
+ _frameInFlight = true;
989
  socket.emit('browser_frame', { frame: frame.split(',')[1] });
990
+ // Auto-release lock after 500ms even if no ack received
991
+ setTimeout(() => { _frameInFlight = false; }, 500);
992
  }
993
+ }, 250); // 4 fps — smooth without overwhelming polling transport
994
  }
995
  });
996
  } catch (err) {
 
1042
  });
1043
 
1044
  socket.on('state_update', (data) => {
1045
+ // Release in-flight lock — server has processed our frame
1046
+ _frameInFlight = false;
1047
  try {
1048
  if (data.frame) {
1049
  document.getElementById('camera-feed').src = 'data:image/jpeg;base64,' + data.frame;