Spaces:
Running
Running
Deepak Roshan
fix: use queue.Queue in SharedState instead of multiprocessing.Manager for HF Spaces cloud mode
6675768 | # isl_detection.py β CLOUD PRODUCTION VERSION | |
| # | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CLOUD FIXES APPLIED (3 changes only β everything else identical): | |
| # | |
| # FIX 1 β pygame TTS: init only in LOCAL mode (crashes on headless server) | |
| # FIX 2 β cv2.CAP_DSHOW: Windows-only flag removed for Linux cloud server | |
| # FIX 3 β SharedMemory cleanup on startup (prevents crash on restart) | |
| # | |
| # LOCAL CAMERA FIXES (built-in laptop webcam static/noise): | |
| # FIX 4 β MJPG codec + 30-frame flush to clear DirectShow buffer on Windows | |
| # FIX 5 β CAP_PROP_BUFFERSIZE set before flush (not after) | |
| # | |
| # ALL original features preserved: | |
| # β Emotion detection v3 (happy/sad/angry/surprise/neutral) | |
| # β Letter hold timer, same-letter cooldown, confidence buffer | |
| # β SharedMemory zero-copy frame transfer | |
| # β CLOUD mode browser frame ingestion (_cloud_frame) | |
| # β gTTS speak button (disabled silently in cloud β browser TTS handles it) | |
| # β Word suggestions, auto-TTS, reset, backspace, accept_suggestion | |
| # β Oracle Free Tier safe: TARGET_FPS=15, EMOTION_PROCESS_INTERVAL=5 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| import cv2 | |
| import mediapipe as mp | |
| import numpy as np | |
| import string | |
| import time | |
| import copy | |
| import itertools | |
| import os | |
| import base64 | |
| import platform | |
| import threading | |
| from collections import deque, defaultdict, Counter | |
| import queue as _queue | |
| from multiprocessing import Value, shared_memory | |
| import ctypes | |
| os.environ["CUDA_VISIBLE_DEVICES"] = "-1" | |
| os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" | |
| # Suppress MediaPipe GL context errors (headless cloud β no GPU, uses CPU) | |
| os.environ["GLOG_minloglevel"] = "3" # FATAL only (0=INFO,1=WARN,2=ERROR,3=FATAL) | |
| os.environ["GLOG_alsologtostderr"] = "0" | |
| os.environ["MEDIAPIPE_DISABLE_GPU"] = "1" | |
| os.environ["GRPC_VERBOSITY"] = "ERROR" | |
| from tensorflow.keras.models import load_model | |
| from gtts import gTTS | |
| import pygame | |
| try: | |
| import translation | |
| TRANSLATION_AVAILABLE = True | |
| except ImportError: | |
| TRANSLATION_AVAILABLE = False | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CONFIG | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| RUN_MODE = os.getenv("RUN_MODE", "LOCAL") | |
| ISL_MODEL_PATH = "model.h5" | |
| CAM_INDEX = 0 | |
| # ββ Letter timing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| LETTER_HOLD_SEC = 0.9 | |
| SAME_LETTER_COOLDOWN_SEC = 1.2 | |
| # ββ Emotion thresholds ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| SMOOTHING_FRAMES = 2 | |
| MIN_CONF_TO_SHOW = 0.06 | |
| EMOTIONS = ["happy", "sad", "angry", "surprise", "neutral"] | |
| CONF_THRESHOLD = 0.32 | |
| SPACE_THRESHOLD = 18 | |
| ENABLE_AUTO_TTS = True | |
| TTS_LANGUAGE = "en" | |
| SHOW_LETTER_OVERLAY = True | |
| OVERLAY_COLOR_LETTER = (0, 255, 255) | |
| OVERLAY_COLOR_CONF = (255, 255, 0) | |
| OVERLAY_POSITION = (20, 50) | |
| OVERLAY_FONT_SCALE = 1.5 | |
| OVERLAY_THICKNESS = 3 | |
| COMMON_WORDS = [ | |
| "HELLO", "HELP", "PLEASE", "THANK", "YOU", "YES", "NO", "GOOD", "BAD", | |
| "MORNING", "AFTERNOON", "EVENING", "NIGHT", "TODAY", "TOMORROW", "WATER", | |
| "FOOD", "HOME", "SCHOOL", "WORK", "HAPPY", "SAD", "SORRY", "WELCOME" | |
| ] | |
| TARGET_FPS = 15 | |
| CAMERA_RESOLUTION = (640, 480) | |
| EMOTION_PROCESS_INTERVAL = 5 | |
| FRAME_W, FRAME_H = 640, 480 | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SHARED STATE | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SharedState: | |
| def __init__(self): | |
| # Use stdlib queue.Queue β safe for single-process threading (CLOUD mode). | |
| # multiprocessing.Manager() would spawn a background process + IPC which | |
| # wastes memory on HF free tier and fails when there is no fork support. | |
| self.ui_queue = _queue.Queue(200) | |
| self.command_queue = _queue.Queue(20) | |
| self.processing_fps = Value(ctypes.c_double, 0.0) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # TTS ENGINE | |
| # FIX 1: pygame.mixer.init() only runs in LOCAL mode. | |
| # On cloud (headless Linux) there is no audio device β | |
| # init() would crash the entire process at import time. | |
| # In CLOUD mode TTS is silently disabled here; the browser | |
| # handles speech via the Web Speech API on the client side. | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| TTS_ENABLED = False | |
| if RUN_MODE == "LOCAL": | |
| try: | |
| pygame.mixer.init() | |
| TTS_ENABLED = True | |
| print("β TTS engine initialized") | |
| except Exception as e: | |
| print(f"β οΈ TTS init warning: {e}") | |
| else: | |
| print("βΉοΈ TTS disabled (CLOUD/headless mode) β browser handles speech") | |
| speaking_word = "" | |
| pause_audio = False | |
| def speak_text(text, lang=TTS_LANGUAGE): | |
| global speaking_word | |
| # FIX 1 continued: guard every call β silently skip on cloud | |
| if not TTS_ENABLED or not text or not text.strip(): | |
| return | |
| def _speak(): | |
| global pause_audio, speaking_word | |
| try: | |
| fname = f"voice_{int(time.time()*1000)}.mp3" | |
| gTTS(text=text, lang=lang).save(fname) | |
| speaking_word = text | |
| pygame.mixer.music.load(fname) | |
| pygame.mixer.music.play() | |
| while pygame.mixer.music.get_busy(): | |
| if pause_audio: | |
| pygame.mixer.music.pause() | |
| while pause_audio: | |
| time.sleep(0.1) | |
| pygame.mixer.music.unpause() | |
| pygame.time.Clock().tick(10) | |
| speaking_word = "" | |
| pygame.mixer.music.stop() | |
| pygame.mixer.music.unload() | |
| time.sleep(0.15) | |
| for _ in range(3): | |
| try: | |
| if os.path.exists(fname): | |
| os.remove(fname) | |
| break | |
| except PermissionError: | |
| time.sleep(0.2) | |
| except Exception as e: | |
| print(f"TTS error: {e}") | |
| speaking_word = "" | |
| threading.Thread(target=_speak, daemon=True).start() | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # LANDMARK UTILITIES | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def calc_landmark_list(image, landmarks): | |
| w, h = image.shape[1], image.shape[0] | |
| return [ | |
| [min(int(lm.x * w), w - 1), min(int(lm.y * h), h - 1)] | |
| for lm in landmarks.landmark | |
| ] | |
| def pre_process_landmark(landmark_list): | |
| temp = copy.deepcopy(landmark_list) | |
| bx, by = temp[0] | |
| for i in range(len(temp)): | |
| temp[i][0] -= bx | |
| temp[i][1] -= by | |
| flat = list(itertools.chain.from_iterable(temp)) | |
| mv = max(map(abs, flat)) if flat else 1 | |
| return [n / mv for n in flat] | |
| def apply_clahe(frame): | |
| """Enhance low-light / low-contrast frames for mobile cameras.""" | |
| try: | |
| lab = cv2.cvtColor(frame, cv2.COLOR_BGR2LAB) | |
| l, a, b = cv2.split(lab) | |
| clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8)) | |
| return cv2.cvtColor(cv2.merge((clahe.apply(l), a, b)), cv2.COLOR_LAB2BGR) | |
| except Exception: | |
| return frame | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # EMOTION DETECTION β v3 | |
| # Redesigned SAD / ANGRY / SURPRISE with external facial cues. | |
| # HAPPY unchanged. | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _dist(a, b): | |
| """Euclidean distance between two 2-D landmark points.""" | |
| return float(np.linalg.norm( | |
| np.array(a, dtype=np.float32) - np.array(b, dtype=np.float32) | |
| )) | |
| def detect_emotion_from_landmarks(face_landmarks, img_shape): | |
| """ | |
| Derive emotion scores from 468 MediaPipe Face Mesh landmarks. | |
| Returns {emotion: float} summing to 1.0. | |
| Key landmark reference: | |
| Mouth corners : 61 (L), 291 (R) | |
| Lip inner : 13 (top), 14 (bottom) | |
| Lip outer : 0 (top), 17 (bottom) | |
| L eye lids : 159 (upper), 145 (lower) | |
| R eye lids : 386 (upper), 374 (lower) | |
| L eye corners : 33 (outer), 133 (inner) | |
| R eye corners : 362 (outer), 263 (inner) | |
| L brow : 70 (inner), 105 (outer), 52 (arch) | |
| R brow : 300 (inner), 334 (outer), 282 (arch) | |
| Nose tip : 4 | |
| Nostrils : 129 (L ala), 358 (R ala) | |
| Cheeks : 50 (L), 280 (R) | |
| """ | |
| try: | |
| pts = np.array(face_landmarks, dtype=np.float32) | |
| face_w = max(float(np.max(pts[:, 0]) - np.min(pts[:, 0])), 1.0) | |
| face_h = max(float(np.max(pts[:, 1]) - np.min(pts[:, 1])), 1.0) | |
| def p(i): | |
| return pts[i] | |
| def nw(a, b): | |
| return _dist(p(a), p(b)) / face_w | |
| def nh(a, b): | |
| return _dist(p(a), p(b)) / face_h | |
| def clamp01(v): | |
| return max(0.0, min(1.0, float(v))) | |
| # ββ Mouth ββββββββββββββββββββββββββββββββββββββββ | |
| lc, rc = p(61), p(291) | |
| tl, bl = p(13), p(14) | |
| ut, lb = p(0), p(17) | |
| mid_y = (tl[1] + bl[1]) / 2.0 | |
| mouth_curve = ((lc[1] - mid_y) + (rc[1] - mid_y)) / (2.0 * face_h) | |
| mouth_open = _dist(ut, lb) / face_h | |
| inner_gap = _dist(tl, bl) / face_h | |
| mouth_w = _dist(lc, rc) / face_w | |
| o_ratio = (mouth_open / (mouth_w + 1e-6)) if mouth_w > 0.05 else 0.0 | |
| # ββ Eyes βββββββββββββββββββββββββββββββββββββββββ | |
| leu, led = p(159), p(145) | |
| reu, red = p(386), p(374) | |
| lel, ler = p(33), p(133) | |
| rel, rer = p(362), p(263) | |
| l_eye_h = _dist(leu, led) | |
| r_eye_h = _dist(reu, red) | |
| l_eye_w = _dist(lel, ler) | |
| r_eye_w = _dist(rel, rer) | |
| l_ear = l_eye_h / (l_eye_w + 1e-6) | |
| r_ear = r_eye_h / (r_eye_w + 1e-6) | |
| avg_ear = (l_ear + r_ear) / 2.0 | |
| eye_open = ((l_eye_h + r_eye_h) / 2.0) / face_h | |
| # ββ Eyebrows ββββββββββββββββββββββββββββββββββββββ | |
| lib, rib = p(70), p(300) | |
| lob, rob = p(105), p(334) | |
| lab, rab = p(52), p(282) | |
| l_brow_in_h = (leu[1] - lib[1]) / face_h | |
| r_brow_in_h = (reu[1] - rib[1]) / face_h | |
| avg_brow_in_h = (l_brow_in_h + r_brow_in_h) / 2.0 | |
| l_brow_out_h = (leu[1] - lob[1]) / face_h | |
| r_brow_out_h = (reu[1] - rob[1]) / face_h | |
| avg_brow_out_h = (l_brow_out_h + r_brow_out_h) / 2.0 | |
| brow_inner_gap = nw(70, 300) | |
| brow_oblique = avg_brow_in_h - avg_brow_out_h | |
| l_arch_h = (leu[1] - lab[1]) / face_h | |
| r_arch_h = (reu[1] - rab[1]) / face_h | |
| avg_arch_h = (l_arch_h + r_arch_h) / 2.0 | |
| # ββ Nose βββββββββββββββββββββββββββββββββββββββββ | |
| nostril_w = nw(129, 358) | |
| nose_flare = max(0.0, nostril_w - 0.26) | |
| # ββ Cheeks βββββββββββββββββββββββββββββββββββββββ | |
| lch, rch, nb = p(50), p(280), p(4) | |
| cheek_raise = ((nb[1] - lch[1]) + (nb[1] - rch[1])) / (2.0 * face_h) | |
| # ββ HAPPY ββββββββββββββββββββββββββββββββββββββββ | |
| happy = 0.0 | |
| if mouth_curve < -0.003: | |
| happy += clamp01(abs(mouth_curve) / 0.018) * 0.55 | |
| if mouth_w > 0.36: | |
| happy += clamp01((mouth_w - 0.36) / 0.07) * 0.28 | |
| if cheek_raise > 0.007: | |
| happy += clamp01(cheek_raise / 0.018) * 0.26 | |
| if avg_ear < 0.27 and eye_open > 0.04: | |
| happy += 0.12 | |
| if mouth_curve > 0.004: happy *= 0.15 | |
| if avg_brow_in_h < -0.05: happy *= 0.40 | |
| if mouth_open > 0.08: happy *= 0.55 | |
| happy = clamp01(happy * 1.30) | |
| # ββ SAD β v3 ββββββββββββββββββββββββββββββββββββββ | |
| sad = 0.0 | |
| if brow_oblique > 0.02: | |
| sad += clamp01((brow_oblique - 0.02) / 0.055) * 0.45 | |
| if 0.08 < avg_ear < 0.23: | |
| peak_dist = abs(avg_ear - 0.16) | |
| sad += clamp01(1.0 - peak_dist / 0.08) * 0.30 | |
| if 0.02 < eye_open < 0.065: | |
| sad += clamp01((0.065 - eye_open) / 0.025) * 0.18 | |
| if mouth_curve > 0.002: | |
| sad += clamp01(mouth_curve / 0.020) * 0.40 | |
| if mouth_w < 0.36: | |
| sad += clamp01((0.36 - mouth_w) / 0.055) * 0.18 | |
| if avg_brow_in_h > 0.04: | |
| sad += clamp01((avg_brow_in_h - 0.04) / 0.040) * 0.20 | |
| if mouth_curve < -0.004: sad *= 0.08 | |
| if cheek_raise > 0.014: sad *= 0.20 | |
| if avg_ear > 0.30: sad *= 0.25 | |
| if avg_brow_in_h < -0.06 and brow_inner_gap < 0.14: sad *= 0.15 | |
| sad = clamp01(sad * 1.50) | |
| # ββ ANGRY β v3 ββββββββββββββββββββββββββββββββββββ | |
| angry = 0.0 | |
| if nose_flare > 0.005: | |
| angry += clamp01(nose_flare / 0.055) * 0.35 | |
| if avg_brow_in_h < -0.04: | |
| angry += clamp01(abs(avg_brow_in_h + 0.04) / 0.045) * 0.40 | |
| if brow_inner_gap < 0.155: | |
| angry += clamp01((0.155 - brow_inner_gap) / 0.045) * 0.35 | |
| if avg_brow_out_h < -0.03: | |
| angry += clamp01(abs(avg_brow_out_h + 0.03) / 0.040) * 0.20 | |
| if avg_ear < 0.20 and eye_open < 0.055: | |
| angry += clamp01((0.055 - eye_open) / 0.025) * 0.25 | |
| if inner_gap < 0.030: | |
| angry += clamp01((0.030 - inner_gap) / 0.025) * 0.22 | |
| if brow_oblique < 0.015: | |
| angry += 0.08 | |
| if mouth_curve < -0.005: angry *= 0.15 | |
| if cheek_raise > 0.014: angry *= 0.20 | |
| if avg_brow_in_h > -0.01: angry *= 0.20 | |
| if mouth_open > 0.07: angry *= 0.40 | |
| if brow_oblique > 0.04: angry *= 0.35 | |
| angry = clamp01(angry * 1.45) | |
| # ββ SURPRISE β v3 βββββββββββββββββββββββββββββββββ | |
| surprise = 0.0 | |
| if 0.40 < o_ratio < 1.60: | |
| peak_dist = abs(o_ratio - 0.90) | |
| surprise += clamp01(1.0 - peak_dist / 0.55) * 0.50 | |
| if 0.03 < mouth_open < 0.11: | |
| surprise += clamp01((mouth_open - 0.03) / 0.06) * 0.25 | |
| if mouth_w > 0.40: | |
| surprise *= max(0.10, 1.0 - (mouth_w - 0.40) / 0.10) | |
| if avg_ear > 0.27: | |
| surprise += clamp01((avg_ear - 0.27) / 0.10) * 0.40 | |
| if eye_open > 0.07: | |
| surprise += clamp01((eye_open - 0.07) / 0.055) * 0.28 | |
| if avg_arch_h > 0.05: | |
| surprise += clamp01((avg_arch_h - 0.05) / 0.055) * 0.35 | |
| if avg_brow_in_h > 0.04 and avg_brow_out_h > 0.02: | |
| surprise += 0.15 | |
| if abs(brow_oblique) < 0.025: | |
| surprise += 0.08 | |
| if mouth_curve < -0.005: surprise *= 0.30 | |
| if avg_brow_in_h < -0.03: surprise *= 0.20 | |
| if avg_ear < 0.18: surprise *= 0.25 | |
| if brow_inner_gap < 0.13: surprise *= 0.25 | |
| if mouth_w > 0.44: surprise *= 0.25 | |
| if mouth_open > 0.12 and o_ratio < 0.40: | |
| surprise *= 0.30 | |
| surprise = clamp01(surprise * 1.35) | |
| # ββ NEUTRAL βββββββββββββββββββββββββββββββββββββββ | |
| neutral = max(0.0, 1.0 - (happy + sad + angry + surprise) * 1.05) | |
| total = happy + sad + angry + surprise + neutral | |
| if total > 1e-6: | |
| happy /= total | |
| sad /= total | |
| angry /= total | |
| surprise /= total | |
| neutral /= total | |
| return {k: round(float(v), 4) | |
| for k, v in zip(EMOTIONS, [happy, sad, angry, surprise, neutral])} | |
| except Exception as ex: | |
| print(f"β οΈ Emotion detection error: {ex}") | |
| return {e: (1.0 if e == "neutral" else 0.0) for e in EMOTIONS} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # DISPLAY HELPERS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_word_suggestions(partial, word_freq): | |
| if not partial: | |
| return [] | |
| p_up = partial.upper() | |
| seen = {} | |
| out = [] | |
| for word, freq in word_freq.most_common(20): | |
| if word.startswith(p_up) and word != p_up: | |
| out.append((word, freq)); seen[word] = True | |
| for word in COMMON_WORDS: | |
| if word.startswith(p_up) and word not in seen: | |
| out.append((word, 0)) | |
| out.sort(key=lambda x: (-x[1], x[0])) | |
| return [m[0] for m in out[:3]] | |
| def draw_hand_landmarks(frame, hand_landmarks): | |
| # PERF: Skeleton drawing disabled in CLOUD mode β it was the main cause | |
| # of blurry/poor video quality and unnecessary CPU usage per frame. | |
| # The letter and confidence are displayed in the browser sidebar instead. | |
| pass | |
| def draw_letter_overlay(frame, letter, confidence): | |
| # PERF: Text overlay disabled β letter shown in sidebar panel. | |
| return frame | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CORE PROCESSING ENGINE | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def core_processing_engine(shared_state, shm_name=None, frame_lock_val=None, frame_seq=None): | |
| print("=" * 70) | |
| print("π ISL Detection System Startingβ¦ (emotion engine v3)") | |
| print(f" RUN_MODE = {RUN_MODE}") | |
| print(f" CONF_THRESHOLD = {CONF_THRESHOLD}") | |
| print(f" LETTER_HOLD_SEC = {LETTER_HOLD_SEC}s") | |
| print(f" SharedMemory = {'enabled' if shm_name else 'disabled (base64 fallback)'}") | |
| print("=" * 70) | |
| # ββ SharedMemory init ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| shm = None | |
| shm_array = None | |
| if shm_name is not None: | |
| try: | |
| shm = shared_memory.SharedMemory(name=shm_name) | |
| shm_array = np.ndarray((480, 640, 3), dtype=np.uint8, buffer=shm.buf) | |
| print("β SharedMemory connected (Zero-copy mode)") | |
| except Exception as e: | |
| print(f"β SharedMemory init failed: {e}") | |
| return | |
| # ββ Load ISL model βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| model = load_model(ISL_MODEL_PATH) | |
| print("β ISL model loaded") | |
| print(f" Input shape : {model.input_shape}") | |
| print(f" Output shape : {model.output_shape}") | |
| except Exception as e: | |
| print(f"β Failed to load model: {e}") | |
| if shm: | |
| shm.close() | |
| return | |
| try: | |
| model.predict(np.zeros((1, 42), np.float32), verbose=0) | |
| print("β Model warmed up") | |
| except Exception as e: | |
| print(f"β οΈ Warmup: {e}") | |
| alphabet = list(string.ascii_uppercase) + [str(i) for i in range(1, 10)] | |
| print(f" Alphabet : A-Z letters only (model outputs {model.output_shape[1]} classes, digits ignored)") | |
| # ββ Detection state ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| current_word = [] | |
| sentence = [] | |
| no_hand_frames = 0 | |
| candidate_letter = "" | |
| candidate_since = 0.0 | |
| last_accepted_letter = "" | |
| last_accepted_time = 0.0 | |
| letter_confidence_buffer = deque(maxlen=4) | |
| word_frequency = Counter() | |
| current_detected_letter = "" | |
| current_detected_letter_prev = "" | |
| current_confidence = 0.0 | |
| emotion_history = deque(maxlen=SMOOTHING_FRAMES) | |
| current_emotion = "neutral" | |
| emotion_scores = {e: (1.0 if e == "neutral" else 0.0) for e in EMOTIONS} | |
| emotion_timeline = deque(maxlen=100) | |
| emotion_frame_counter = 0 | |
| frame_count = 0 | |
| fps_frame_times = deque(maxlen=30) | |
| stats = { | |
| "letters_detected": 0, | |
| "words_formed": 0, | |
| "sentences_formed": 0, | |
| "emotion_changes": 0, | |
| "session_start": time.time(), | |
| } | |
| cmd_queue = shared_state.command_queue | |
| ui_queue = shared_state.ui_queue | |
| # ββ Camera init ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| print("π₯ Initializing cameraβ¦") | |
| if RUN_MODE == "LOCAL": | |
| # FIX 2: cv2.CAP_DSHOW is Windows-only β use platform check | |
| if platform.system() == "Windows": | |
| cap = cv2.VideoCapture(CAM_INDEX, cv2.CAP_DSHOW) | |
| else: | |
| cap = cv2.VideoCapture(CAM_INDEX) # Linux/Mac β no CAP_DSHOW | |
| if not cap.isOpened(): | |
| cap = cv2.VideoCapture(CAM_INDEX) # fallback without backend flag | |
| if not cap.isOpened(): | |
| print("β Camera not found β is another app using it?") | |
| if shm: | |
| shm.close() | |
| return | |
| # FIX 4: Set MJPG codec BEFORE resolution β forces hardware MJPEG path | |
| # on built-in laptop webcams, avoiding the raw uncompressed buffer that | |
| # causes the horizontal static/noise stripes seen in the MJPEG stream. | |
| # Must be set before width/height or it has no effect on some drivers. | |
| if platform.system() == "Windows": | |
| cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc('M', 'J', 'P', 'G')) | |
| cap.set(cv2.CAP_PROP_FRAME_WIDTH, CAMERA_RESOLUTION[0]) | |
| cap.set(cv2.CAP_PROP_FRAME_HEIGHT, CAMERA_RESOLUTION[1]) | |
| cap.set(cv2.CAP_PROP_FPS, TARGET_FPS) | |
| # FIX 5: Buffer size must be 1 to prevent stale frame accumulation. | |
| # Set AFTER resolution so the driver doesn't reset it. | |
| cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) | |
| # FIX 4 continued: Flush 30 frames (was 10) to drain the DirectShow | |
| # ring buffer. Built-in webcams pre-fill ~20 frames before stabilizing. | |
| print("π₯ Flushing camera buffer (30 frames)β¦") | |
| for _ in range(30): | |
| cap.read() | |
| # Verify the camera is actually producing valid frames | |
| ret, test_frame = cap.read() | |
| if not ret or test_frame is None: | |
| print("β Camera flush failed β no valid frame received") | |
| cap.release() | |
| if shm: | |
| shm.close() | |
| return | |
| actual_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) | |
| actual_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) | |
| print(f"β Camera: {actual_w}x{actual_h}") | |
| print("β Camera ready!") | |
| else: | |
| cap = None | |
| print("βοΈ CLOUD mode β waiting for browser frames via socket") | |
| print("=" * 70) | |
| mp_hands = mp.solutions.hands | |
| mp_face = mp.solutions.face_mesh | |
| with mp_hands.Hands( | |
| static_image_mode=False, | |
| model_complexity=0, | |
| max_num_hands=1, | |
| min_detection_confidence=0.6, | |
| min_tracking_confidence=0.6, | |
| ) as hands, \ | |
| mp_face.FaceMesh( | |
| static_image_mode=False, | |
| max_num_faces=1, | |
| refine_landmarks=True, | |
| min_detection_confidence=0.6, | |
| min_tracking_confidence=0.6, | |
| ) as face_mesh: | |
| print("β MediaPipe initialized β entering main loop") | |
| while True: | |
| t0 = time.time() | |
| now = t0 | |
| # ββ Commands βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| while not cmd_queue.empty(): | |
| cmd = cmd_queue.get_nowait() | |
| action = cmd.get("action", "") | |
| if action == "reset": | |
| current_word = [] | |
| sentence = [] | |
| no_hand_frames = 0 | |
| candidate_letter = "" | |
| candidate_since = 0.0 | |
| last_accepted_letter = "" | |
| last_accepted_time = 0.0 | |
| letter_confidence_buffer.clear() | |
| current_detected_letter = "" | |
| current_confidence = 0.0 | |
| print("π Reset") | |
| elif action == "backspace": | |
| if current_word: | |
| current_word.pop() | |
| elif sentence: | |
| sentence.pop() | |
| elif action == "accept_suggestion": | |
| w = cmd.get("word", "") | |
| if w: | |
| current_word = list(w) | |
| elif action == "speak": | |
| txt = (cmd.get("text", "") | |
| or (" ".join(sentence) if sentence | |
| else "".join(current_word))) | |
| if txt: | |
| speak_text(txt) # silently skipped in CLOUD mode | |
| elif action == "stop": | |
| if cap: | |
| cap.release() | |
| if shm: | |
| shm.close() | |
| return | |
| except Exception: | |
| pass | |
| # ββ Capture ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if RUN_MODE == "LOCAL": | |
| ret, raw = cap.read() | |
| if not ret or raw is None: | |
| print("β οΈ Camera read failed β retrying") | |
| time.sleep(0.02) | |
| continue | |
| else: | |
| fd = getattr(shared_state, "_cloud_frame", None) | |
| if fd is None: | |
| time.sleep(0.03) | |
| continue | |
| shared_state._cloud_frame = None | |
| try: | |
| raw = cv2.imdecode( | |
| np.frombuffer(base64.b64decode(fd), np.uint8), | |
| cv2.IMREAD_COLOR) | |
| if raw is None: | |
| continue | |
| except Exception: | |
| continue | |
| frame = cv2.resize(raw, (FRAME_W, FRAME_H)) | |
| frame = cv2.flip(frame, 1) | |
| frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) | |
| frame_rgb.flags.writeable = False | |
| # ββ Hand detection βββββββββββββββββββββββββββββββββββββββββββββ | |
| hand_results = hands.process(frame_rgb) | |
| frame_rgb.flags.writeable = True | |
| detected_letter = "" | |
| hand_detected = False | |
| if hand_results and hand_results.multi_hand_landmarks: | |
| hand_detected = True | |
| no_hand_frames = 0 | |
| for hlm in hand_results.multi_hand_landmarks: | |
| # draw_hand_landmarks disabled β clean frame sent to browser | |
| try: | |
| lm_list = calc_landmark_list(frame, hlm) | |
| pre_list = pre_process_landmark(lm_list) | |
| if model is not None and len(pre_list) == 42: | |
| features = np.array(pre_list, dtype=np.float32).reshape(1, 42) | |
| pred = model.predict(features, verbose=0) | |
| prob = float(np.max(pred)) | |
| raw_letter = alphabet[int(np.argmax(pred))] | |
| # Skip number predictions β only A-Z letters | |
| if raw_letter.isdigit(): | |
| current_detected_letter = "" | |
| current_detected_letter_prev = "" | |
| current_confidence = 0.0 | |
| continue | |
| current_detected_letter = raw_letter | |
| current_confidence = prob | |
| # Only log when detected letter changes β avoids | |
| # hundreds of identical lines per gesture hold. | |
| if raw_letter != current_detected_letter_prev: | |
| print(f"π Gesture: {raw_letter} ({prob:.3f})") | |
| current_detected_letter_prev = raw_letter | |
| if prob >= CONF_THRESHOLD: | |
| if raw_letter == candidate_letter: | |
| letter_confidence_buffer.append(prob) | |
| else: | |
| candidate_letter = raw_letter | |
| candidate_since = now | |
| letter_confidence_buffer.clear() | |
| letter_confidence_buffer.append(prob) | |
| avg_conf = float(np.mean(letter_confidence_buffer)) | |
| hold_time = now - candidate_since | |
| if hold_time >= LETTER_HOLD_SEC and avg_conf >= CONF_THRESHOLD: | |
| same_letter_ok = ( | |
| raw_letter != last_accepted_letter or | |
| (now - last_accepted_time) >= SAME_LETTER_COOLDOWN_SEC | |
| ) | |
| if same_letter_ok: | |
| detected_letter = raw_letter | |
| last_accepted_letter = raw_letter | |
| last_accepted_time = now | |
| current_word.append(raw_letter) | |
| stats["letters_detected"] += 1 | |
| print(f"βοΈ Letter: {raw_letter} " | |
| f"(held {hold_time:.2f}s, conf {avg_conf:.3f})") | |
| candidate_letter = "" | |
| candidate_since = 0.0 | |
| letter_confidence_buffer.clear() | |
| else: | |
| candidate_letter = "" | |
| candidate_since = 0.0 | |
| letter_confidence_buffer.clear() | |
| except Exception as e: | |
| print(f"β οΈ Prediction error: {e}") | |
| else: | |
| no_hand_frames += 1 | |
| letter_confidence_buffer.clear() | |
| candidate_letter = "" | |
| candidate_since = 0.0 | |
| current_detected_letter = "" | |
| current_detected_letter_prev = "" | |
| current_confidence = 0.0 | |
| if no_hand_frames >= SPACE_THRESHOLD and current_word: | |
| word = "".join(current_word) | |
| sentence.append(word) | |
| word_frequency[word] += 1 | |
| stats["words_formed"] += 1 | |
| print(f"π Word committed: {word}") | |
| if ENABLE_AUTO_TTS: | |
| speak_text(word) # silently skipped in CLOUD mode | |
| current_word = [] | |
| last_accepted_letter = "" | |
| last_accepted_time = 0.0 | |
| no_hand_frames = 0 | |
| # frame = draw_letter_overlay(...) # disabled β shown in sidebar UI | |
| # ββ Emotion (every N frames) βββββββββββββββββββββββββββββββββββ | |
| emotion_frame_counter += 1 | |
| if emotion_frame_counter % EMOTION_PROCESS_INTERVAL == 0: | |
| ns = {e: (1.0 if e == "neutral" else 0.0) for e in EMOTIONS} | |
| try: | |
| cr = cv2.cvtColor(apply_clahe(frame), cv2.COLOR_BGR2RGB) | |
| cr.flags.writeable = False | |
| mr = face_mesh.process(cr) | |
| cr.flags.writeable = True | |
| if mr and mr.multi_face_landmarks: | |
| ns = detect_emotion_from_landmarks( | |
| calc_landmark_list(frame, mr.multi_face_landmarks[0]), | |
| frame.shape) | |
| except Exception: | |
| pass | |
| emotion_history.append(ns) | |
| avg_e = defaultdict(float) | |
| for d in emotion_history: | |
| for k, v in d.items(): | |
| avg_e[k] += v | |
| for k in avg_e: | |
| avg_e[k] /= len(emotion_history) | |
| dominant = (max(avg_e, key=avg_e.get) | |
| if avg_e and max(avg_e.values()) >= MIN_CONF_TO_SHOW | |
| else "neutral") | |
| if current_emotion != dominant: | |
| stats["emotion_changes"] += 1 | |
| scores_str = " | ".join( | |
| [f"{k.upper()}:{avg_e[k]:.3f}" for k in EMOTIONS] | |
| ) | |
| print(f"π Emotion: {current_emotion.upper()} β {dominant.upper()}") | |
| print(f" Scores : {scores_str}") | |
| current_emotion = dominant | |
| emotion_scores = dict(avg_e) | |
| emotion_timeline.append({ | |
| "time": time.time(), | |
| "emotion": dominant, | |
| "scores": dict(avg_e), | |
| }) | |
| # ββ FPS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| frame_count += 1 | |
| fps_frame_times.append(time.time()) | |
| if len(fps_frame_times) > 1: | |
| td = fps_frame_times[-1] - fps_frame_times[0] | |
| if td > 0: | |
| shared_state.processing_fps.value = (len(fps_frame_times) - 1) / td | |
| # ββ Frame output βββββββββββββββββββββββββββββββββββββββββββββββ | |
| if shm_array is not None: | |
| shm_array[:] = frame | |
| if frame_lock_val is not None: | |
| frame_lock_val.value = 1 | |
| if frame_seq is not None: | |
| frame_seq.value += 1 | |
| frame_base64 = None | |
| else: | |
| # Send full 640Γ480 frame β browser displays at native resolution. | |
| # Skeleton/overlay removed so JPEG compresses cleanly at quality 75. | |
| _, buffer_img = cv2.imencode( | |
| ".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85]) | |
| frame_base64 = base64.b64encode(buffer_img.tobytes()).decode("utf-8") | |
| # ββ State packet βββββββββββββββββββββββββββββββββββββββββββββββ | |
| state_packet = { | |
| "frame": frame_base64 if frame_base64 else "", | |
| "current_word": "".join(current_word), | |
| "sentence": " ".join(sentence), | |
| "suggestions": get_word_suggestions( | |
| "".join(current_word), word_frequency), | |
| "emotion": current_emotion, | |
| "emotion_scores": emotion_scores, | |
| "emotion_timeline": list(emotion_timeline)[-20:], | |
| "stats": stats.copy(), | |
| "detected_letter": detected_letter, | |
| "hand_detected": hand_detected, | |
| "fps": shared_state.processing_fps.value, | |
| "timestamp": time.time(), | |
| "speaking": speaking_word, | |
| "overlay_letter": current_detected_letter, | |
| "overlay_confidence": current_confidence, | |
| "hold_progress": min(1.0, | |
| (now - candidate_since) / LETTER_HOLD_SEC) | |
| if candidate_letter else 0.0, | |
| } | |
| try: | |
| ui_queue.put_nowait(state_packet) | |
| except Exception: | |
| try: | |
| ui_queue.get_nowait() | |
| ui_queue.put_nowait(state_packet) | |
| except Exception: | |
| pass | |
| if frame_count == 1: | |
| print("π€ First frame sent β dashboard can read now") | |
| if shm_array is not None: | |
| print("π€ SharedMemory active β zero-copy mode") | |
| # ββ Frame-rate limiter βββββββββββββββββββββββββββββββββββββββββ | |
| wait = (1.0 / TARGET_FPS) - (time.time() - t0) | |
| if wait > 0: | |
| time.sleep(wait) | |
| if cap: | |
| cap.release() | |
| if shm is not None: | |
| shm.close() | |
| print("β System stopped") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STANDALONE ENTRY POINT | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| FRAME_BYTES = FRAME_W * FRAME_H * 3 | |
| # FIX 3: Clean up leftover shared memory from a previous crash | |
| # Without this, restarting after a crash raises FileExistsError | |
| try: | |
| _old_shm = shared_memory.SharedMemory(name="isl_frame_shm") | |
| _old_shm.close() | |
| _old_shm.unlink() | |
| print("π§Ή Cleaned up leftover shared memory from previous run") | |
| except FileNotFoundError: | |
| pass # normal β no leftover | |
| shm = shared_memory.SharedMemory(create=True, size=FRAME_BYTES, name="isl_frame_shm") | |
| seq = Value(ctypes.c_uint64, 0) | |
| lock = Value(ctypes.c_bool, False) | |
| ss = SharedState() | |
| try: | |
| core_processing_engine(ss, shm_name=shm.name, frame_lock_val=lock, frame_seq=seq) | |
| finally: | |
| shm.close() | |
| shm.unlink() |