ransoppong's picture
Switch to MediaPipe Solutions API (no EGL needed)
64069f8 verified
Raw
History Blame Contribute Delete
33.6 kB
"""
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()