github-actions[bot] commited on
Commit
c96664a
·
1 Parent(s): e152096

🚀 Deploy from GitHub Actions - 2026-02-03 09:56:04

Browse files
Files changed (4) hide show
  1. Dockerfile +24 -0
  2. README.md +0 -11
  3. app.py +497 -0
  4. requirements.txt +23 -0
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && apt-get install -y \
7
+ libgomp1 \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Copy requirements
11
+ COPY requirements.txt .
12
+
13
+ # Install Python packages
14
+ RUN pip install --no-cache-dir --upgrade pip && \
15
+ pip install --no-cache-dir -r requirements.txt
16
+
17
+ # Copy app
18
+ COPY app.py .
19
+
20
+ # Expose port
21
+ EXPOSE 7860
22
+
23
+ # Run FastAPI
24
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,11 +0,0 @@
1
- ---
2
- title: Wakee Api
3
- emoji: 🦀
4
- colorFrom: pink
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
app.py ADDED
@@ -0,0 +1,497 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Wakee Reloaded - API FastAPI
3
+ Logique : /predict ne sauvegarde RIEN, /insert fait tout
4
+ """
5
+
6
+ from fastapi import FastAPI, File, UploadFile, HTTPException
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from pydantic import BaseModel, Field
9
+ from typing import List, Optional
10
+ from huggingface_hub import hf_hub_download
11
+ import onnxruntime as ort
12
+ from PIL import Image
13
+ import io
14
+ import numpy as np
15
+ from datetime import datetime
16
+ import base64
17
+ import os
18
+
19
+ from sqlalchemy import create_engine, text
20
+ from sqlalchemy.exc import SQLAlchemyError
21
+ import boto3
22
+ from botocore.exceptions import ClientError
23
+ from torchvision import transforms
24
+
25
+ # ============================================================================
26
+ # CONFIGURATION
27
+ # ============================================================================
28
+
29
+ HF_MODEL_REPO = "Terorra/wakee-reloaded"
30
+ MODEL_FILENAME = "model.onnx"
31
+
32
+ NEON_DATABASE_URL = os.getenv("NEON_DATABASE_URL")
33
+ R2_ACCOUNT_ID = os.getenv("R2_ACCOUNT_ID")
34
+ R2_ACCESS_KEY_ID = os.getenv("R2_ACCESS_KEY_ID")
35
+ R2_SECRET_ACCESS_KEY = os.getenv("R2_SECRET_ACCESS_KEY")
36
+ R2_BUCKET_NAME = os.getenv("R2_BUCKET_NAME", "wakee-bucket")
37
+
38
+ # ============================================================================
39
+ # PYDANTIC MODELS
40
+ # ============================================================================
41
+
42
+ class PredictionResponse(BaseModel):
43
+ """Response de /predict - JUSTE les scores"""
44
+ boredom: float = Field(..., ge=0, le=3)
45
+ confusion: float = Field(..., ge=0, le=3)
46
+ engagement: float = Field(..., ge=0, le=3)
47
+ frustration: float = Field(..., ge=0, le=3)
48
+ timestamp: str
49
+
50
+ class AnnotationInsert(BaseModel):
51
+ """Données pour /insert - Image + Labels"""
52
+ # Image (base64 encodée)
53
+ image_base64: str
54
+
55
+ # Prédictions du modèle
56
+ predicted_boredom: float = Field(..., ge=0, le=3)
57
+ predicted_confusion: float = Field(..., ge=0, le=3)
58
+ predicted_engagement: float = Field(..., ge=0, le=3)
59
+ predicted_frustration: float = Field(..., ge=0, le=3)
60
+
61
+ # Corrections utilisateur
62
+ user_boredom: float = Field(..., ge=0, le=3)
63
+ user_confusion: float = Field(..., ge=0, le=3)
64
+ user_engagement: float = Field(..., ge=0, le=3)
65
+ user_frustration: float = Field(..., ge=0, le=3)
66
+
67
+ class InsertResponse(BaseModel):
68
+ """Response de /insert"""
69
+ status: str
70
+ message: str
71
+ img_name: str
72
+ s3_url: Optional[str] = None
73
+
74
+ class LoadResponse(BaseModel):
75
+ """Response de /load"""
76
+ total_samples: int
77
+ validated_samples: int
78
+ recent_predictions: List[dict]
79
+ statistics: dict
80
+
81
+ # ============================================================================
82
+ # FASTAPI APP
83
+ # ============================================================================
84
+
85
+ app = FastAPI(
86
+ title="Wakee Emotion API",
87
+ description="Multi-label emotion detection API",
88
+ version="1.0.0",
89
+ docs_url="/docs",
90
+ redoc_url="/redoc"
91
+ )
92
+
93
+ app.add_middleware(
94
+ CORSMiddleware,
95
+ allow_origins=["*"],
96
+ allow_credentials=True,
97
+ allow_methods=["*"],
98
+ allow_headers=["*"],
99
+ )
100
+
101
+ # ============================================================================
102
+ # GLOBAL VARIABLES
103
+ # ============================================================================
104
+
105
+ onnx_session = None
106
+ db_engine = None
107
+ s3_client = None
108
+ transform = None
109
+
110
+ # ============================================================================
111
+ # STARTUP
112
+ # ============================================================================
113
+
114
+ @app.on_event("startup")
115
+ async def startup_event():
116
+ global onnx_session, db_engine, s3_client, transform
117
+
118
+ print("=" * 70)
119
+ print("🚀 DÉMARRAGE API WAKEE")
120
+ print("=" * 70)
121
+
122
+ # 1. Download model from HF Model Hub
123
+ try:
124
+ print(f"\n📥 Téléchargement du modèle...")
125
+ print(f" Repo : {HF_MODEL_REPO}")
126
+ print(f" File : {MODEL_FILENAME}")
127
+
128
+ model_path = hf_hub_download(
129
+ repo_id=HF_MODEL_REPO,
130
+ filename=MODEL_FILENAME,
131
+ cache_dir="/tmp/models"
132
+ )
133
+
134
+ onnx_session = ort.InferenceSession(model_path)
135
+
136
+ input_name = onnx_session.get_inputs()[0].name
137
+ input_shape = onnx_session.get_inputs()[0].shape
138
+
139
+ print(f"✅ Modèle chargé : {model_path}")
140
+ print(f" Input : {input_name} {input_shape}\n")
141
+
142
+ except Exception as e:
143
+ print(f"❌ Erreur chargement modèle : {e}\n")
144
+ onnx_session = None
145
+
146
+ # 2. Preprocessing
147
+ transform = transforms.Compose([
148
+ transforms.Resize(256),
149
+ transforms.CenterCrop(224),
150
+ transforms.ToTensor(),
151
+ transforms.Normalize([0.485, 0.456, 0.406],
152
+ [0.229, 0.224, 0.225])
153
+ ])
154
+ print("✅ Preprocessing configuré\n")
155
+
156
+ # 3. Database
157
+ if NEON_DATABASE_URL:
158
+ try:
159
+ db_engine = create_engine(NEON_DATABASE_URL)
160
+ with db_engine.connect() as conn:
161
+ conn.execute(text("SELECT 1"))
162
+ print("✅ Connexion NeonDB établie\n")
163
+ except Exception as e:
164
+ print(f"⚠️ NeonDB non disponible : {e}\n")
165
+ db_engine = None
166
+ else:
167
+ print("⚠️ NEON_DATABASE_URL non défini\n")
168
+
169
+ # 4. Cloudflare R2
170
+ if all([R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY]):
171
+ try:
172
+ s3_client = boto3.client(
173
+ 's3',
174
+ endpoint_url=f'https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com',
175
+ aws_access_key_id=R2_ACCESS_KEY_ID,
176
+ aws_secret_access_key=R2_SECRET_ACCESS_KEY,
177
+ region_name='auto'
178
+ )
179
+ s3_client.head_bucket(Bucket=R2_BUCKET_NAME)
180
+ print(f"✅ Connexion Cloudflare R2 (bucket: {R2_BUCKET_NAME})\n")
181
+ except Exception as e:
182
+ print(f"⚠️ Cloudflare R2 non disponible : {e}\n")
183
+ s3_client = None
184
+ else:
185
+ print("⚠️ R2 secrets non définis\n")
186
+
187
+ print("=" * 70)
188
+ print("🎉 API WAKEE PRÊTE !")
189
+ print("=" * 70)
190
+ print(f"📊 Status :")
191
+ print(f" - Modèle : {'✅' if onnx_session else '❌'}")
192
+ print(f" - Database : {'✅' if db_engine else '❌'}")
193
+ print(f" - Storage : {'✅' if s3_client else '❌'}")
194
+ print("=" * 70 + "\n")
195
+
196
+ # ============================================================================
197
+ # HELPER FUNCTIONS
198
+ # ============================================================================
199
+
200
+ def preprocess_image(pil_image: Image.Image) -> np.ndarray:
201
+ """Preprocessing identique à ton cnn.py"""
202
+ img_tensor = transform(pil_image).unsqueeze(0).numpy()
203
+ return img_tensor
204
+
205
+ # ============================================================================
206
+ # ENDPOINTS
207
+ # ============================================================================
208
+
209
+ @app.get("/")
210
+ async def root():
211
+ """Page d'accueil"""
212
+ return {
213
+ "message": "Wakee Emotion API",
214
+ "version": "1.0.0",
215
+ "model_source": HF_MODEL_REPO,
216
+ "workflow": {
217
+ "1": "POST /predict - Obtenir prédiction (rien n'est sauvegardé)",
218
+ "2": "Utilisateur valide/corrige les scores",
219
+ "3": "POST /insert - Uploader image + labels (R2 + NeonDB)",
220
+ "4": "GET /load - Charger données et statistiques"
221
+ },
222
+ "docs": "/docs",
223
+ "author": "Terorra"
224
+ }
225
+
226
+ @app.get("/health")
227
+ async def health_check():
228
+ """Health check"""
229
+ return {
230
+ "status": "healthy",
231
+ "model_loaded": onnx_session is not None,
232
+ "model_source": HF_MODEL_REPO,
233
+ "database_connected": db_engine is not None,
234
+ "storage_connected": s3_client is not None,
235
+ "timestamp": datetime.now().isoformat()
236
+ }
237
+
238
+ @app.post("/predict", response_model=PredictionResponse)
239
+ async def predict_emotion(file: UploadFile = File(...)):
240
+ """
241
+ Prédiction des 4 émotions depuis une image
242
+
243
+ ⚠️ RIEN N'EST SAUVEGARDÉ à cette étape
244
+
245
+ L'utilisateur doit ensuite appeler /insert pour sauvegarder
246
+ """
247
+
248
+ if not onnx_session:
249
+ raise HTTPException(
250
+ status_code=503,
251
+ detail="Model not loaded"
252
+ )
253
+
254
+ if not file.content_type.startswith('image/'):
255
+ raise HTTPException(status_code=400, detail="File must be an image")
256
+
257
+ try:
258
+ # 1. Load image
259
+ image_bytes = await file.read()
260
+ image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
261
+
262
+ # 2. Preprocessing
263
+ input_tensor = preprocess_image(image)
264
+
265
+ # 3. Inference ONNX
266
+ outputs = onnx_session.run(['output'], {'input': input_tensor})
267
+ scores_array = outputs[0][0]
268
+
269
+ # 4. Format résultats
270
+ return PredictionResponse(
271
+ boredom=round(float(scores_array[0]), 2),
272
+ confusion=round(float(scores_array[1]), 2),
273
+ engagement=round(float(scores_array[2]), 2),
274
+ frustration=round(float(scores_array[3]), 2),
275
+ timestamp=datetime.now().isoformat()
276
+ )
277
+
278
+ # ⚠️ PAS de sauvegarde R2
279
+ # ⚠️ PAS de sauvegarde NeonDB
280
+ # → L'utilisateur décide s'il valide via /insert
281
+
282
+ except Exception as e:
283
+ print(f"❌ Erreur prédiction : {e}")
284
+ raise HTTPException(status_code=500, detail=str(e))
285
+
286
+ @app.post("/insert", response_model=InsertResponse)
287
+ async def insert_annotation(annotation: AnnotationInsert):
288
+ """
289
+ Insert annotation utilisateur
290
+
291
+ Ce endpoint fait 2 choses :
292
+ 1. Upload image vers Cloudflare R2
293
+ 2. Insert labels (predicted + user) dans NeonDB
294
+
295
+ ✅ Appelé uniquement quand l'utilisateur clique "Valider"
296
+ """
297
+
298
+ # Vérifications
299
+ if not db_engine:
300
+ raise HTTPException(status_code=503, detail="Database not available")
301
+
302
+ if not s3_client:
303
+ raise HTTPException(status_code=503, detail="Storage not available")
304
+
305
+ try:
306
+ # 1. Decode image base64
307
+ try:
308
+ image_bytes = base64.b64decode(annotation.image_base64)
309
+ except Exception as e:
310
+ raise HTTPException(status_code=400, detail=f"Invalid base64 image: {e}")
311
+
312
+ # 2. Generate unique filename
313
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
314
+ img_name = f"{timestamp}_{hash(annotation.image_base64) % 10000:04d}.jpg"
315
+ s3_key = f"collected/{img_name}"
316
+
317
+ # 3. Upload image to Cloudflare R2
318
+ print(f"📤 Upload vers R2 : {s3_key}")
319
+ try:
320
+ s3_client.put_object(
321
+ Bucket=R2_BUCKET_NAME,
322
+ Key=s3_key,
323
+ Body=image_bytes,
324
+ ContentType='image/jpeg'
325
+ )
326
+ print(f"✅ Upload R2 réussi : {img_name}")
327
+ except ClientError as e:
328
+ print(f"❌ Erreur upload R2 : {e}")
329
+ raise HTTPException(status_code=500, detail=f"R2 upload failed: {e}")
330
+
331
+ # 4. Insert labels in NeonDB
332
+ query = text("""
333
+ INSERT INTO emotion_labels
334
+ (img_name, s3_path,
335
+ predicted_boredom, predicted_confusion, predicted_engagement, predicted_frustration,
336
+ user_boredom, user_confusion, user_engagement, user_frustration,
337
+ source, is_validated, timestamp)
338
+ VALUES
339
+ (:img_name, :s3_path,
340
+ :pred_boredom, :pred_confusion, :pred_engagement, :pred_frustration,
341
+ :user_boredom, :user_confusion, :user_engagement, :user_frustration,
342
+ 'app_sourcing', TRUE, :timestamp)
343
+ """)
344
+
345
+ with db_engine.connect() as conn:
346
+ conn.execute(query, {
347
+ 'img_name': img_name,
348
+ 's3_path': s3_key,
349
+ 'pred_boredom': annotation.predicted_boredom,
350
+ 'pred_confusion': annotation.predicted_confusion,
351
+ 'pred_engagement': annotation.predicted_engagement,
352
+ 'pred_frustration': annotation.predicted_frustration,
353
+ 'user_boredom': annotation.user_boredom,
354
+ 'user_confusion': annotation.user_confusion,
355
+ 'user_engagement': annotation.user_engagement,
356
+ 'user_frustration': annotation.user_frustration,
357
+ 'timestamp': datetime.now()
358
+ })
359
+ conn.commit()
360
+
361
+ print(f"✅ Insert NeonDB réussi : {img_name}")
362
+
363
+ # 5. Generate public URL (si tu as activé l'accès public)
364
+ # public_url = f"https://pub-{R2_ACCOUNT_ID}.r2.dev/{s3_key}"
365
+ # Ou None si pas d'accès public
366
+ public_url = None
367
+
368
+ return InsertResponse(
369
+ status="success",
370
+ message="Image uploaded to R2 and labels saved to NeonDB",
371
+ img_name=img_name,
372
+ s3_url=public_url
373
+ )
374
+
375
+ except SQLAlchemyError as e:
376
+ print(f"❌ Erreur NeonDB : {e}")
377
+ raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
378
+
379
+ except Exception as e:
380
+ print(f"❌ Erreur insert : {e}")
381
+ raise HTTPException(status_code=500, detail=str(e))
382
+
383
+ @app.get("/load", response_model=LoadResponse)
384
+ async def load_data(limit: int = 10):
385
+ """
386
+ Charge les données depuis NeonDB
387
+
388
+ Retourne :
389
+ - Nombre total d'échantillons
390
+ - Nombre d'échantillons validés
391
+ - Dernières prédictions (avec corrections utilisateur)
392
+ - Statistiques globales
393
+ """
394
+
395
+ if not db_engine:
396
+ raise HTTPException(status_code=503, detail="Database not available")
397
+
398
+ try:
399
+ with db_engine.connect() as conn:
400
+ # Total samples
401
+ total = conn.execute(text(
402
+ "SELECT COUNT(*) FROM emotion_labels"
403
+ )).scalar()
404
+
405
+ # Validated samples (ceux insérés via /insert)
406
+ validated = conn.execute(text(
407
+ "SELECT COUNT(*) FROM emotion_labels WHERE is_validated = TRUE"
408
+ )).scalar()
409
+
410
+ # Recent predictions
411
+ recent = conn.execute(text(f"""
412
+ SELECT
413
+ img_name,
414
+ s3_path,
415
+ predicted_boredom,
416
+ predicted_confusion,
417
+ predicted_engagement,
418
+ predicted_frustration,
419
+ user_boredom,
420
+ user_confusion,
421
+ user_engagement,
422
+ user_frustration,
423
+ timestamp
424
+ FROM emotion_labels
425
+ WHERE is_validated = TRUE
426
+ ORDER BY timestamp DESC
427
+ LIMIT :limit
428
+ """), {'limit': limit}).fetchall()
429
+
430
+ recent_list = [
431
+ {
432
+ 'img_name': row[0],
433
+ 's3_path': row[1],
434
+ 'predicted': {
435
+ 'boredom': float(row[2]),
436
+ 'confusion': float(row[3]),
437
+ 'engagement': float(row[4]),
438
+ 'frustration': float(row[5])
439
+ },
440
+ 'user_corrected': {
441
+ 'boredom': float(row[6]),
442
+ 'confusion': float(row[7]),
443
+ 'engagement': float(row[8]),
444
+ 'frustration': float(row[9])
445
+ },
446
+ 'timestamp': row[10].isoformat() if row[10] else None
447
+ }
448
+ for row in recent
449
+ ]
450
+
451
+ # Statistics (moyennes)
452
+ stats = conn.execute(text("""
453
+ SELECT
454
+ AVG(predicted_boredom) as avg_pred_boredom,
455
+ AVG(predicted_confusion) as avg_pred_confusion,
456
+ AVG(predicted_engagement) as avg_pred_engagement,
457
+ AVG(predicted_frustration) as avg_pred_frustration,
458
+ AVG(user_boredom) as avg_user_boredom,
459
+ AVG(user_confusion) as avg_user_confusion,
460
+ AVG(user_engagement) as avg_user_engagement,
461
+ AVG(user_frustration) as avg_user_frustration
462
+ FROM emotion_labels
463
+ WHERE is_validated = TRUE
464
+ """)).fetchone()
465
+
466
+ statistics = {
467
+ 'predictions': {
468
+ 'boredom': round(float(stats[0] or 0), 2),
469
+ 'confusion': round(float(stats[1] or 0), 2),
470
+ 'engagement': round(float(stats[2] or 0), 2),
471
+ 'frustration': round(float(stats[3] or 0), 2)
472
+ },
473
+ 'user_corrections': {
474
+ 'boredom': round(float(stats[4] or 0), 2),
475
+ 'confusion': round(float(stats[5] or 0), 2),
476
+ 'engagement': round(float(stats[6] or 0), 2),
477
+ 'frustration': round(float(stats[7] or 0), 2)
478
+ }
479
+ }
480
+
481
+ return LoadResponse(
482
+ total_samples=total or 0,
483
+ validated_samples=validated or 0,
484
+ recent_predictions=recent_list,
485
+ statistics=statistics
486
+ )
487
+
488
+ except SQLAlchemyError as e:
489
+ raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
490
+
491
+ # ============================================================================
492
+ # MAIN
493
+ # ============================================================================
494
+
495
+ if __name__ == "__main__":
496
+ import uvicorn
497
+ uvicorn.run(app, host="0.0.0.0", port=8000)
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Wakee API Requirements (Python 3.11)
2
+
3
+ # FastAPI
4
+ fastapi==0.109.0
5
+ uvicorn[standard]==0.27.0
6
+ python-multipart==0.0.6
7
+
8
+ # HuggingFace
9
+ huggingface-hub==0.20.3
10
+
11
+ # ML
12
+ onnxruntime==1.16.3
13
+ torch==2.1.2
14
+ torchvision==0.16.2
15
+ Pillow==10.2.0
16
+ numpy==1.26.3
17
+
18
+ # Database
19
+ sqlalchemy==2.0.25
20
+ psycopg2-binary==2.9.9
21
+
22
+ # Cloud Storage
23
+ boto3==1.34.34