Santos-Loc commited on
Commit
a9474cf
Β·
verified Β·
1 Parent(s): e95ed55

Initial Commit

Browse files
Files changed (4) hide show
  1. Dockerfile +8 -0
  2. main.py +193 -0
  3. models/best_kfold_fusion.pt +3 -0
  4. requirements.txt +14 -0
Dockerfile ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+ WORKDIR /app
3
+ RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
4
+ COPY requirements.txt .
5
+ RUN pip install --no-cache-dir -r requirements.txt
6
+ COPY . .
7
+ EXPOSE 7860
8
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
main.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NeuroSense Backend β€” FastAPI (Optimized for Semantically-Guided Fusion)
3
+ """
4
+
5
+ import os, uuid, shutil, subprocess, asyncio, logging, time, tempfile
6
+ from pathlib import Path
7
+ from concurrent.futures import ThreadPoolExecutor
8
+
9
+ import torch
10
+ import torch.nn as nn
11
+
12
+ from fastapi import FastAPI, File, UploadFile, Request
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import JSONResponse
15
+
16
+ import whisper
17
+ from transformers import AutoTokenizer, AutoModel, WavLMModel, AutoFeatureExtractor
18
+ from groq import AsyncGroq, AuthenticationError, RateLimitError, APIError
19
+ import librosa
20
+
21
+ # ─── LOGGING SETUP ────────────────────────────────────────────────────────────
22
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-8s %(message)s", datefmt="%H:%M:%S")
23
+ log = logging.getLogger("neurosense")
24
+
25
+ # ─── CONFIG ───────────────────────────────────────────────────────────────────
26
+ MODELS_DIR = Path("models")
27
+ PT_MODEL_PATH = MODELS_DIR / "best_kfold_fusion.pt" # <--- Updated to your new model name
28
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
29
+ TMPDIR = tempfile.gettempdir()
30
+
31
+ log.info(f"Target Hardware: {DEVICE}")
32
+
33
+ cpu_pool = ThreadPoolExecutor(max_workers=2)
34
+
35
+ app = FastAPI(title="NeuroSense API")
36
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
37
+
38
+ @app.middleware("http")
39
+ async def log_requests(request: Request, call_next):
40
+ start = time.time()
41
+ response = await call_next(request)
42
+ elapsed = (time.time() - start) * 1000
43
+ log.info(f"[{response.status_code}] {request.method} {request.url.path} ({elapsed:.0f}ms)")
44
+ return response
45
+
46
+ # ─── LOAD BACKBONES ───────────────────────────────────────────────────────────
47
+ log.info("Loading Frozen Feature Extractors...")
48
+ whisper_model = whisper.load_model("base")
49
+ bert_tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
50
+ bert_model = AutoModel.from_pretrained("bert-base-uncased").eval().to(DEVICE)
51
+ wavlm_extractor = AutoFeatureExtractor.from_pretrained("microsoft/wavlm-base")
52
+ wavlm_model = WavLMModel.from_pretrained("microsoft/wavlm-base").eval().to(DEVICE)
53
+
54
+ # ─── NEUROSENSE ARCHITECTURE ──────────────────────────────────────────────────
55
+ class SemanticallyGuidedBottleneckFusion(nn.Module):
56
+ def __init__(self, text_dim=768, audio_dim=768, visual_dim=68, bottleneck_dim=16, dropout_rate=0.7):
57
+ super().__init__()
58
+ self.text_projection = nn.Sequential(nn.Linear(text_dim, bottleneck_dim), nn.LayerNorm(bottleneck_dim), nn.Dropout(dropout_rate))
59
+ self.audio_projection = nn.Sequential(nn.Linear(audio_dim, bottleneck_dim), nn.LayerNorm(bottleneck_dim), nn.Dropout(dropout_rate))
60
+ self.visual_projection = nn.Sequential(nn.Linear(visual_dim, bottleneck_dim), nn.LayerNorm(bottleneck_dim), nn.Dropout(dropout_rate))
61
+
62
+ self.audio_cross_attention = nn.MultiheadAttention(embed_dim=bottleneck_dim, num_heads=1, dropout=dropout_rate, batch_first=True)
63
+ self.visual_cross_attention = nn.MultiheadAttention(embed_dim=bottleneck_dim, num_heads=1, dropout=dropout_rate, batch_first=True)
64
+
65
+ self.classifier = nn.Linear(bottleneck_dim * 3, 1)
66
+
67
+ def forward(self, text, audio, visual):
68
+ t_proj = self.text_projection(text)
69
+ a_proj = self.audio_projection(audio)
70
+ v_proj = self.visual_projection(visual)
71
+
72
+ t_seq = t_proj.unsqueeze(1)
73
+ a_seq = a_proj.unsqueeze(1)
74
+ v_seq = v_proj.unsqueeze(1)
75
+
76
+ attended_audio, _ = self.audio_cross_attention(query=t_seq, key=a_seq, value=a_seq)
77
+ attended_visual, _ = self.visual_cross_attention(query=t_seq, key=v_seq, value=v_seq)
78
+
79
+ fused_representation = torch.cat([t_proj, attended_audio.squeeze(1), attended_visual.squeeze(1)], dim=1)
80
+ return self.classifier(fused_representation)
81
+
82
+ ns_model = SemanticallyGuidedBottleneckFusion().to(DEVICE)
83
+ if PT_MODEL_PATH.exists():
84
+ checkpoint = torch.load(PT_MODEL_PATH, map_location=DEVICE, weights_only=True)
85
+ state_dict = checkpoint['model_state_dict'] if 'model_state_dict' in checkpoint else checkpoint
86
+ ns_model.load_state_dict(state_dict)
87
+ log.info("βœ… NeuroSense K-Fold Model Loaded Successfully.")
88
+ else:
89
+ log.error(f"❌ Could not find {PT_MODEL_PATH}. Inference will fail.")
90
+ ns_model.eval()
91
+
92
+ # ─── GROQ SETUP ────────────────────────────────────────────────────��──────────
93
+ _groq_key = os.environ.get("GROQ_API_KEY", "")
94
+ groq_client = AsyncGroq(api_key=_groq_key) if _groq_key else None
95
+ sessions = {}
96
+ LUMI_SYSTEM = "You are Lumi, a warm, empathetic mental wellness companion conducting a structured clinical screening interview based on the DAIC-WOZ protocol..." # (Truncated for brevity, keep your original string here)
97
+
98
+ # ─── UTILITIES ────────────────────────────────────────────────────────────────
99
+ def tmpfile(sid: str, suffix: str) -> str: return os.path.join(TMPDIR, f"ns_{sid}{suffix}")
100
+
101
+ def to_wav(input_path: str, output_path: str):
102
+ subprocess.run(["ffmpeg", "-y", "-i", input_path, "-ar", "16000", "-ac", "1", output_path], capture_output=True)
103
+
104
+ # ─── FEATURE EXTRACTION ───────────────────────────────────────────────────────
105
+ def _transcribe_wav(wav_path: str) -> str:
106
+ return whisper_model.transcribe(wav_path)["text"].strip()
107
+
108
+ def _extract_text_features(text: str) -> torch.Tensor:
109
+ inputs = bert_tokenizer(text, return_tensors="pt", truncation=True, max_length=512, padding=True).to(DEVICE)
110
+ with torch.no_grad():
111
+ out = bert_model(**inputs)
112
+ return out.last_hidden_state[:, 0, :] # Shape: [1, 768]
113
+
114
+ def _extract_wavlm_features(wav_path: str) -> torch.Tensor:
115
+ audio, _ = librosa.load(wav_path, sr=16000)
116
+ inputs = wavlm_extractor(audio, sampling_rate=16000, return_tensors="pt").to(DEVICE)
117
+ with torch.no_grad():
118
+ out = wavlm_model(**inputs)
119
+ return out.last_hidden_state.mean(dim=1) # Shape: [1, 768]
120
+
121
+ def _extract_vision_features() -> torch.Tensor:
122
+ # Modality Imputation: We return a neutral 68-dim tensor since OpenFace C++ binaries aren't running
123
+ return torch.zeros((1, 68)).to(DEVICE)
124
+
125
+ # ─── ENDPOINTS ────────────────────────────────────────────────────────────────
126
+ @app.get("/health")
127
+ def health(): return {"status": "NeuroSense backend is live", "models_ready": True}
128
+
129
+ @app.post("/transcribe")
130
+ async def transcribe(audio: UploadFile = File(...)):
131
+ sid = uuid.uuid4().hex[:8]
132
+ raw_path, wav_path = tmpfile(sid, "_raw"), tmpfile(sid, ".wav")
133
+ try:
134
+ with open(raw_path, "wb") as f: shutil.copyfileobj(audio.file, f)
135
+ await asyncio.to_thread(to_wav, raw_path, wav_path)
136
+ return {"transcript": await asyncio.to_thread(_transcribe_wav, wav_path)}
137
+ finally:
138
+ for p in (raw_path, wav_path):
139
+ if os.path.exists(p): os.remove(p)
140
+
141
+ @app.post("/chat")
142
+ async def chat(request: dict):
143
+ # (Keep your existing Groq logic here exactly as it was, it works perfectly)
144
+ session_id, user_msg = request.get("session_id", "default"), request.get("message", "")
145
+ if session_id not in sessions: sessions[session_id] = []
146
+ sessions[session_id].append({"role": "user", "content": user_msg})
147
+
148
+ response = await groq_client.chat.completions.create(
149
+ model="llama-3.1-8b-instant", max_tokens=300,
150
+ messages=[{"role": "system", "content": LUMI_SYSTEM}] + sessions[session_id],
151
+ )
152
+ assistant_msg = response.choices[0].message.content
153
+ is_complete = "[INTERVIEW_COMPLETE]" in assistant_msg
154
+ sessions[session_id].append({"role": "assistant", "content": assistant_msg})
155
+ return {"message": assistant_msg, "interview_complete": is_complete}
156
+
157
+ @app.post("/analyze")
158
+ async def analyze(audio: UploadFile = File(...), video: UploadFile = File(None), transcript: UploadFile = File(None)):
159
+ sid = uuid.uuid4().hex[:8]
160
+ raw_audio, wav_path = tmpfile(sid, "_audio_raw"), tmpfile(sid, ".wav")
161
+
162
+ try:
163
+ with open(raw_audio, "wb") as f: shutil.copyfileobj(audio.file, f)
164
+ await asyncio.to_thread(to_wav, raw_audio, wav_path)
165
+ transcribed_text = await asyncio.to_thread(_transcribe_wav, wav_path)
166
+
167
+ # 1. Feature Extraction Pipeline
168
+ text_feat, wavlm_feat = await asyncio.gather(
169
+ asyncio.to_thread(_extract_text_features, transcribed_text),
170
+ asyncio.to_thread(_extract_wavlm_features, wav_path),
171
+ )
172
+ visual_feat = _extract_vision_features()
173
+
174
+ # 2. Forward Pass
175
+ with torch.no_grad():
176
+ logits = ns_model(text_feat, wavlm_feat, visual_feat)
177
+ prob = torch.sigmoid(logits).item()
178
+
179
+ # 3. Apply the Optimized Clinical Threshold
180
+ clinical_threshold = 0.45
181
+ prediction = 1 if prob >= clinical_threshold else 0
182
+
183
+ # Returning logits as 'confidence' perfectly syncs with your React App's sigmoid gauge
184
+ return {
185
+ "prediction": prediction,
186
+ "label": "High Likelihood" if prediction == 1 else "Low Likelihood",
187
+ "confidence": logits.item(),
188
+ "transcript_text": transcribed_text
189
+ }
190
+
191
+ finally:
192
+ for p in (raw_audio, wav_path):
193
+ if os.path.exists(p): os.remove(p)
models/best_kfold_fusion.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c5eb720270e09f99d0cb1c933fb3248b0aad5529ca41d2e3b1c572b9ad3a031e
3
+ size 120825
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn[standard]==0.29.0
3
+ python-multipart==0.0.9
4
+ torch>=2.0.0
5
+ transformers>=4.40.0
6
+ openai-whisper>=20231117
7
+ librosa>=0.10.0
8
+ opensmile>=2.5.0
9
+ scikit-learn>=1.4.0
10
+ joblib>=1.3.0
11
+ opencv-python>=4.9.0
12
+ torchvision>=0.17.0
13
+ groq>=0.9.0
14
+ numpy>=1.26.0