| """ |
| api.py |
| ------ |
| FastAPI inference server for the Multimodal Emotional Intelligence System. |
| |
| Endpoints: |
| POST /analyze β image + text β full emotion analysis |
| POST /analyze/text β text only (no image required) |
| GET /health β health check |
| GET /emotions β list supported emotion labels |
| |
| Run locally (MacBook): |
| uvicorn api:app --host 0.0.0.0 --port 8000 --reload |
| |
| Deploy on Render (free tier): |
| 1. Push to GitHub |
| 2. New Web Service β connect repo β start command: uvicorn api:app --host 0.0.0.0 --port $PORT |
| 3. Set env var: PYTHON_VERSION=3.11.9 |
| """ |
|
|
| from __future__ import annotations |
| import io |
| import os |
| import time |
| from typing import Optional |
|
|
| from fastapi import FastAPI, File, Form, HTTPException, UploadFile |
| from fastapi.middleware.cors import CORSMiddleware |
| from pydantic import BaseModel |
| from PIL import Image |
|
|
| from inference import EmotionInferencePipeline, EmotionResult |
|
|
| |
|
|
| app = FastAPI( |
| title="Multimodal Emotional Intelligence API", |
| description="Fuses facial expression analysis and NLP sentiment into unified emotion outputs.", |
| version="1.0.0", |
| docs_url="/docs", |
| redoc_url="/redoc", |
| ) |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=[ |
| "http://localhost:3000", |
| "https://perceive-ai.vercel.app", |
| "*", |
| ], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| |
| _pipeline: Optional[EmotionInferencePipeline] = None |
|
|
| def get_pipeline() -> EmotionInferencePipeline: |
| global _pipeline |
| if _pipeline is None: |
| _pipeline = EmotionInferencePipeline() |
| return _pipeline |
|
|
| @app.on_event("startup") |
| async def startup_event(): |
| global _pipeline |
| print("[startup] Loading models into memory...") |
| _pipeline = EmotionInferencePipeline() |
| print("[startup] Models ready.") |
|
|
|
|
|
|
|
|
| |
|
|
| class ModalWeights(BaseModel): |
| visual: float |
| text: float |
|
|
| class EmotionAnalysisResponse(BaseModel): |
| facial_emotion: str |
| facial_probs: dict[str, float] |
| text_emotion: str |
| text_probs: dict[str, float] |
| sentiment_score: float |
| fused_emotion: str |
| fused_probs: dict[str, float] |
| confidence: float |
| valence: float |
| arousal: float |
| risk_level: str |
| modal_weights: ModalWeights |
| recommendation: str |
| inference_ms: float |
|
|
| class TextOnlyResponse(BaseModel): |
| emotion: str |
| probs: dict[str, float] |
| sentiment_score: float |
| inference_ms: float |
|
|
|
|
| |
|
|
| def result_to_response(r: EmotionResult) -> EmotionAnalysisResponse: |
| return EmotionAnalysisResponse( |
| facial_emotion=r.facial_emotion, |
| facial_probs=r.facial_probs, |
| text_emotion=r.text_emotion, |
| text_probs=r.text_probs, |
| sentiment_score=r.sentiment_score, |
| fused_emotion=r.fused_emotion, |
| fused_probs=r.fused_probs, |
| confidence=r.confidence, |
| valence=r.valence, |
| arousal=r.arousal, |
| risk_level=r.risk_level, |
| modal_weights=ModalWeights(**r.modal_weights), |
| recommendation=r.recommendation, |
| inference_ms=r.inference_ms, |
| ) |
|
|
|
|
| |
|
|
| @app.get("/health") |
| def health_check(): |
| return { |
| "status": "ok", |
| "model_loaded": _pipeline is not None, |
| "timestamp": time.time(), |
| } |
|
|
|
|
| @app.get("/emotions") |
| def list_emotions(): |
| from fusion.cross_modal_attention import EMOTION_LABELS, RISK_LABELS |
| return { |
| "emotions": EMOTION_LABELS, |
| "risk_levels": RISK_LABELS, |
| } |
|
|
|
|
| @app.post("/analyze", response_model=EmotionAnalysisResponse) |
| async def analyze( |
| image: UploadFile = File(..., description="Face image (JPEG/PNG)"), |
| text: str = Form(..., description="Transcribed speech or chat message"), |
| ): |
| """ |
| Full multimodal emotion analysis. |
| Accepts a face image + text, returns fused emotion prediction. |
| """ |
| |
| if not image.content_type.startswith("image/"): |
| raise HTTPException(status_code=422, detail="File must be an image (JPEG/PNG).") |
|
|
| image_bytes = await image.read() |
| try: |
| pil_image = Image.open(io.BytesIO(image_bytes)).convert("RGB") |
| except Exception: |
| raise HTTPException(status_code=422, detail="Could not decode image.") |
|
|
| if not text.strip(): |
| raise HTTPException(status_code=422, detail="Text field cannot be empty.") |
|
|
| pipeline = get_pipeline() |
| result = pipeline.run(image=pil_image, text=text.strip()) |
| return result_to_response(result) |
|
|
|
|
| @app.post("/analyze/text", response_model=TextOnlyResponse) |
| async def analyze_text(text: str = Form(..., description="Text to analyze")): |
| """Text-only emotion and sentiment analysis.""" |
| if not text.strip(): |
| raise HTTPException(status_code=422, detail="Text cannot be empty.") |
|
|
| t0 = time.perf_counter() |
| pipeline = get_pipeline() |
|
|
| txt_emotion, txt_probs, sentiment = pipeline._run_text(text.strip()) |
|
|
| return TextOnlyResponse( |
| emotion=txt_emotion, |
| probs=txt_probs, |
| sentiment_score=sentiment, |
| inference_ms=(time.perf_counter() - t0) * 1000, |
| ) |
|
|
|
|
| |
|
|
| if __name__ == "__main__": |
| import uvicorn |
| port = int(os.environ.get("PORT", 7860)) |
| uvicorn.run("api:app", host="0.0.0.0", port=port, reload=False) |
|
|