Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
@@ -1,9 +1,9 @@
|
|
1 |
-
# app.py β Multi-Modal Emotion AI (Text β’ Voice β’ Face)
|
2 |
-
#
|
3 |
-
#
|
4 |
-
#
|
5 |
|
6 |
-
import os, json, datetime
|
7 |
from typing import Dict, List, Optional, Tuple
|
8 |
|
9 |
import numpy as np
|
@@ -17,10 +17,10 @@ from fpdf import FPDF
|
|
17 |
from transformers import pipeline
|
18 |
|
19 |
# =========================
|
20 |
-
#
|
21 |
# =========================
|
22 |
APP_NAME = "Multi-Modal Emotion AI"
|
23 |
-
APP_VERSION = "
|
24 |
RUN_LOG = "runs.csv"
|
25 |
CHARTS_DIR = "charts"
|
26 |
os.makedirs(CHARTS_DIR, exist_ok=True)
|
@@ -29,12 +29,13 @@ if not os.path.exists(RUN_LOG):
|
|
29 |
"timestamp","text","text_top","voice_top","face_top","fused_top","pos_index"
|
30 |
]).to_csv(RUN_LOG, index=False)
|
31 |
|
32 |
-
#
|
|
|
|
|
33 |
TEXT_MODEL = "SamLowe/roberta-base-go_emotions" # text emotions (GoEmotions, 27)
|
34 |
VOICE_MODEL = "superb/wav2vec2-base-superb-er" # voice emotion recognition
|
35 |
FACE_MODEL = "trpakov/vit-face-expression" # facial expression (ViT)
|
36 |
|
37 |
-
# Pipelines (cached once)
|
38 |
text_pipe = pipeline("text-classification", model=TEXT_MODEL, top_k=None)
|
39 |
voice_pipe = pipeline("audio-classification", model=VOICE_MODEL, top_k=None)
|
40 |
face_pipe = pipeline("image-classification", model=FACE_MODEL, top_k=None)
|
@@ -141,7 +142,7 @@ def save_chart(prob: Dict[str,float], title: str, path: str):
|
|
141 |
plt.close(fig)
|
142 |
|
143 |
# =========================
|
144 |
-
# Computer vision: face crop
|
145 |
# =========================
|
146 |
HAAR = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
|
147 |
def crop_face(image_path: str) -> Image.Image:
|
@@ -158,24 +159,41 @@ def crop_face(image_path: str) -> Image.Image:
|
|
158 |
except Exception:
|
159 |
return Image.open(image_path).convert("RGB")
|
160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
161 |
# =========================
|
162 |
# Per-modality inference
|
163 |
# =========================
|
164 |
def analyze_text(text: str):
|
165 |
if not text or not text.strip():
|
166 |
-
return gr.Error("Please enter text."), None, None
|
167 |
with gr.Progress() as p:
|
168 |
-
p(0.
|
169 |
probs = pipe_to_probs(text_pipe(text))
|
170 |
msg = f"**Top Text Emotion:** {top_item(probs)} | **Positivity Index:** {positivity_index(probs):.2f}"
|
171 |
fig = bar_fig(probs, "Text Emotions")
|
172 |
-
|
|
|
|
|
|
|
173 |
|
174 |
def analyze_voice(audio_path: Optional[str]):
|
175 |
if not audio_path:
|
176 |
return "No audio provided.", None, None
|
177 |
with gr.Progress() as p:
|
178 |
-
p(0.
|
179 |
probs = pipe_to_probs(voice_pipe(audio_path))
|
180 |
msg = f"**Top Voice Emotion:** {top_item(probs)}"
|
181 |
fig = bar_fig(probs, "Voice Emotions")
|
@@ -185,16 +203,66 @@ def analyze_face(image_path: Optional[str]):
|
|
185 |
if not image_path:
|
186 |
return "No image provided.", None, None
|
187 |
with gr.Progress() as p:
|
188 |
-
p(0.
|
189 |
face_img = crop_face(image_path)
|
190 |
-
p(0.
|
191 |
probs = pipe_to_probs(face_pipe(face_img))
|
192 |
msg = f"**Top Face Emotion:** {top_item(probs)}"
|
193 |
fig = bar_fig(probs, "Face Emotions")
|
194 |
return msg, fig, json.dumps(probs)
|
195 |
|
196 |
# =========================
|
197 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
198 |
# =========================
|
199 |
def build_pdf(text_in: str,
|
200 |
text_prob: Optional[Dict[str,float]],
|
@@ -202,7 +270,8 @@ def build_pdf(text_in: str,
|
|
202 |
face_prob: Optional[Dict[str,float]],
|
203 |
fused_prob: Optional[Dict[str,float]],
|
204 |
safety_level: str, safety_hits: Dict[str,List[str]],
|
205 |
-
|
|
|
206 |
|
207 |
# save charts
|
208 |
paths = []
|
@@ -211,6 +280,7 @@ def build_pdf(text_in: str,
|
|
211 |
if face_prob: save_chart(face_prob, "Face Emotions", os.path.join(CHARTS_DIR,"face.png")); paths.append(os.path.join(CHARTS_DIR,"face.png"))
|
212 |
if fused_prob: save_chart(fused_prob, "Fused Profile", os.path.join(CHARTS_DIR,"fused.png")); paths.append(os.path.join(CHARTS_DIR,"fused.png"))
|
213 |
|
|
|
214 |
pdf = FPDF()
|
215 |
pdf.add_page()
|
216 |
pdf.set_font("Arial", size=16)
|
@@ -231,8 +301,8 @@ def build_pdf(text_in: str,
|
|
231 |
pdf.set_text_color(0,0,0)
|
232 |
pdf.ln(2)
|
233 |
|
234 |
-
if
|
235 |
-
pdf.cell(0, 8, f"Cognitive distortions: {', '.join(
|
236 |
if tips:
|
237 |
pdf.cell(0, 8, "Reframe suggestions:", ln=True)
|
238 |
for t in tips:
|
@@ -244,14 +314,34 @@ def build_pdf(text_in: str,
|
|
244 |
pdf.image(pth, w=180)
|
245 |
pdf.ln(4)
|
246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
247 |
out = "emotion_report.pdf"
|
248 |
pdf.output(out)
|
249 |
return out
|
250 |
|
251 |
# =========================
|
252 |
-
# Trends
|
253 |
# =========================
|
254 |
-
def log_run(row: dict):
|
|
|
|
|
255 |
df = pd.read_csv(RUN_LOG)
|
256 |
df.loc[len(df)] = row
|
257 |
df.to_csv(RUN_LOG, index=False)
|
@@ -272,10 +362,14 @@ def plot_trends():
|
|
272 |
plt.xticks(rotation=25, ha="right"); plt.tight_layout()
|
273 |
return fig
|
274 |
|
|
|
|
|
|
|
|
|
275 |
# =========================
|
276 |
-
# Fusion +
|
277 |
# =========================
|
278 |
-
def
|
279 |
te = json.loads(text_json) if text_json else None
|
280 |
ve = json.loads(voice_json) if voice_json else None
|
281 |
fe = json.loads(face_json) if face_json else None
|
@@ -285,81 +379,109 @@ def fuse_and_report(text_json, voice_json, face_json, text_raw, w_text, w_voice,
|
|
285 |
fused = merge_probs([te, ve, fe], weights) if (te or ve or fe) else None
|
286 |
|
287 |
safety_level, safety_hits = safety_screen(text_raw or "")
|
288 |
-
distos =
|
289 |
-
tips =
|
|
|
290 |
|
291 |
-
|
|
|
|
|
|
|
292 |
|
293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
294 |
log_run({
|
295 |
"timestamp": datetime.datetime.now().isoformat(sep=" ", timespec="seconds"),
|
296 |
"text": text_raw or "",
|
297 |
"text_top": top_item(te),
|
298 |
"voice_top": top_item(ve),
|
299 |
"face_top": top_item(fe),
|
300 |
-
"fused_top":
|
301 |
-
"pos_index":
|
302 |
-
})
|
303 |
|
304 |
-
|
305 |
-
msg += f" | Weights β Text:{weights[0]:.2f}, Voice:{weights[1]:.2f}, Face:{weights[2]:.2f}"
|
306 |
-
plot = bar_fig(fused, "Fused Emotional Profile") if fused else None
|
307 |
-
return msg, plot, pdf_path
|
308 |
|
309 |
-
#
|
310 |
def text_api(text: str):
|
311 |
if not text or not text.strip():
|
312 |
return {"error":"text required"}
|
313 |
probs = pipe_to_probs(text_pipe(text))
|
|
|
314 |
return {
|
315 |
"top": top_item(probs),
|
316 |
"positivity_index": positivity_index(probs),
|
317 |
-
"distribution": probs
|
|
|
318 |
}
|
319 |
|
320 |
# =========================
|
321 |
# Gradio UI
|
322 |
# =========================
|
323 |
with gr.Blocks(theme=gr.themes.Soft()) as demo:
|
324 |
-
#
|
325 |
gr.HTML(f"""
|
326 |
<div style="padding:10px 12px;border:1px solid #eee;border-radius:10px;
|
327 |
display:flex;justify-content:space-between;align-items:center;">
|
328 |
-
<div><b>π§ {APP_NAME}</b> β <span style="opacity:.8">Text β’ Voice β’ Face</span></div>
|
329 |
<div style="opacity:.7">{APP_VERSION} Β· MIT Β· Made with π€</div>
|
330 |
</div>
|
331 |
""")
|
332 |
gr.Markdown("Analyze emotions across **text, voice, and face**, detect **safety risks** and **cognitive distortions**, "
|
333 |
-
"
|
334 |
-
|
335 |
-
#
|
336 |
-
st_text_json
|
337 |
-
st_voice_json
|
338 |
-
st_face_json
|
339 |
-
st_text_raw
|
|
|
|
|
|
|
|
|
340 |
|
341 |
with gr.Row():
|
342 |
demo_btn = gr.Button("Load demo text", variant="secondary")
|
343 |
-
|
344 |
|
|
|
345 |
with gr.Tab("π Text"):
|
346 |
t_in = gr.Textbox(label="Your text", lines=3, placeholder="How are you feeling today?")
|
347 |
t_btn = gr.Button("Analyze Text", variant="primary")
|
348 |
t_msg = gr.Markdown()
|
349 |
t_plot = gr.Plot()
|
|
|
|
|
350 |
def _t_chain(txt):
|
351 |
-
msg, fig, j = analyze_text(txt)
|
352 |
-
return msg, fig, j, txt
|
353 |
-
t_btn.click(_t_chain, inputs=t_in, outputs=[t_msg, t_plot, st_text_json, st_text_raw])
|
354 |
|
|
|
355 |
with gr.Tab("π€ Voice"):
|
356 |
-
a_in = gr.Audio(sources=["microphone","upload"], type="filepath",
|
357 |
-
label="Record or upload audio (optional)")
|
358 |
a_btn = gr.Button("Analyze Voice", variant="primary")
|
359 |
a_msg = gr.Markdown()
|
360 |
a_plot = gr.Plot()
|
361 |
a_btn.click(analyze_voice, inputs=a_in, outputs=[a_msg, a_plot, st_voice_json])
|
362 |
|
|
|
363 |
with gr.Tab("π· Face"):
|
364 |
f_in = gr.Image(type="filepath", label="Upload a face image (optional)")
|
365 |
f_btn = gr.Button("Analyze Face", variant="primary")
|
@@ -367,39 +489,60 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
|
|
367 |
f_plot = gr.Plot()
|
368 |
f_btn.click(analyze_face, inputs=f_in, outputs=[f_msg, f_plot, st_face_json])
|
369 |
|
370 |
-
|
|
|
371 |
with gr.Row():
|
372 |
-
w_text = gr.Slider(0, 1, value=0.
|
373 |
w_voice = gr.Slider(0, 1, value=0.30, step=0.05, label="Voice weight")
|
374 |
-
w_face = gr.Slider(0, 1, value=0.
|
375 |
-
fuse_btn = gr.Button("Fuse &
|
376 |
fuse_msg = gr.Markdown()
|
377 |
fuse_plot = gr.Plot()
|
378 |
-
|
|
|
379 |
fuse_btn.click(
|
380 |
-
|
381 |
-
inputs=[st_text_json, st_voice_json, st_face_json, st_text_raw, w_text, w_voice, w_face],
|
382 |
-
outputs=[fuse_msg, fuse_plot,
|
383 |
-
api_name="
|
|
|
|
|
384 |
)
|
385 |
-
def _reset():
|
386 |
-
return 0.50, 0.30, 0.20
|
387 |
-
reset_btn.click(_reset, None, [w_text, w_voice, w_face])
|
388 |
|
389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
390 |
tr_btn = gr.Button("Refresh Positivity Trend")
|
391 |
tr_plot = gr.Plot()
|
392 |
tr_btn.click(plot_trends, inputs=None, outputs=tr_plot)
|
393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
394 |
with gr.Tab("π API"):
|
395 |
-
gr.Markdown("**Text-only JSON API** (
|
396 |
api_in = gr.Textbox(label="Text")
|
397 |
api_out = gr.JSON(label="Response")
|
398 |
gr.Button("Run API").click(text_api, inputs=api_in, outputs=api_out, api_name="text_api")
|
399 |
|
400 |
# Demo filler
|
401 |
def load_demo():
|
402 |
-
return "I
|
403 |
demo_btn.click(load_demo, None, t_in)
|
404 |
|
405 |
app = demo
|
|
|
1 |
+
# app.py β Multi-Modal Emotion AI (Text β’ Voice β’ Face) β "Super Useful" Edition
|
2 |
+
# Big upgrades: Fusion (weighted), PDF report, Safety + CBT, Coaching Plan generator,
|
3 |
+
# Privacy controls (opt-out logging + clear data), Trends, Face auto-crop, Demo button,
|
4 |
+
# Lightweight keyword "triggers", simple JSON API. CPU-friendly for Hugging Face Spaces.
|
5 |
|
6 |
+
import os, json, datetime, re
|
7 |
from typing import Dict, List, Optional, Tuple
|
8 |
|
9 |
import numpy as np
|
|
|
17 |
from transformers import pipeline
|
18 |
|
19 |
# =========================
|
20 |
+
# App config / metadata
|
21 |
# =========================
|
22 |
APP_NAME = "Multi-Modal Emotion AI"
|
23 |
+
APP_VERSION = "v2.0"
|
24 |
RUN_LOG = "runs.csv"
|
25 |
CHARTS_DIR = "charts"
|
26 |
os.makedirs(CHARTS_DIR, exist_ok=True)
|
|
|
29 |
"timestamp","text","text_top","voice_top","face_top","fused_top","pos_index"
|
30 |
]).to_csv(RUN_LOG, index=False)
|
31 |
|
32 |
+
# =========================
|
33 |
+
# Public, lightweight models
|
34 |
+
# =========================
|
35 |
TEXT_MODEL = "SamLowe/roberta-base-go_emotions" # text emotions (GoEmotions, 27)
|
36 |
VOICE_MODEL = "superb/wav2vec2-base-superb-er" # voice emotion recognition
|
37 |
FACE_MODEL = "trpakov/vit-face-expression" # facial expression (ViT)
|
38 |
|
|
|
39 |
text_pipe = pipeline("text-classification", model=TEXT_MODEL, top_k=None)
|
40 |
voice_pipe = pipeline("audio-classification", model=VOICE_MODEL, top_k=None)
|
41 |
face_pipe = pipeline("image-classification", model=FACE_MODEL, top_k=None)
|
|
|
142 |
plt.close(fig)
|
143 |
|
144 |
# =========================
|
145 |
+
# Computer vision: face auto-crop
|
146 |
# =========================
|
147 |
HAAR = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
|
148 |
def crop_face(image_path: str) -> Image.Image:
|
|
|
159 |
except Exception:
|
160 |
return Image.open(image_path).convert("RGB")
|
161 |
|
162 |
+
# =========================
|
163 |
+
# Lightweight keyword "triggers"
|
164 |
+
# =========================
|
165 |
+
STOP = set("""a an and the of to in is it for on with as that this i im i'm are was were be been being by from at or but if then so very really just kind quite rather about into up down over under again further once do does did doing have has had having not no nor only own same than too s t can will don should now""".split())
|
166 |
+
def extract_triggers(text: str, top_k: int = 6) -> List[str]:
|
167 |
+
if not text: return []
|
168 |
+
tokens = re.findall(r"[a-zA-Z']{3,}", text.lower())
|
169 |
+
words = [w for w in tokens if w not in STOP]
|
170 |
+
if not words: return []
|
171 |
+
counts = {}
|
172 |
+
for w in words: counts[w] = counts.get(w,0) + 1
|
173 |
+
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
174 |
+
return [w for w,_ in ranked[:top_k]]
|
175 |
+
|
176 |
# =========================
|
177 |
# Per-modality inference
|
178 |
# =========================
|
179 |
def analyze_text(text: str):
|
180 |
if not text or not text.strip():
|
181 |
+
return gr.Error("Please enter text."), None, None, None, None
|
182 |
with gr.Progress() as p:
|
183 |
+
p(0.2, desc="Analyzing text emotionsβ¦")
|
184 |
probs = pipe_to_probs(text_pipe(text))
|
185 |
msg = f"**Top Text Emotion:** {top_item(probs)} | **Positivity Index:** {positivity_index(probs):.2f}"
|
186 |
fig = bar_fig(probs, "Text Emotions")
|
187 |
+
distos = detect_distortions(text)
|
188 |
+
tips = reframe_tips(distos)
|
189 |
+
triggers = extract_triggers(text)
|
190 |
+
return msg, fig, json.dumps(probs), json.dumps({"distortions":distos,"tips":tips}), json.dumps({"triggers":triggers})
|
191 |
|
192 |
def analyze_voice(audio_path: Optional[str]):
|
193 |
if not audio_path:
|
194 |
return "No audio provided.", None, None
|
195 |
with gr.Progress() as p:
|
196 |
+
p(0.2, desc="Analyzing voice emotionsβ¦")
|
197 |
probs = pipe_to_probs(voice_pipe(audio_path))
|
198 |
msg = f"**Top Voice Emotion:** {top_item(probs)}"
|
199 |
fig = bar_fig(probs, "Voice Emotions")
|
|
|
203 |
if not image_path:
|
204 |
return "No image provided.", None, None
|
205 |
with gr.Progress() as p:
|
206 |
+
p(0.2, desc="Detecting faceβ¦")
|
207 |
face_img = crop_face(image_path)
|
208 |
+
p(0.7, desc="Analyzing facial expressionβ¦")
|
209 |
probs = pipe_to_probs(face_pipe(face_img))
|
210 |
msg = f"**Top Face Emotion:** {top_item(probs)}"
|
211 |
fig = bar_fig(probs, "Face Emotions")
|
212 |
return msg, fig, json.dumps(probs)
|
213 |
|
214 |
# =========================
|
215 |
+
# Coaching plan (actionable)
|
216 |
+
# =========================
|
217 |
+
def generate_coaching_plan(text_prob, voice_prob, face_prob, disto_json, safety_level, triggers):
|
218 |
+
# Parse inputs
|
219 |
+
te = json.loads(text_prob) if text_prob else {}
|
220 |
+
ve = json.loads(voice_prob) if voice_prob else {}
|
221 |
+
fe = json.loads(face_prob) if face_prob else {}
|
222 |
+
distos = json.loads(disto_json).get("distortions", []) if disto_json else []
|
223 |
+
tips = json.loads(disto_json).get("tips", []) if disto_json else []
|
224 |
+
trig_list = json.loads(triggers).get("triggers", []) if triggers else []
|
225 |
+
|
226 |
+
# Determine dominant emotions (top across modalities)
|
227 |
+
def topk(d, k=3): return sorted(d.items(), key=lambda kv: kv[1], reverse=True)[:k]
|
228 |
+
dominant = [lab for lab,_ in topk(te,1)+topk(ve,1)+topk(fe,1)]
|
229 |
+
dom_set = set(dominant)
|
230 |
+
|
231 |
+
# Recipe library
|
232 |
+
exercises = []
|
233 |
+
if "fear" in dom_set or "nervousness" in dom_set:
|
234 |
+
exercises += ["4-7-8 breathing (5 rounds)", "Name 5-4-3-2-1 sensory objects", "Write worst/best/most-likely outcomes"]
|
235 |
+
if "sadness" in dom_set or "grief" in dom_set:
|
236 |
+
exercises += ["Text one friend to check in", "10-minute sunlight walk", "Gratitude list (3 items)"]
|
237 |
+
if "anger" in dom_set or "disgust" in dom_set:
|
238 |
+
exercises += ["2-minute cold water face splash", "Box breathing 4x4x4x4", "Delay response 20 minutes + draft message"]
|
239 |
+
if "joy" in dom_set or "excitement" in dom_set or "admiration" in dom_set:
|
240 |
+
exercises += ["Savoring: write 3 details you enjoyed", "Share win with someone", "Schedule repeat of the activity"]
|
241 |
+
|
242 |
+
# Personalization via distortions & triggers
|
243 |
+
reframe_block = tips[:3]
|
244 |
+
trigger_block = [f"Avoid or prepare for: {t}" for t in trig_list]
|
245 |
+
|
246 |
+
# Safety append
|
247 |
+
crisis_block = []
|
248 |
+
if safety_level == "high":
|
249 |
+
crisis_block = ["β If in danger: contact local emergency services.",
|
250 |
+
"US: 988 (Suicide & Crisis Lifeline)"]
|
251 |
+
|
252 |
+
# Structure plan
|
253 |
+
plan = {
|
254 |
+
"today": list(dict.fromkeys(exercises))[:5] or ["5-minute mindful breathing", "Short walk outside"],
|
255 |
+
"reframes": reframe_block or ["Reframe: What evidence supports/against my thought?"],
|
256 |
+
"triggers": trigger_block[:5],
|
257 |
+
"sleep": ["Wind-down alarm + devices off 30m before bed", "Keep consistent wake time"],
|
258 |
+
"movement": ["10-15m easy cardio or stretching"],
|
259 |
+
"social": ["Send a supportive text to someone"],
|
260 |
+
"safety": crisis_block
|
261 |
+
}
|
262 |
+
return json.dumps(plan, ensure_ascii=False)
|
263 |
+
|
264 |
+
# =========================
|
265 |
+
# PDF Report (analysis + plan)
|
266 |
# =========================
|
267 |
def build_pdf(text_in: str,
|
268 |
text_prob: Optional[Dict[str,float]],
|
|
|
270 |
face_prob: Optional[Dict[str,float]],
|
271 |
fused_prob: Optional[Dict[str,float]],
|
272 |
safety_level: str, safety_hits: Dict[str,List[str]],
|
273 |
+
distos: List[str], tips: List[str],
|
274 |
+
plan_json: Optional[str]) -> str:
|
275 |
|
276 |
# save charts
|
277 |
paths = []
|
|
|
280 |
if face_prob: save_chart(face_prob, "Face Emotions", os.path.join(CHARTS_DIR,"face.png")); paths.append(os.path.join(CHARTS_DIR,"face.png"))
|
281 |
if fused_prob: save_chart(fused_prob, "Fused Profile", os.path.join(CHARTS_DIR,"fused.png")); paths.append(os.path.join(CHARTS_DIR,"fused.png"))
|
282 |
|
283 |
+
# build PDF
|
284 |
pdf = FPDF()
|
285 |
pdf.add_page()
|
286 |
pdf.set_font("Arial", size=16)
|
|
|
301 |
pdf.set_text_color(0,0,0)
|
302 |
pdf.ln(2)
|
303 |
|
304 |
+
if distos:
|
305 |
+
pdf.cell(0, 8, f"Cognitive distortions: {', '.join(distos)}", ln=True)
|
306 |
if tips:
|
307 |
pdf.cell(0, 8, "Reframe suggestions:", ln=True)
|
308 |
for t in tips:
|
|
|
314 |
pdf.image(pth, w=180)
|
315 |
pdf.ln(4)
|
316 |
|
317 |
+
# Coaching plan section
|
318 |
+
if plan_json:
|
319 |
+
try:
|
320 |
+
plan = json.loads(plan_json)
|
321 |
+
pdf.set_font("Arial", size=13)
|
322 |
+
pdf.cell(0, 10, "Personalized Coaching Plan", ln=True)
|
323 |
+
pdf.set_font("Arial", size=12)
|
324 |
+
for sec in ["today","reframes","triggers","movement","sleep","social","safety"]:
|
325 |
+
items = plan.get(sec, [])
|
326 |
+
if not items: continue
|
327 |
+
title = sec.capitalize()
|
328 |
+
pdf.cell(0, 8, f"{title}:", ln=True)
|
329 |
+
for i in items:
|
330 |
+
pdf.multi_cell(0, 7, f" β’ {i}")
|
331 |
+
pdf.ln(1)
|
332 |
+
except Exception:
|
333 |
+
pass
|
334 |
+
|
335 |
out = "emotion_report.pdf"
|
336 |
pdf.output(out)
|
337 |
return out
|
338 |
|
339 |
# =========================
|
340 |
+
# Trends & data controls
|
341 |
# =========================
|
342 |
+
def log_run(row: dict, enable_logging: bool):
|
343 |
+
if not enable_logging:
|
344 |
+
return
|
345 |
df = pd.read_csv(RUN_LOG)
|
346 |
df.loc[len(df)] = row
|
347 |
df.to_csv(RUN_LOG, index=False)
|
|
|
362 |
plt.xticks(rotation=25, ha="right"); plt.tight_layout()
|
363 |
return fig
|
364 |
|
365 |
+
def clear_history():
|
366 |
+
pd.DataFrame(columns=["timestamp","text","text_top","voice_top","face_top","fused_top","pos_index"]).to_csv(RUN_LOG, index=False)
|
367 |
+
return "History cleared."
|
368 |
+
|
369 |
# =========================
|
370 |
+
# Fusion + end-to-end actions
|
371 |
# =========================
|
372 |
+
def fuse_and_plan(text_json, voice_json, face_json, text_raw, w_text, w_voice, w_face, disto_json, triggers_json):
|
373 |
te = json.loads(text_json) if text_json else None
|
374 |
ve = json.loads(voice_json) if voice_json else None
|
375 |
fe = json.loads(face_json) if face_json else None
|
|
|
379 |
fused = merge_probs([te, ve, fe], weights) if (te or ve or fe) else None
|
380 |
|
381 |
safety_level, safety_hits = safety_screen(text_raw or "")
|
382 |
+
distos = json.loads(disto_json).get("distortions", []) if disto_json else []
|
383 |
+
tips = json.loads(disto_json).get("tips", []) if disto_json else []
|
384 |
+
plan = generate_coaching_plan(text_json, voice_json, face_json, disto_json, safety_level, triggers_json)
|
385 |
|
386 |
+
msg = f"**Fused Top:** {top_item(fused) or '(insufficient inputs)'}"
|
387 |
+
msg += f" | Weights β Text:{weights[0]:.2f}, Voice:{weights[1]:.2f}, Face:{weights[2]:.2f}"
|
388 |
+
plot = bar_fig(fused, "Fused Emotional Profile") if fused else None
|
389 |
+
return msg, plot, json.dumps({"safety":safety_level,"hits":safety_hits}), plan
|
390 |
|
391 |
+
def full_report(text_json, voice_json, face_json, text_raw, fusion_plot, safety_json, disto_json, plan_json, enable_logging):
|
392 |
+
te = json.loads(text_json) if text_json else None
|
393 |
+
ve = json.loads(voice_json) if voice_json else None
|
394 |
+
fe = json.loads(face_json) if face_json else None
|
395 |
+
safety = json.loads(safety_json) if safety_json else {"safety":"low","hits":{}}
|
396 |
+
distos = json.loads(disto_json).get("distortions", []) if disto_json else []
|
397 |
+
tips = json.loads(disto_json).get("tips", []) if disto_json else []
|
398 |
+
|
399 |
+
pdf_path = build_pdf(
|
400 |
+
text_in=text_raw,
|
401 |
+
text_prob=te, voice_prob=ve, face_prob=fe,
|
402 |
+
fused_prob=None, # fused chart already shown; omit to keep PDF lighter or replace with fused if desired
|
403 |
+
safety_level=safety.get("safety","low"), safety_hits=safety.get("hits",{}),
|
404 |
+
distos=distos, tips=tips,
|
405 |
+
plan_json=plan_json
|
406 |
+
)
|
407 |
+
|
408 |
+
# Log compact row
|
409 |
log_run({
|
410 |
"timestamp": datetime.datetime.now().isoformat(sep=" ", timespec="seconds"),
|
411 |
"text": text_raw or "",
|
412 |
"text_top": top_item(te),
|
413 |
"voice_top": top_item(ve),
|
414 |
"face_top": top_item(fe),
|
415 |
+
"fused_top": "", # could compute again if needed
|
416 |
+
"pos_index": positivity_index(te)
|
417 |
+
}, enable_logging=enable_logging)
|
418 |
|
419 |
+
return pdf_path
|
|
|
|
|
|
|
420 |
|
421 |
+
# Simple text-only JSON API (for quick programmatic use)
|
422 |
def text_api(text: str):
|
423 |
if not text or not text.strip():
|
424 |
return {"error":"text required"}
|
425 |
probs = pipe_to_probs(text_pipe(text))
|
426 |
+
distos = detect_distortions(text)
|
427 |
return {
|
428 |
"top": top_item(probs),
|
429 |
"positivity_index": positivity_index(probs),
|
430 |
+
"distribution": probs,
|
431 |
+
"distortions": distos
|
432 |
}
|
433 |
|
434 |
# =========================
|
435 |
# Gradio UI
|
436 |
# =========================
|
437 |
with gr.Blocks(theme=gr.themes.Soft()) as demo:
|
438 |
+
# Header
|
439 |
gr.HTML(f"""
|
440 |
<div style="padding:10px 12px;border:1px solid #eee;border-radius:10px;
|
441 |
display:flex;justify-content:space-between;align-items:center;">
|
442 |
+
<div><b>π§ {APP_NAME}</b> β <span style="opacity:.8">Text β’ Voice β’ Face β’ Coaching</span></div>
|
443 |
<div style="opacity:.7">{APP_VERSION} Β· MIT Β· Made with π€</div>
|
444 |
</div>
|
445 |
""")
|
446 |
gr.Markdown("Analyze emotions across **text, voice, and face**, detect **safety risks** and **cognitive distortions**, "
|
447 |
+
"generate a **personalized coaching plan**, and download a **PDF report**. Audio/image optional.")
|
448 |
+
|
449 |
+
# App state
|
450 |
+
st_text_json = gr.State()
|
451 |
+
st_voice_json = gr.State()
|
452 |
+
st_face_json = gr.State()
|
453 |
+
st_text_raw = gr.State()
|
454 |
+
st_disto_json = gr.State()
|
455 |
+
st_triggers = gr.State()
|
456 |
+
st_safety_json = gr.State()
|
457 |
+
st_plan_json = gr.State()
|
458 |
|
459 |
with gr.Row():
|
460 |
demo_btn = gr.Button("Load demo text", variant="secondary")
|
461 |
+
enable_log = gr.Checkbox(value=True, label="Enable logging for Trends (privacy toggle)")
|
462 |
|
463 |
+
# ---------- Text ----------
|
464 |
with gr.Tab("π Text"):
|
465 |
t_in = gr.Textbox(label="Your text", lines=3, placeholder="How are you feeling today?")
|
466 |
t_btn = gr.Button("Analyze Text", variant="primary")
|
467 |
t_msg = gr.Markdown()
|
468 |
t_plot = gr.Plot()
|
469 |
+
t_dist = gr.JSON(label="CBT Distortions & Tips")
|
470 |
+
t_trig = gr.JSON(label="Possible Triggers")
|
471 |
def _t_chain(txt):
|
472 |
+
msg, fig, j, djson, trig = analyze_text(txt)
|
473 |
+
return msg, fig, j, txt, djson, trig
|
474 |
+
t_btn.click(_t_chain, inputs=t_in, outputs=[t_msg, t_plot, st_text_json, st_text_raw, st_disto_json, st_triggers])
|
475 |
|
476 |
+
# ---------- Voice ----------
|
477 |
with gr.Tab("π€ Voice"):
|
478 |
+
a_in = gr.Audio(sources=["microphone","upload"], type="filepath", label="Record or upload audio (optional)")
|
|
|
479 |
a_btn = gr.Button("Analyze Voice", variant="primary")
|
480 |
a_msg = gr.Markdown()
|
481 |
a_plot = gr.Plot()
|
482 |
a_btn.click(analyze_voice, inputs=a_in, outputs=[a_msg, a_plot, st_voice_json])
|
483 |
|
484 |
+
# ---------- Face ----------
|
485 |
with gr.Tab("π· Face"):
|
486 |
f_in = gr.Image(type="filepath", label="Upload a face image (optional)")
|
487 |
f_btn = gr.Button("Analyze Face", variant="primary")
|
|
|
489 |
f_plot = gr.Plot()
|
490 |
f_btn.click(analyze_face, inputs=f_in, outputs=[f_msg, f_plot, st_face_json])
|
491 |
|
492 |
+
# ---------- Fusion & Plan ----------
|
493 |
+
with gr.Tab("π§© Fusion + Plan"):
|
494 |
with gr.Row():
|
495 |
+
w_text = gr.Slider(0, 1, value=0.55, step=0.05, label="Text weight")
|
496 |
w_voice = gr.Slider(0, 1, value=0.30, step=0.05, label="Voice weight")
|
497 |
+
w_face = gr.Slider(0, 1, value=0.15, step=0.05, label="Face weight")
|
498 |
+
fuse_btn = gr.Button("Fuse & Build Coaching Plan", variant="primary")
|
499 |
fuse_msg = gr.Markdown()
|
500 |
fuse_plot = gr.Plot()
|
501 |
+
safety_box = gr.JSON(label="Safety screen")
|
502 |
+
plan_box = gr.JSON(label="Coaching plan (today + reframes + triggers)")
|
503 |
fuse_btn.click(
|
504 |
+
fuse_and_plan,
|
505 |
+
inputs=[st_text_json, st_voice_json, st_face_json, st_text_raw, w_text, w_voice, w_face, st_disto_json, st_triggers],
|
506 |
+
outputs=[fuse_msg, fuse_plot, st_safety_json, st_plan_json],
|
507 |
+
api_name="fuse" # expose endpoint
|
508 |
+
).then(
|
509 |
+
lambda s,p: (s,p), inputs=[st_safety_json, st_plan_json], outputs=[safety_box, plan_box]
|
510 |
)
|
|
|
|
|
|
|
511 |
|
512 |
+
# ---------- Report ----------
|
513 |
+
with gr.Tab("π Report"):
|
514 |
+
report_btn = gr.Button("Generate PDF Report")
|
515 |
+
pdf_out = gr.File(label="Download Report")
|
516 |
+
report_btn.click(
|
517 |
+
full_report,
|
518 |
+
inputs=[st_text_json, st_voice_json, st_face_json, st_text_raw, fuse_plot, st_safety_json, st_disto_json, st_plan_json, enable_log],
|
519 |
+
outputs=pdf_out,
|
520 |
+
api_name="report"
|
521 |
+
)
|
522 |
+
|
523 |
+
# ---------- Trends & Data ----------
|
524 |
+
with gr.Tab("π Trends & Data"):
|
525 |
tr_btn = gr.Button("Refresh Positivity Trend")
|
526 |
tr_plot = gr.Plot()
|
527 |
tr_btn.click(plot_trends, inputs=None, outputs=tr_plot)
|
528 |
+
with gr.Row():
|
529 |
+
dl_btn = gr.Button("Download CSV")
|
530 |
+
dl_out = gr.File()
|
531 |
+
clr_btn = gr.Button("Clear History", variant="stop")
|
532 |
+
clr_msg = gr.Markdown()
|
533 |
+
dl_btn.click(lambda: RUN_LOG if os.path.exists(RUN_LOG) else None, inputs=None, outputs=dl_out)
|
534 |
+
clr_btn.click(lambda: clear_history(), inputs=None, outputs=clr_msg)
|
535 |
+
|
536 |
+
# ---------- API (text-only quick JSON) ----------
|
537 |
with gr.Tab("π API"):
|
538 |
+
gr.Markdown("**Text-only JSON API** (quick programmatic use).")
|
539 |
api_in = gr.Textbox(label="Text")
|
540 |
api_out = gr.JSON(label="Response")
|
541 |
gr.Button("Run API").click(text_api, inputs=api_in, outputs=api_out, api_name="text_api")
|
542 |
|
543 |
# Demo filler
|
544 |
def load_demo():
|
545 |
+
return "I'm overwhelmed by school deadlines, but I'm also excited for the new opportunities."
|
546 |
demo_btn.click(load_demo, None, t_in)
|
547 |
|
548 |
app = demo
|