Spaces:
Sleeping
Sleeping
| """ | |
| GhSL Emotion Recognition β Hugging Face Space API | |
| Deployed at: https://huggingface.co/spaces/ransoppong/ghsl-emotion-api | |
| Provides named Gradio API endpoints for use by Deafly Health platform. | |
| Models are downloaded from ransoppong/ghsl-emosign-models on first run. | |
| """ | |
| import os | |
| import sys | |
| import json | |
| import pickle | |
| import traceback | |
| import numpy as np | |
| import torch | |
| from pathlib import Path | |
| # ββ Disable MediaPipe GPU delegate (Tasks API tries EGL even on CPU) ββββββββββ | |
| # Use MEDIAPIPE_DISABLE_GPU to avoid the libEGL.so.1 dlopen on headless HF Spaces. | |
| os.environ["MEDIAPIPE_DISABLE_GPU"] = "1" | |
| # ββ Monkey-patch gradio_client bug present in Gradio 5.9.1 βββββββββββββββββββ | |
| # gradio_client/utils.py:887 does `if "const" in schema` where schema can be | |
| # a JSON Schema boolean (True/False) β TypeError. Patch before importing gradio. | |
| try: | |
| import gradio_client.utils as _gcu | |
| _orig_js2py = _gcu._json_schema_to_python_type | |
| def _patched_js2py(schema, defs=None): | |
| if not isinstance(schema, dict): | |
| return "Any" | |
| return _orig_js2py(schema, defs) | |
| _gcu._json_schema_to_python_type = _patched_js2py | |
| except Exception: | |
| pass | |
| import gradio as gr | |
| # Ensure local models_arch package is on the path | |
| sys.path.insert(0, str(Path(__file__).parent)) | |
| # ββ Model download ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MODEL_REPO = "ransoppong/ghsl-emosign-models" | |
| MODELS_DIR = Path("models") | |
| MODELS_DIR.mkdir(exist_ok=True) | |
| _download_errors = {} | |
| def _download_model(filename: str) -> Path: | |
| dest = MODELS_DIR / filename | |
| if dest.exists(): | |
| return dest | |
| try: | |
| from huggingface_hub import hf_hub_download | |
| print(f"Downloading {filename} from {MODEL_REPO}...") | |
| hf_hub_download(repo_id=MODEL_REPO, filename=filename, | |
| local_dir=str(MODELS_DIR)) | |
| print(f" -> {dest}") | |
| except Exception as e: | |
| _download_errors[filename] = str(e) | |
| print(f"Warning: could not download {filename}: {e}") | |
| return dest | |
| for _fname in [ | |
| "st_egn_best.pt", | |
| "ei_gn_best.pt", | |
| "emotion_clf_best.pkl", | |
| "pose_landmarker_lite.task", | |
| "hand_landmarker.task", | |
| "face_landmarker.task", | |
| ]: | |
| _download_model(_fname) | |
| # ββ Architecture imports (from local models_arch/) ββββββββββββββββββββββββββββ | |
| try: | |
| from models_arch.st_egn import build_stegn | |
| from models_arch.ei_gn import build_eign, EMOTIONS | |
| from models_arch.counselling import generate_response, StrategyPolicy, safety_check | |
| from models_arch.train import FACE_KEY_IDX, N_KEY | |
| _ARCH_OK = True | |
| except Exception as _arch_err: | |
| _ARCH_OK = False | |
| _arch_err_msg = traceback.format_exc() | |
| print(f"Architecture import error:\n{_arch_err_msg}") | |
| # Provide fallbacks so the rest of the file parses cleanly | |
| EMOTIONS = ["joy", "excited", "surprise_pos", "surprise_neg", | |
| "worry", "sadness", "fear", "disgust", "frustration", "anger"] | |
| FACE_KEY_IDX = list(range(84)) | |
| N_KEY = 84 | |
| # ββ Constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| CLASS_NAMES_BINARY = ["Negative", "Positive"] | |
| CLASS_NAMES_TERNARY = ["Negative", "Neutral", "Positive"] | |
| MAX_FRAMES = 60 | |
| N_POSE, N_FACE, N_HAND = 33, 478, 21 | |
| POSE_UPPER = [11, 12, 13, 14, 15, 16, 23, 24] | |
| _STRATEGY_DESC = { | |
| "reflect": "Reflect the patient's experience back to them to show understanding.", | |
| "validate": "Validate the patient's feelings as understandable and normal.", | |
| "ground": "Use grounding techniques to help the patient stay present and calm.", | |
| "refer": "Acknowledge the concern and refer to a specialist or emergency support.", | |
| } | |
| DEVICE = torch.device("cpu") # HF Spaces free tier β CPU only | |
| # ββ Load models βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| CLF = None # RandomForest pipeline | |
| STEGN = None # Spatio-Temporal Emotion Graph Network | |
| EIGN = None # Emotion Interaction Graph Network | |
| _model_status = { | |
| "rf": "not loaded", | |
| "stegn": "not loaded", | |
| "eign": "not loaded", | |
| "mediapipe": "not loaded", | |
| } | |
| def _load_models(): | |
| global CLF, STEGN, EIGN | |
| # RandomForest | |
| rf_path = MODELS_DIR / "emotion_clf_best.pkl" | |
| if rf_path.exists(): | |
| try: | |
| with open(rf_path, "rb") as f: | |
| _saved = pickle.load(f) | |
| CLF = _saved["pipeline"] | |
| _model_status["rf"] = "ok" | |
| print("RF loaded.") | |
| except Exception as e: | |
| _model_status["rf"] = f"error: {e}" | |
| else: | |
| _model_status["rf"] = "file not found" | |
| if not _ARCH_OK: | |
| _model_status["stegn"] = "arch import failed" | |
| _model_status["eign"] = "arch import failed" | |
| return | |
| # ST-EGN | |
| stegn_path = MODELS_DIR / "st_egn_best.pt" | |
| if stegn_path.exists(): | |
| try: | |
| ckpt = torch.load(stegn_path, map_location="cpu", weights_only=False) | |
| cfg = ckpt.get("cfg", {}) | |
| STEGN = build_stegn( | |
| num_classes = ckpt["num_classes"], | |
| hidden = ckpt["hidden"], | |
| num_nodes = N_KEY, | |
| gcn_layers = cfg.get("stegn_gcn_layers", 3), | |
| tf_layers = cfg.get("stegn_tf_layers", 2), | |
| ) | |
| STEGN.load_state_dict(ckpt["state_dict"]) | |
| STEGN.eval() | |
| _model_status["stegn"] = "ok" | |
| print("ST-EGN loaded.") | |
| except Exception as e: | |
| _model_status["stegn"] = f"error: {e}" | |
| print(f"ST-EGN load error: {e}") | |
| else: | |
| _model_status["stegn"] = "file not found" | |
| # EI-GN β instantiate EIGN class directly to pass tf_layers from checkpoint | |
| eign_path = MODELS_DIR / "ei_gn_best.pt" | |
| if eign_path.exists(): | |
| try: | |
| ckpt = torch.load(eign_path, map_location="cpu", weights_only=False) | |
| ecfg = ckpt.get("cfg", {}) | |
| # Import the class, not the factory, to avoid older build_eign signature | |
| from models_arch.ei_gn import EIGN as _EIGN_cls, NUM_EMOTIONS as _NUM_EMO | |
| EIGN = _EIGN_cls( | |
| num_emotions = _NUM_EMO, | |
| hidden = ckpt.get("hidden", 64), | |
| gcn_layers = ecfg.get("eign_gcn_layers", 3), | |
| tf_layers = ecfg.get("eign_tf_layers", 3), | |
| tf_heads = 2, | |
| num_classes = ckpt.get("num_classes", 2), | |
| dropout = 0.2, | |
| ) | |
| EIGN.load_state_dict(ckpt["state_dict"]) | |
| EIGN.eval() | |
| _model_status["eign"] = "ok" | |
| print("EI-GN loaded.") | |
| except Exception as e: | |
| _model_status["eign"] = f"error: {e}" | |
| print(f"EI-GN load error: {e}") | |
| else: | |
| _model_status["eign"] = "file not found" | |
| _load_models() | |
| # ββ MediaPipe detectors (legacy Solutions API β no EGL required on CPU) βββββββ | |
| POSE_D = None | |
| HAND_D = None | |
| FACE_D = None | |
| def _init_mediapipe(): | |
| global POSE_D, HAND_D, FACE_D | |
| try: | |
| from mediapipe.tasks import python as mp_python | |
| from mediapipe.tasks.python import vision as mp_vision | |
| from mediapipe.tasks.python.vision import RunningMode | |
| # Explicitly force CPU delegate β prevents libEGL.so.1 dlopen on headless servers | |
| CPU = mp_python.BaseOptions.Delegate.CPU | |
| base = mp_python.BaseOptions | |
| pose_path = MODELS_DIR / "pose_landmarker_lite.task" | |
| hand_path = MODELS_DIR / "hand_landmarker.task" | |
| face_path = MODELS_DIR / "face_landmarker.task" | |
| if pose_path.exists(): | |
| POSE_D = mp_vision.PoseLandmarker.create_from_options( | |
| mp_vision.PoseLandmarkerOptions( | |
| base_options=base(model_asset_path=str(pose_path), delegate=CPU), | |
| running_mode=RunningMode.IMAGE, num_poses=1, | |
| min_pose_detection_confidence=0.5, | |
| min_pose_presence_confidence=0.5, | |
| min_tracking_confidence=0.5, | |
| output_segmentation_masks=False)) | |
| if hand_path.exists(): | |
| HAND_D = mp_vision.HandLandmarker.create_from_options( | |
| mp_vision.HandLandmarkerOptions( | |
| base_options=base(model_asset_path=str(hand_path), delegate=CPU), | |
| running_mode=RunningMode.IMAGE, num_hands=2, | |
| min_hand_detection_confidence=0.5, | |
| min_hand_presence_confidence=0.5, | |
| min_tracking_confidence=0.5)) | |
| if face_path.exists(): | |
| FACE_D = mp_vision.FaceLandmarker.create_from_options( | |
| mp_vision.FaceLandmarkerOptions( | |
| base_options=base(model_asset_path=str(face_path), delegate=CPU), | |
| running_mode=RunningMode.IMAGE, num_faces=1, | |
| min_face_detection_confidence=0.5, | |
| min_face_presence_confidence=0.5, | |
| min_tracking_confidence=0.5, | |
| output_face_blendshapes=False, | |
| output_facial_transformation_matrixes=False)) | |
| _model_status["mediapipe"] = "ok" | |
| print("MediaPipe detectors ready.") | |
| except Exception as e: | |
| _model_status["mediapipe"] = f"error: {e}" | |
| print(f"MediaPipe init error: {e}") | |
| _init_mediapipe() | |
| # ββ Video feature extraction ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _extract_all(video_path: str): | |
| """ | |
| Returns (feat_vec, face_seq) where: | |
| feat_vec : (862,) pooled features for RF | |
| face_seq : (T, N_KEY, 3) raw key face landmarks for ST-EGN | |
| Either may be None on failure. | |
| """ | |
| try: | |
| import cv2 | |
| except ImportError: | |
| return None, None | |
| if FACE_D is None: | |
| return None, None | |
| cap = cv2.VideoCapture(video_path) | |
| if not cap.isOpened(): | |
| return None, None | |
| fp, ff, flh, frh = [], [], [], [] | |
| while True: | |
| ok, bgr = cap.read() | |
| if not ok: | |
| break | |
| rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) | |
| import mediapipe as mp | |
| img = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb) | |
| # Pose (Tasks API) | |
| if POSE_D is not None: | |
| pr = POSE_D.detect(img) | |
| p = (np.array([[lm.x, lm.y, lm.z, lm.visibility] | |
| for lm in pr.pose_landmarks[0]], np.float32) | |
| if pr.pose_landmarks else np.zeros((N_POSE, 4), np.float32)) | |
| else: | |
| p = np.zeros((N_POSE, 4), np.float32) | |
| fp.append(p) | |
| # Face (Tasks API) | |
| fr = FACE_D.detect(img) | |
| if fr.face_landmarks: | |
| raw = [[lm.x, lm.y, lm.z] for lm in fr.face_landmarks[0]] | |
| if len(raw) < N_FACE: | |
| raw += [[0.0, 0.0, 0.0]] * (N_FACE - len(raw)) | |
| f = np.array(raw[:N_FACE], np.float32) | |
| else: | |
| f = np.zeros((N_FACE, 3), np.float32) | |
| ff.append(f) | |
| # Hands (Tasks API) | |
| lh = rh = np.zeros((N_HAND, 3), np.float32) | |
| if HAND_D is not None: | |
| hr = HAND_D.detect(img) | |
| if hr.hand_landmarks: | |
| for i, cats in enumerate(hr.handedness): | |
| arr = np.array([[lm.x, lm.y, lm.z] | |
| for lm in hr.hand_landmarks[i]], np.float32) | |
| if cats[0].category_name == "Left": | |
| lh = arr | |
| else: | |
| rh = arr | |
| flh.append(lh) | |
| frh.append(rh) | |
| cap.release() | |
| if not fp: | |
| return None, None | |
| # RF feature vector: mean + std pooling over time | |
| pose_arr = np.stack(fp)[:, POSE_UPPER, :].reshape(len(fp), -1) | |
| face_arr = np.stack(ff)[:, FACE_KEY_IDX, :].reshape(len(ff), -1) | |
| lhand_arr = np.stack(flh).reshape(len(flh), -1) | |
| rhand_arr = np.stack(frh).reshape(len(frh), -1) | |
| feats = [] | |
| for part in [pose_arr, face_arr, lhand_arr, rhand_arr]: | |
| feats.extend([part.mean(axis=0), part.std(axis=0)]) | |
| feat_vec = np.nan_to_num( | |
| np.concatenate(feats), nan=0.0, posinf=0.0, neginf=0.0) | |
| # ST-EGN face sequence (key landmarks only) | |
| face_full = np.stack(ff) # (T, 478, 3) | |
| face_key_seq = face_full[:, FACE_KEY_IDX, :] # (T, N_KEY, 3) | |
| T = face_key_seq.shape[0] | |
| if T > MAX_FRAMES: | |
| indices = np.linspace(0, T - 1, MAX_FRAMES, dtype=int) | |
| face_key_seq = face_key_seq[indices] | |
| return feat_vec, face_key_seq | |
| def _stegn_predict(face_seq: np.ndarray): | |
| """Run ST-EGN on a (T, N_KEY, 3) array. Returns (pred_label, proba_array).""" | |
| T = face_seq.shape[0] | |
| pad_len = MAX_FRAMES - T | |
| mask = torch.zeros(1, MAX_FRAMES, dtype=torch.bool) | |
| if pad_len > 0: | |
| face_seq = np.concatenate( | |
| [face_seq, np.zeros((pad_len, N_KEY, 3), np.float32)]) | |
| mask[0, T:] = True | |
| face_tensor = torch.tensor(face_seq, dtype=torch.float32).unsqueeze(0) | |
| with torch.no_grad(): | |
| logits = STEGN(face_tensor, mask) | |
| proba = torch.softmax(logits, dim=-1)[0].numpy() | |
| pred_idx = int(np.argmax(proba)) | |
| return CLASS_NAMES_BINARY[pred_idx], proba | |
| # ββ Endpoint 1: predict_video βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def predict_video(video_path): | |
| """ | |
| Predict emotional affect from a sign language video. | |
| Args: | |
| video_path: path to uploaded video file | |
| Returns: | |
| affect : "Negative" or "Positive" | |
| confidence : 0.0β1.0 | |
| strategy : counselling strategy string | |
| details : JSON string with per-model breakdown | |
| """ | |
| if video_path is None: | |
| return "No video uploaded.", 0.0, "", json.dumps({"error": "no video"}) | |
| feat_vec, face_seq = _extract_all(video_path) | |
| if feat_vec is None: | |
| return ( | |
| "Could not extract features from video.", | |
| 0.0, "", | |
| json.dumps({"error": "feature extraction failed β check MediaPipe task files"}), | |
| ) | |
| results = {} | |
| affect = "Negative" | |
| conf = 0.5 | |
| source = "none" | |
| # Try RF (fast baseline) | |
| if CLF is not None: | |
| try: | |
| proba_rf = CLF.predict_proba(feat_vec.reshape(1, -1))[0] | |
| idx_rf = int(np.argmax(proba_rf)) | |
| results["rf"] = { | |
| "affect": CLASS_NAMES_BINARY[idx_rf], | |
| "confidence": float(proba_rf[idx_rf]), | |
| "scores": {c: float(p) for c, p in zip(CLASS_NAMES_BINARY, proba_rf)}, | |
| } | |
| affect = CLASS_NAMES_BINARY[idx_rf] | |
| conf = float(proba_rf[idx_rf]) | |
| source = "RandomForest" | |
| except Exception as e: | |
| results["rf"] = {"error": str(e)} | |
| # Try ST-EGN if enough frames | |
| if STEGN is not None and face_seq is not None and face_seq.shape[0] >= 10: | |
| try: | |
| affect_stegn, proba_stegn = _stegn_predict(face_seq) | |
| results["stegn"] = { | |
| "affect": affect_stegn, | |
| "confidence": float(np.max(proba_stegn)), | |
| "scores": {c: float(p) for c, p in zip(CLASS_NAMES_BINARY, proba_stegn)}, | |
| "frames_used": int(min(face_seq.shape[0], MAX_FRAMES)), | |
| } | |
| # Prefer ST-EGN when we have enough frames | |
| if face_seq.shape[0] >= 30: | |
| affect = affect_stegn | |
| conf = float(np.max(proba_stegn)) | |
| source = "ST-EGN" | |
| except Exception as e: | |
| results["stegn"] = {"error": str(e)} | |
| # Build emotion scores from binary prediction for strategy | |
| emotion_scores = { | |
| "joy": 4.0 if affect == "Positive" else 1.0, | |
| "excited": 3.0 if affect == "Positive" else 1.0, | |
| "surprise_pos": 2.0, | |
| "surprise_neg": 2.0, | |
| "worry": 3.5 if affect == "Negative" else 1.0, | |
| "sadness": 3.0 if affect == "Negative" else 1.0, | |
| "fear": 2.5 if affect == "Negative" else 1.0, | |
| "disgust": 2.0 if affect == "Negative" else 1.0, | |
| "frustration": 2.5 if affect == "Negative" else 1.0, | |
| "anger": 2.0 if affect == "Negative" else 1.0, | |
| } | |
| strategy = "reflect" | |
| if _ARCH_OK: | |
| try: | |
| strategy = StrategyPolicy.rule_based(affect, emotion_scores) | |
| except Exception: | |
| pass | |
| results["final"] = {"affect": affect, "confidence": conf, "source": source} | |
| strategy_text = f"{strategy.upper()} β {_STRATEGY_DESC.get(strategy, '')}" | |
| return affect, round(conf, 4), strategy_text, json.dumps(results, indent=2) | |
| # ββ Endpoint 2: predict_emotion_vec ββββββββββββββββββββββββββββββββββββββββββ | |
| def predict_emotion_vec( | |
| joy: float, excited: float, surprise_pos: float, surprise_neg: float, | |
| worry: float, sadness: float, fear: float, disgust: float, | |
| frustration: float, anger: float, | |
| ): | |
| """ | |
| Predict affect from 10-dimensional emotion intensity scores (1β5 scale). | |
| Returns: | |
| affect : "Negative", "Neutral", or "Positive" | |
| confidence : 0.0β1.0 | |
| node_weights : JSON string mapping emotion names to attention weights | |
| """ | |
| scores = [joy, excited, surprise_pos, surprise_neg, | |
| worry, sadness, fear, disgust, frustration, anger] | |
| if EIGN is None: | |
| return ( | |
| "EI-GN model not loaded.", | |
| 0.0, | |
| json.dumps({"error": "EI-GN not loaded", "status": _model_status["eign"]}), | |
| ) | |
| try: | |
| vec = torch.tensor(scores, dtype=torch.float32).unsqueeze(0) # (1, 10) | |
| with torch.no_grad(): | |
| logits, node_w = EIGN(vec) | |
| proba = torch.softmax(logits, dim=-1)[0].numpy() | |
| node_weights = node_w[0].numpy() | |
| # Determine label set from output size | |
| n_cls = len(proba) | |
| label_set = CLASS_NAMES_TERNARY if n_cls == 3 else CLASS_NAMES_BINARY | |
| pred_idx = int(np.argmax(proba)) | |
| affect = label_set[pred_idx] if pred_idx < len(label_set) else str(pred_idx) | |
| conf = float(proba[pred_idx]) | |
| weights_dict = {EMOTIONS[i]: round(float(node_weights[i]), 4) | |
| for i in range(len(EMOTIONS))} | |
| weights_dict["_scores"] = {c: round(float(p), 4) | |
| for c, p in zip(label_set, proba)} | |
| return affect, round(conf, 4), json.dumps(weights_dict, indent=2) | |
| except Exception as e: | |
| return ( | |
| f"Inference error: {e}", | |
| 0.0, | |
| json.dumps({"error": str(e)}), | |
| ) | |
| # ββ Endpoint 3: get_counselling βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_counselling( | |
| context_sentence: str, | |
| emotion_class: str, | |
| emotion_scores_json: str, | |
| strategy: str, | |
| category: str, | |
| openai_api_key: str, | |
| ): | |
| """ | |
| Generate a clinical counselling response via GPT-4o-mini. | |
| Args: | |
| context_sentence : text description of what the patient signed | |
| emotion_class : "Negative", "Neutral", or "Positive" | |
| emotion_scores_json : JSON string like '{"joy": 4, "sadness": 2, ...}' | |
| strategy : one of reflect / validate / ground / refer | |
| category : healthcare category e.g. "Mental health interactions" | |
| openai_api_key : OpenAI API key (or set OPENAI_API_KEY env var) | |
| Returns: | |
| response_text : final safe counselling response | |
| strategy_used : strategy applied | |
| safety_status : "safe" or comma-separated flags | |
| """ | |
| if not context_sentence.strip(): | |
| return "Please enter the patient's signing context.", "", "n/a" | |
| if not _ARCH_OK: | |
| return "Architecture not loaded β cannot generate counselling response.", "", "error" | |
| # Parse emotion scores | |
| try: | |
| emotion_scores = json.loads(emotion_scores_json) if emotion_scores_json.strip() else {} | |
| except json.JSONDecodeError as e: | |
| return f"Invalid emotion_scores_json: {e}", "", "error" | |
| # Default scores if not provided | |
| if not emotion_scores: | |
| emotion_scores = {e: 1.0 for e in EMOTIONS} | |
| # Auto-select strategy if not specified | |
| if not strategy or strategy not in _STRATEGY_DESC: | |
| try: | |
| strategy = StrategyPolicy.rule_based(emotion_class, emotion_scores) | |
| except Exception: | |
| strategy = "reflect" | |
| if not category: | |
| category = "General healthcare" | |
| api_key = (openai_api_key.strip() if openai_api_key else "") or \ | |
| os.environ.get("OPENAI_API_KEY", "") | |
| if not api_key: | |
| # Return rule-based fallback without LLM | |
| strategy_desc = _STRATEGY_DESC.get(strategy, "") | |
| fallback = ( | |
| f"[No API key β rule-based fallback]\n\n" | |
| f"Strategy: {strategy.upper()}\n" | |
| f"{strategy_desc}" | |
| ) | |
| return fallback, strategy, "n/a" | |
| try: | |
| result = generate_response( | |
| context_sentence=context_sentence, | |
| emotion_class=emotion_class, | |
| emotion_scores=emotion_scores, | |
| strategy=strategy, | |
| category=category, | |
| api_key=api_key, | |
| ) | |
| safety_status = "safe" if result["safe"] else ", ".join(result["safety_flags"]) | |
| return result["final_response"], result["strategy"], safety_status | |
| except Exception as e: | |
| return f"Response generation failed: {e}", strategy, "error" | |
| # ββ Endpoint 4: health_check ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def health_check(): | |
| """Returns JSON status of all loaded models.""" | |
| status = { | |
| "version": "1.0.0", | |
| "device": str(DEVICE), | |
| "arch_ok": _ARCH_OK, | |
| "models": _model_status, | |
| "download_errors": _download_errors, | |
| } | |
| return json.dumps(status, indent=2) | |
| # ββ Gradio UI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _API_DOCS_MD = """ | |
| ## Calling from Deafly Health (Python) | |
| ```python | |
| from gradio_client import Client | |
| client = Client("ransoppong/ghsl-emotion-api") | |
| # 1. Predict from video | |
| affect, conf, strategy, details = client.predict( | |
| video_path="/path/to/video.mp4", | |
| api_name="/predict_video" | |
| ) | |
| # 2. Predict from 10-dim emotion scores | |
| affect, conf, node_weights = client.predict( | |
| joy=4.0, excited=2.0, surprise_pos=1.0, surprise_neg=1.0, | |
| worry=3.0, sadness=2.0, fear=2.0, disgust=1.0, | |
| frustration=2.0, anger=1.0, | |
| api_name="/predict_emotion_vec" | |
| ) | |
| # 3. Generate counselling response | |
| response, strategy, safety = client.predict( | |
| context_sentence="I have been feeling very anxious lately.", | |
| emotion_class="Negative", | |
| emotion_scores_json='{"joy":1,"excited":1,"surprise_pos":1,"surprise_neg":2,' | |
| '"worry":4,"sadness":3,"fear":3,"disgust":1,"frustration":2,"anger":1}', | |
| strategy="", # leave blank to auto-select | |
| category="Mental health interactions", | |
| openai_api_key="sk-...", | |
| api_name="/get_counselling" | |
| ) | |
| # 4. Health check | |
| status_json = client.predict(api_name="/health_check") | |
| ``` | |
| ## REST (cURL) | |
| ```bash | |
| curl -X POST https://ransoppong-ghsl-emotion-api.hf.space/run/health_check \\ | |
| -H "Content-Type: application/json" -d '{"data":[]}' | |
| ``` | |
| ## Emotion Score Scale | |
| Each of the 10 emotion dimensions uses a **1β5 scale** matching EmoSign annotations: | |
| | Score | Meaning | | |
| |-------|---------| | |
| | 1 | Not present | | |
| | 2 | Slightly present | | |
| | 3 | Moderately present | | |
| | 4 | Strongly present | | |
| | 5 | Dominantly present | | |
| Emotions: `joy`, `excited`, `surprise_pos`, `surprise_neg`, `worry`, `sadness`, `fear`, `disgust`, `frustration`, `anger` | |
| ## Strategy Meanings | |
| | Strategy | Guidance | | |
| |----------|---------| | |
| | **reflect** | Reflect the patient's experience back to show understanding | | |
| | **validate** | Validate feelings as understandable and normal | | |
| | **ground** | Use grounding techniques to help the patient stay present | | |
| | **refer** | Acknowledge concern and refer to a specialist / crisis line | | |
| """ | |
| with gr.Blocks(title="GhSL Emotion Recognition API", theme=gr.themes.Soft()) as demo: | |
| gr.Markdown(""" | |
| # GhSL Emotion Recognition API | |
| Real-time emotion recognition for Ghanaian Sign Language (GhSL) healthcare videos. | |
| Deployed models: **ST-EGN** Β· **EI-GN** Β· **RandomForest** | |
| """) | |
| with gr.Tabs(): | |
| # ββ Tab 1: Video Prediction ββββββββββββββββββββββββββββββββββββββββ | |
| with gr.TabItem("Video Prediction"): | |
| gr.Markdown(""" | |
| Upload a sign language video. The system runs MediaPipe to extract face landmarks, | |
| then classifies emotional affect using RandomForest (fast) and ST-EGN (deep). | |
| ST-EGN is used when β₯30 frames are detected. | |
| """) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| video_in = gr.Video(label="Upload Sign Language Video") | |
| predict_btn = gr.Button("Analyse Video", variant="primary") | |
| with gr.Column(scale=1): | |
| affect_out = gr.Textbox(label="Detected Affect", interactive=False) | |
| conf_out = gr.Number(label="Confidence (0β1)", interactive=False) | |
| strategy_out = gr.Textbox(label="Recommended Strategy", interactive=False) | |
| details_out = gr.Textbox(label="Model Details (JSON)", | |
| lines=8, interactive=False) | |
| predict_btn.click( | |
| fn=predict_video, | |
| inputs=[video_in], | |
| outputs=[affect_out, conf_out, strategy_out, details_out], | |
| api_name="predict_video", | |
| ) | |
| # ββ Tab 2: Emotion Score Prediction βββββββββββββββββββββββββββββββ | |
| with gr.TabItem("Emotion Scores (EI-GN)"): | |
| gr.Markdown(""" | |
| Enter 10 emotion intensity scores (1 = not present, 5 = dominant). | |
| EI-GN (Emotion Interaction Graph Network) predicts the overall affect. | |
| """) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| slider_joy = gr.Slider(1, 5, value=2, step=0.5, label="Joy") | |
| slider_exc = gr.Slider(1, 5, value=2, step=0.5, label="Excited") | |
| slider_spos = gr.Slider(1, 5, value=1, step=0.5, label="Surprise (Positive)") | |
| slider_sneg = gr.Slider(1, 5, value=1, step=0.5, label="Surprise (Negative)") | |
| slider_worry = gr.Slider(1, 5, value=2, step=0.5, label="Worry") | |
| slider_sad = gr.Slider(1, 5, value=2, step=0.5, label="Sadness") | |
| slider_fear = gr.Slider(1, 5, value=2, step=0.5, label="Fear") | |
| slider_dis = gr.Slider(1, 5, value=1, step=0.5, label="Disgust") | |
| slider_frus = gr.Slider(1, 5, value=2, step=0.5, label="Frustration") | |
| slider_ang = gr.Slider(1, 5, value=1, step=0.5, label="Anger") | |
| emo_btn = gr.Button("Predict from Scores", variant="primary") | |
| with gr.Column(scale=1): | |
| emo_affect_out = gr.Textbox(label="Predicted Affect", interactive=False) | |
| emo_conf_out = gr.Number(label="Confidence (0β1)", interactive=False) | |
| emo_weights = gr.Textbox(label="Node Attention Weights (JSON)", | |
| lines=8, interactive=False) | |
| emo_btn.click( | |
| fn=predict_emotion_vec, | |
| inputs=[slider_joy, slider_exc, slider_spos, slider_sneg, | |
| slider_worry, slider_sad, slider_fear, slider_dis, | |
| slider_frus, slider_ang], | |
| outputs=[emo_affect_out, emo_conf_out, emo_weights], | |
| api_name="predict_emotion_vec", | |
| ) | |
| # ββ Tab 3: Counselling Response ββββββββββββββββββββββββββββββββββββ | |
| with gr.TabItem("Counselling Response"): | |
| gr.Markdown(""" | |
| Generate an empathetic, strategy-aligned counselling response for a clinician | |
| to deliver to the patient. Requires an OpenAI API key (GPT-4o-mini). | |
| """) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| ctx_in = gr.Textbox( | |
| label="Patient Signing Context", | |
| placeholder="Describe what the patient signed, e.g. 'I have been feeling very anxious lately.'", | |
| lines=3, | |
| ) | |
| emo_cls_in = gr.Dropdown( | |
| choices=["Negative", "Neutral", "Positive"], | |
| value="Negative", | |
| label="Detected Emotion Class", | |
| ) | |
| emo_json_in = gr.Textbox( | |
| label="Emotion Scores JSON (optional)", | |
| placeholder='{"joy":1,"worry":4,"sadness":3,"fear":3,...}', | |
| lines=2, | |
| ) | |
| strategy_in = gr.Dropdown( | |
| choices=["", "reflect", "validate", "ground", "refer"], | |
| value="", | |
| label="Strategy (leave blank to auto-select)", | |
| ) | |
| cat_in = gr.Textbox( | |
| label="Healthcare Category", | |
| placeholder="e.g. Mental health interactions", | |
| value="General healthcare", | |
| ) | |
| api_key_in = gr.Textbox( | |
| label="OpenAI API Key", | |
| placeholder="sk-... (or set OPENAI_API_KEY env var)", | |
| type="password", | |
| ) | |
| counsel_btn = gr.Button("Generate Response", variant="primary") | |
| with gr.Column(scale=1): | |
| response_out = gr.Textbox(label="Counselling Response", | |
| lines=6, interactive=False) | |
| strategy_used = gr.Textbox(label="Strategy Applied", interactive=False) | |
| safety_out = gr.Textbox(label="Safety Status", interactive=False) | |
| counsel_btn.click( | |
| fn=get_counselling, | |
| inputs=[ctx_in, emo_cls_in, emo_json_in, strategy_in, cat_in, api_key_in], | |
| outputs=[response_out, strategy_used, safety_out], | |
| api_name="get_counselling", | |
| ) | |
| # ββ Tab 4: API Docs ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.TabItem("API Docs"): | |
| gr.Markdown(_API_DOCS_MD) | |
| with gr.Row(): | |
| hc_btn = gr.Button("Run Health Check", variant="secondary") | |
| hc_output = gr.Textbox(label="System Status (JSON)", | |
| lines=10, interactive=False) | |
| hc_btn.click( | |
| fn=health_check, | |
| inputs=[], | |
| outputs=[hc_output], | |
| api_name="health_check", | |
| ) | |
| gr.Markdown(""" | |
| --- | |
| *GhSL-EmoCare Β· EmoSign + SignTalk-GH datasets Β· MediaPipe + PyTorch + Gradio* | |
| Model repo: [ransoppong/ghsl-emosign-models](https://huggingface.co/ransoppong/ghsl-emosign-models) | |
| """) | |
| if __name__ == "__main__": | |
| demo.launch() | |