signflow2 / server /app.py
LouisVasseur's picture
Use checkpoint source_code
5ad0a97
Raw
History Blame Contribute Delete
23.5 kB
#!/usr/bin/env python3
"""
SignFlow — STGCN2D Sign Language Recognition Server (HF Spaces deployable)
Self-contained: vendors the entire fall_2025 pipeline inline.
- MediaPipe Holistic extraction with hand correction
- Shoulder-centered pose normalization
- Standard ST-GCN architecture (K=3 spatial partitioning, 10 layers)
- 51 nodes: 17 pose + 17 left hand + 17 right hand (no pinky)
- Channels: auto-detected from checkpoint
"""
import os
import sys
import base64
import threading
from pathlib import Path
from typing import Dict, List, Optional
from collections import deque
from dataclasses import dataclass, field
import cv2
import numpy as np
import torch
import torch.nn as nn
import pandas as pd
from flask import Flask, request, send_from_directory, jsonify
from flask_cors import CORS
from flask_socketio import SocketIO, emit
# =============================================================================
# Landmark constants — subset used by the model (drops pinky to get 17)
# =============================================================================
POSE_LANDMARKS = list(range(17)) # MediaPipe pose: 0..16 (nose, eyes, ears, shoulders, elbows, wrists)
HAND_LANDMARKS = list(range(17)) # MediaPipe hand: 0..16 (wrist + thumb + index + middle + ring; drops pinky 17-20)
NUM_NODES = len(POSE_LANDMARKS) + 2 * len(HAND_LANDMARKS) # 51
POSE_CONNECTIONS = [
(0,1),(1,2),(2,3),(3,7),(0,4),(4,5),(5,6),(6,8),
(9,10),(11,12),(11,13),(13,15),(12,14),(14,16),
(0,9),(0,10),(0,11),(0,12)
]
HAND_CONNECTIONS = [
(0,1),(1,2),(2,3),(3,4),(0,5),(5,6),(6,7),(7,8),
(0,9),(9,10),(10,11),(11,12),(0,13),(13,14),(14,15),(15,16),
(5,9),(9,13)
]
LEFT_WRIST_POSE_IDX = 15
RIGHT_WRIST_POSE_IDX = 16
# =============================================================================
# Adjacency matrix construction (ST-GCN spatial partitioning, K=3)
# =============================================================================
def build_adjacency() -> np.ndarray:
"""Build (3, V, V) adjacency for ST-GCN spatial partitioning."""
V = NUM_NODES
A = np.zeros((V, V), dtype=np.float32)
# Self-loops
for i in range(V):
A[i, i] = 1.0
# Pose connections (nodes 0..16)
for s, e in POSE_CONNECTIONS:
if s < len(POSE_LANDMARKS) and e < len(POSE_LANDMARKS):
A[s, e] = A[e, s] = 1.0
# Left hand (nodes 17..33), right hand (nodes 34..50)
for offset in [len(POSE_LANDMARKS), len(POSE_LANDMARKS) + len(HAND_LANDMARKS)]:
for s, e in HAND_CONNECTIONS:
if s < len(HAND_LANDMARKS) and e < len(HAND_LANDMARKS):
A[offset + s, offset + e] = A[offset + e, offset + s] = 1.0
# Attach hand wrists to pose wrists
left_offset = len(POSE_LANDMARKS)
right_offset = len(POSE_LANDMARKS) + len(HAND_LANDMARKS)
A[LEFT_WRIST_POSE_IDX, left_offset] = A[left_offset, LEFT_WRIST_POSE_IDX] = 1.0
A[RIGHT_WRIST_POSE_IDX, right_offset] = A[right_offset, RIGHT_WRIST_POSE_IDX] = 1.0
# Spatial partitioning: K=3 (self, inward, outward) using distance from center
# Center node = root (we use node 0, the nose)
center = 0
# BFS distances from center
dist = np.full(V, -1, dtype=np.int32)
dist[center] = 0
frontier = [center]
while frontier:
nxt = []
for u in frontier:
for v in range(V):
if A[u, v] > 0 and dist[v] == -1:
dist[v] = dist[u] + 1
nxt.append(v)
frontier = nxt
# Any disconnected nodes get max distance
dist[dist == -1] = dist.max() + 1
A_self = np.zeros((V, V), dtype=np.float32)
A_in = np.zeros((V, V), dtype=np.float32)
A_out = np.zeros((V, V), dtype=np.float32)
for i in range(V):
for j in range(V):
if A[i, j] > 0:
if dist[j] == dist[i]:
A_self[i, j] = 1.0
elif dist[j] > dist[i]:
A_out[i, j] = 1.0
else:
A_in[i, j] = 1.0
# Normalize each partition
def normalize(M):
D = M.sum(axis=0)
D[D == 0] = 1.0
return M / D[None, :]
A_stack = np.stack([normalize(A_self), normalize(A_in), normalize(A_out)], axis=0)
return A_stack # (3, V, V)
# =============================================================================
# ST-GCN model (matches the fall_2025/CV/src/models/stgcn_2d.py architecture)
# =============================================================================
class ConvTemporalGraphical(nn.Module):
def __init__(self, in_ch, out_ch, kernel_size, t_kernel=1, t_stride=1, t_pad=0, t_dil=1, bias=True):
super().__init__()
self.kernel_size = kernel_size # K (spatial partitions)
self.conv = nn.Conv2d(in_ch, out_ch * kernel_size, (t_kernel, 1), (t_stride, 1), (t_pad, 0), (t_dil, 1), bias=bias)
def forward(self, x, A):
x = self.conv(x)
n, kc, t, v = x.size()
x = x.view(n, self.kernel_size, kc // self.kernel_size, t, v)
x = torch.einsum("nkctv,kvw->nctw", x, A).contiguous()
return x, A
class STGCNLayer(nn.Module):
def __init__(self, in_ch, out_ch, kernel_size, stride=1, dropout=0, residual=True):
super().__init__()
padding = ((kernel_size[0] - 1) // 2, 0)
self.gcn = ConvTemporalGraphical(in_ch, out_ch, kernel_size[1])
self.tcn = nn.Sequential(
nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True),
nn.Conv2d(out_ch, out_ch, (kernel_size[0], 1), (stride, 1), padding),
nn.BatchNorm2d(out_ch), nn.Dropout(dropout, inplace=True),
)
if not residual:
self.residual = lambda x: 0
elif in_ch == out_ch and stride == 1:
self.residual = lambda x: x
else:
self.residual = nn.Sequential(nn.Conv2d(in_ch, out_ch, 1, (stride, 1)), nn.BatchNorm2d(out_ch))
self.relu = nn.ReLU(inplace=True)
def forward(self, x, A):
res = self.residual(x)
x, A = self.gcn(x, A)
return self.relu(self.tcn(x) + res), A
class STGCN2D(nn.Module):
def __init__(self, in_channels: int, num_class: int, graph_args=None, edge_importance: bool = True, dropout: float = 0, edge_importance_weighting=None):
super().__init__()
if edge_importance_weighting is not None: edge_importance = edge_importance_weighting
A = build_adjacency() # (3, V, V)
self.register_buffer("A", torch.tensor(A, dtype=torch.float32))
K_spatial = A.shape[0] # 3
V = A.shape[1] # 51
kernel = (9, K_spatial)
self.data_bn = nn.BatchNorm1d(in_channels * V)
self.st_gcn_networks = nn.ModuleList([
STGCNLayer(in_channels, 64, kernel, 1, residual=False, dropout=dropout),
STGCNLayer(64, 64, kernel, 1, dropout=dropout),
STGCNLayer(64, 64, kernel, 1, dropout=dropout),
STGCNLayer(64, 64, kernel, 1, dropout=dropout),
STGCNLayer(64, 128, kernel, 2, dropout=dropout),
STGCNLayer(128, 128, kernel, 1, dropout=dropout),
STGCNLayer(128, 128, kernel, 1, dropout=dropout),
STGCNLayer(128, 256, kernel, 2, dropout=dropout),
STGCNLayer(256, 256, kernel, 1, dropout=dropout),
STGCNLayer(256, 256, kernel, 1, dropout=dropout),
])
if edge_importance:
self.edge_importance = nn.ParameterList([nn.Parameter(torch.ones(A.shape)) for _ in self.st_gcn_networks])
else:
self.edge_importance = [1] * len(self.st_gcn_networks)
self.fcn = nn.Conv2d(256, num_class, 1)
def forward(self, x):
# x: (N, C, T, V)
N, C, T, V = x.size()
# data_bn over (C*V) dims
x = x.permute(0, 3, 1, 2).contiguous().view(N, V * C, T)
x = self.data_bn(x)
x = x.view(N, V, C, T).permute(0, 2, 3, 1).contiguous() # back to (N, C, T, V)
for gcn, importance in zip(self.st_gcn_networks, self.edge_importance):
x, _ = gcn(x, self.A * importance)
# Global pooling over T, V
x = nn.functional.adaptive_avg_pool2d(x, 1)
x = self.fcn(x)
x = x.view(N, -1)
return x
# =============================================================================
# MediaPipe extraction with hand correction + shoulder-centered normalization
# =============================================================================
class MediaPipeExtractor:
def __init__(self):
from mediapipe import solutions as mp_solutions
self.mp_holistic = mp_solutions.holistic
self.holistic = self.mp_holistic.Holistic(
static_image_mode=False,
model_complexity=1,
min_detection_confidence=0.5,
min_tracking_confidence=0.5,
)
self._lock = threading.Lock()
print("✓ MediaPipe Holistic initialized")
def extract(self, image: np.ndarray) -> Optional[Dict]:
"""Return shoulder-centered landmarks as dict of lists of (x, y, z, vis)."""
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
with self._lock:
results = self.holistic.process(rgb)
if not results.pose_landmarks:
return None
pose_lms = results.pose_landmarks.landmark
# Shoulder-center: midpoint of L/R shoulders (indices 11, 12)
left_sh = pose_lms[11]
right_sh = pose_lms[12]
cx = (left_sh.x + right_sh.x) / 2
cy = (left_sh.y + right_sh.y) / 2
cz = (left_sh.z + right_sh.z) / 2
# Scale: shoulder width
scale = max(abs(left_sh.x - right_sh.x), 1e-3)
unified = {"pose": [], "left_hand": [], "right_hand": []}
# Pose (subset POSE_LANDMARKS, shoulder-centered, scaled)
for i in POSE_LANDMARKS:
lm = pose_lms[i]
unified["pose"].append({
"x": (lm.x - cx) / scale,
"y": (lm.y - cy) / scale,
"z": (lm.z - cz) / scale,
"visibility": lm.visibility,
})
# Wrists in shoulder-centered space (for hand anchoring)
left_wrist_sc = unified["pose"][LEFT_WRIST_POSE_IDX]
right_wrist_sc = unified["pose"][RIGHT_WRIST_POSE_IDX]
# LEFT hand: anchor to left wrist
if results.left_hand_landmarks:
lhms = results.left_hand_landmarks.landmark
hw = lhms[0]
for i in HAND_LANDMARKS:
lm = lhms[i]
unified["left_hand"].append({
"x": left_wrist_sc["x"] + (lm.x - hw.x) / scale,
"y": left_wrist_sc["y"] + (lm.y - hw.y) / scale,
"z": left_wrist_sc["z"] + (lm.z - hw.z) / scale,
"visibility": 1.0,
})
else:
for _ in HAND_LANDMARKS:
unified["left_hand"].append({"x": 0, "y": 0, "z": 0, "visibility": 0})
# RIGHT hand: anchor to right wrist
if results.right_hand_landmarks:
rhms = results.right_hand_landmarks.landmark
hw = rhms[0]
for i in HAND_LANDMARKS:
lm = rhms[i]
unified["right_hand"].append({
"x": right_wrist_sc["x"] + (lm.x - hw.x) / scale,
"y": right_wrist_sc["y"] + (lm.y - hw.y) / scale,
"z": right_wrist_sc["z"] + (lm.z - hw.z) / scale,
"visibility": 1.0,
})
else:
for _ in HAND_LANDMARKS:
unified["right_hand"].append({"x": 0, "y": 0, "z": 0, "visibility": 0})
unified["_has_left_hand"] = results.left_hand_landmarks is not None
unified["_has_right_hand"] = results.right_hand_landmarks is not None
return unified
def frames_to_tensor(frames: List[Dict], in_channels: int) -> torch.Tensor:
"""Convert a list of unified-landmark frames to (1, C, T, V) tensor."""
T = len(frames)
V = NUM_NODES
arr = np.zeros((T, V, in_channels), dtype=np.float32)
for t, f in enumerate(frames):
row = 0
for part in ["pose", "left_hand", "right_hand"]:
for lm in f[part]:
if in_channels >= 1: arr[t, row, 0] = lm["x"]
if in_channels >= 2: arr[t, row, 1] = lm["y"]
if in_channels >= 3: arr[t, row, 2] = lm["z"]
if in_channels >= 4: arr[t, row, 3] = lm["visibility"]
row += 1
# Reshape to (1, C, T, V)
tensor = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0)
return tensor
# =============================================================================
# Configuration
# =============================================================================
MODEL_PATH = os.environ.get("MODEL_PATH", "best_model.pth")
GLOSS_MAP_PATH = os.environ.get("GLOSS_MAP_PATH", "gloss_map.csv")
BUFFER_SIZE = 48
CONFIDENCE_THRESHOLD = 0.0
TOP_K = 5
# =============================================================================
# Globals
# =============================================================================
app = Flask(__name__, static_folder='dist', static_url_path='')
CORS(app)
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model: Optional[STGCN2D] = None
extractor: Optional[MediaPipeExtractor] = None
gloss_map: Dict[int, str] = {}
IN_CHANNELS: int = 2 # auto-detected from checkpoint
@dataclass
class Session:
sid: str
frame_buffer: deque = field(default_factory=lambda: deque(maxlen=BUFFER_SIZE))
last_prediction: Optional[List] = None
processing: bool = False
sessions: Dict[str, Session] = {}
# =============================================================================
# Model loading with auto-detected channel count
# =============================================================================
def load_model() -> bool:
global model, extractor, gloss_map, IN_CHANNELS
# Gloss map
if os.path.exists(GLOSS_MAP_PATH):
df = pd.read_csv(GLOSS_MAP_PATH)
col = 'gloss' if 'gloss' in df.columns else ('label' if 'label' in df.columns else df.columns[-1])
gloss_map = {i: str(g) for i, g in enumerate(df[col].tolist())}
print(f"✓ Loaded {len(gloss_map)} glosses from column '{col}'")
else:
gloss_map = {i: f"SIGN_{i}" for i in range(208)}
print("⚠ No gloss map, using defaults")
if not os.path.exists(MODEL_PATH):
print(f"✗ Model not found: {MODEL_PATH}")
return False
ckpt = torch.load(MODEL_PATH, map_location=device, weights_only=False)
# New checkpoint format: {"config": ..., "state": ..., "source_code": ..., "meta": ...}
if isinstance(ckpt, dict) and "state" in ckpt:
state = ckpt["state"]
ckpt_config = ckpt.get("config", {})
source_code = ckpt.get("source_code", "")
meta = ckpt.get("meta", {})
print(f"✓ New checkpoint format. Meta: epoch={meta.get('epoch','?')}, val_acc={meta.get('val_accuracy','?')}")
else:
state = ckpt
ckpt_config = {}
source_code = ""
# Strip prefixes
state = {k.replace('module.', '').replace('_orig_mod.', ''): v for k, v in state.items()}
# Get config params
model_cfg = ckpt_config.get("model", {})
data_cfg = ckpt_config.get("data", {})
IN_CHANNELS = model_cfg.get("num_features", 4)
num_classes = model_cfg.get("num_classes", len(gloss_map))
edge_importance = model_cfg.get("edge_importance_weighting", True)
dropout = model_cfg.get("dropout", 0)
body_parts = data_cfg.get("body_parts", ["pose", "left_hand", "right_hand"])
print(f"✓ Config: in_channels={IN_CHANNELS}, num_classes={num_classes}, body_parts={body_parts}")
global model
used_source = False
# Try to use the source_code from checkpoint for exact architecture match
if source_code:
try:
tmp_path = "/tmp/_ckpt_model.py"
with open(tmp_path, "w") as f:
f.write(source_code)
import importlib.util
spec = importlib.util.spec_from_file_location("_ckpt_model", tmp_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
# Find the Model class
ModelCls = getattr(mod, "Model", None) or getattr(mod, "STGCN2D", None) or getattr(mod, "STGCN", None)
if ModelCls is None:
# Find any nn.Module subclass at the top level
for name in dir(mod):
obj = getattr(mod, name)
if isinstance(obj, type) and issubclass(obj, nn.Module) and obj is not nn.Module:
ModelCls = obj
break
if ModelCls is not None:
graph_args = model_cfg.get("graph_args", {
"body_parts": body_parts,
"strategy": model_cfg.get("graph_strategy", "spatial"),
"max_hop": model_cfg.get("max_hop", 1),
})
# Try common constructor signatures
try:
model = ModelCls(
in_channels=IN_CHANNELS,
num_class=num_classes,
graph_args=graph_args,
edge_importance_weighting=edge_importance,
dropout=dropout,
)
except TypeError:
try:
model = ModelCls(
in_channels=IN_CHANNELS,
num_class=num_classes,
graph_args=graph_args,
edge_importance=edge_importance,
dropout=dropout,
)
except TypeError:
model = ModelCls(IN_CHANNELS, num_classes, graph_args)
used_source = True
print(f"✓ Built model from checkpoint source_code: {ModelCls.__name__}")
except Exception as e:
print(f"⚠ Could not use source_code: {e}")
import traceback; traceback.print_exc()
# Fallback to vendored STGCN2D
if not used_source:
print("⚠ Falling back to vendored STGCN2D")
model = STGCN2D(in_channels=IN_CHANNELS, num_class=num_classes, edge_importance=edge_importance, dropout=dropout)
missing, unexpected = model.load_state_dict(state, strict=False)
print(f"⚠ Missing keys: {len(missing)}, Unexpected: {len(unexpected)}")
if missing[:3]:
print(f" First missing: {missing[:3]}")
if unexpected[:3]:
print(f" First unexpected: {unexpected[:3]}")
model.to(device).eval()
print(f"✓ Model on {device}")
extractor = MediaPipeExtractor()
return True
# =============================================================================
# Inference
# =============================================================================
def run_inference(frames: List[Dict]) -> List[Dict]:
try:
tensor = frames_to_tensor(frames, IN_CHANNELS).to(device)
with torch.no_grad():
logits = model(tensor)
probs = torch.softmax(logits, dim=-1)[0]
top_probs, top_idx = torch.topk(probs, TOP_K)
results = []
for p, i in zip(top_probs.cpu().numpy(), top_idx.cpu().numpy()):
if p >= CONFIDENCE_THRESHOLD:
results.append({
'gloss': gloss_map.get(int(i), f"SIGN_{i}"),
'confidence': float(p),
'index': int(i),
})
if results:
print(f"[INFER] top={results[0]['gloss']} conf={results[0]['confidence']:.3f}", flush=True)
return results
except Exception as e:
print(f"[INFER ERROR] {e}", flush=True)
import traceback; traceback.print_exc()
return []
# =============================================================================
# Frame processing
# =============================================================================
def process_frame(session: Session, frame_data: str) -> Optional[Dict]:
try:
img_bytes = base64.b64decode(frame_data.split(',')[-1])
arr = np.frombuffer(img_bytes, dtype=np.uint8)
frame = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if frame is None:
return None
landmarks = extractor.extract(frame)
if landmarks is None:
return {
'status': 'no_person',
'predictions': [],
'buffer_fill': len(session.frame_buffer) / BUFFER_SIZE,
'has_hands': False,
}
session.frame_buffer.append(landmarks)
has_hands = landmarks.get('_has_left_hand') or landmarks.get('_has_right_hand')
if len(session.frame_buffer) >= BUFFER_SIZE and not session.processing:
session.processing = True
try:
preds = run_inference(list(session.frame_buffer))
session.last_prediction = preds
return {
'status': 'prediction',
'predictions': preds,
'has_hands': has_hands,
'buffer_fill': 1.0,
}
finally:
session.processing = False
return {
'status': 'buffering',
'buffer_fill': len(session.frame_buffer) / BUFFER_SIZE,
'has_hands': has_hands,
'predictions': session.last_prediction or [],
}
except Exception as e:
print(f"[PROCESS ERROR] {e}", flush=True)
return None
# =============================================================================
# Socket.IO + HTTP routes
# =============================================================================
@socketio.on('connect')
def on_connect():
sid = request.sid
sessions[sid] = Session(sid=sid)
print(f"Client connected: {sid[:8]}", flush=True)
emit('status', {
'connected': True,
'model_loaded': model is not None,
'num_glosses': len(gloss_map),
})
@socketio.on('disconnect')
def on_disconnect():
sid = request.sid
sessions.pop(sid, None)
@socketio.on('frame')
def on_frame(data):
sid = request.sid
if sid not in sessions:
return
result = process_frame(sessions[sid], data.get('image', ''))
if result:
emit('prediction', result)
@socketio.on('reset')
def on_reset():
sid = request.sid
if sid in sessions:
sessions[sid].frame_buffer.clear()
sessions[sid].last_prediction = None
@app.route('/')
def index():
return send_from_directory(app.static_folder, 'index.html')
@app.route('/health')
def health():
return jsonify({'ok': model is not None, 'glosses': len(gloss_map), 'channels': IN_CHANNELS})
# =============================================================================
# Main
# =============================================================================
if __name__ == '__main__':
print("=" * 60)
print(" SignFlow STGCN2D Server")
print("=" * 60)
if not load_model():
sys.exit(1)
port = int(os.environ.get('PORT', 7860))
print(f"🚀 Listening on 0.0.0.0:{port}", flush=True)
socketio.run(app, host='0.0.0.0', port=port, debug=False, allow_unsafe_werkzeug=True)