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

🚀 Deploy from GitHub Actions - 2026-02-03 10:48:14

Browse files
Files changed (2) hide show
  1. README.md +2 -2
  2. app.py +253 -1
README.md CHANGED
@@ -5,7 +5,7 @@ colorFrom: blue
5
  colorTo: green
6
  sdk: docker
7
  pinned: false
8
- license: apache-2.0
9
  ---
10
 
11
  # 🧠 Wakee Emotion Detection API
@@ -102,7 +102,7 @@ L'API nécessite les secrets suivants (configurés dans les Settings du Space) :
102
 
103
  ## 📄 License
104
 
105
- MIT
106
 
107
  ---
108
 
 
5
  colorTo: green
6
  sdk: docker
7
  pinned: false
8
+ license: mit
9
  ---
10
 
11
  # 🧠 Wakee Emotion Detection API
 
102
 
103
  ## 📄 License
104
 
105
+ mit
106
 
107
  ---
108
 
app.py CHANGED
@@ -278,7 +278,259 @@ async def predict_emotion(file: UploadFile = File(...)):
278
  print(f"❌ Erreur prédiction : {e}")
279
  raise HTTPException(status_code=500, detail=str(e))
280
 
281
- # (reste des endpoints /insert et /load identiques)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
 
283
  if __name__ == "__main__":
284
  import uvicorn
 
278
  print(f"❌ Erreur prédiction : {e}")
279
  raise HTTPException(status_code=500, detail=str(e))
280
 
281
+ @app.post("/predict", response_model=PredictionResponse)
282
+ async def predict_emotion(file: UploadFile = File(...)):
283
+ """
284
+ Prédiction des 4 émotions depuis une image
285
+
286
+ ⚠️ RIEN N'EST SAUVEGARDÉ à cette étape
287
+
288
+ L'utilisateur doit ensuite appeler /insert pour sauvegarder
289
+ """
290
+
291
+ if not onnx_session:
292
+ raise HTTPException(
293
+ status_code=503,
294
+ detail="Model not loaded"
295
+ )
296
+
297
+ if not file.content_type.startswith('image/'):
298
+ raise HTTPException(status_code=400, detail="File must be an image")
299
+
300
+ try:
301
+ # 1. Load image
302
+ image_bytes = await file.read()
303
+ image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
304
+
305
+ # 2. Preprocessing
306
+ input_tensor = preprocess_image(image)
307
+
308
+ # 3. Inference ONNX
309
+ outputs = onnx_session.run(['output'], {'input': input_tensor})
310
+ scores_array = outputs[0][0]
311
+
312
+ # 4. Format résultats
313
+ return PredictionResponse(
314
+ boredom=round(float(scores_array[0]), 2),
315
+ confusion=round(float(scores_array[1]), 2),
316
+ engagement=round(float(scores_array[2]), 2),
317
+ frustration=round(float(scores_array[3]), 2),
318
+ timestamp=datetime.now().isoformat()
319
+ )
320
+
321
+ # ⚠️ PAS de sauvegarde R2
322
+ # ⚠️ PAS de sauvegarde NeonDB
323
+ # → L'utilisateur décide s'il valide via /insert
324
+
325
+ except Exception as e:
326
+ print(f"❌ Erreur prédiction : {e}")
327
+ raise HTTPException(status_code=500, detail=str(e))
328
+
329
+ @app.post("/insert", response_model=InsertResponse)
330
+ async def insert_annotation(annotation: AnnotationInsert):
331
+ """
332
+ Insert annotation utilisateur
333
+
334
+ Ce endpoint fait 2 choses :
335
+ 1. Upload image vers Cloudflare R2
336
+ 2. Insert labels (predicted + user) dans NeonDB
337
+
338
+ ✅ Appelé uniquement quand l'utilisateur clique "Valider"
339
+ """
340
+
341
+ # Vérifications
342
+ if not db_engine:
343
+ raise HTTPException(status_code=503, detail="Database not available")
344
+
345
+ if not s3_client:
346
+ raise HTTPException(status_code=503, detail="Storage not available")
347
+
348
+ try:
349
+ # 1. Decode image base64
350
+ try:
351
+ image_bytes = base64.b64decode(annotation.image_base64)
352
+ except Exception as e:
353
+ raise HTTPException(status_code=400, detail=f"Invalid base64 image: {e}")
354
+
355
+ # 2. Generate unique filename
356
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
357
+ img_name = f"{timestamp}_{hash(annotation.image_base64) % 10000:04d}.jpg"
358
+ s3_key = f"collected/{img_name}"
359
+
360
+ # 3. Upload image to Cloudflare R2
361
+ print(f"📤 Upload vers R2 : {s3_key}")
362
+ try:
363
+ s3_client.put_object(
364
+ Bucket=R2_BUCKET_NAME,
365
+ Key=s3_key,
366
+ Body=image_bytes,
367
+ ContentType='image/jpeg'
368
+ )
369
+ print(f"✅ Upload R2 réussi : {img_name}")
370
+ except ClientError as e:
371
+ print(f"❌ Erreur upload R2 : {e}")
372
+ raise HTTPException(status_code=500, detail=f"R2 upload failed: {e}")
373
+
374
+ # 4. Insert labels in NeonDB
375
+ query = text("""
376
+ INSERT INTO emotion_labels
377
+ (img_name, s3_path,
378
+ predicted_boredom, predicted_confusion, predicted_engagement, predicted_frustration,
379
+ user_boredom, user_confusion, user_engagement, user_frustration,
380
+ source, is_validated, timestamp)
381
+ VALUES
382
+ (:img_name, :s3_path,
383
+ :pred_boredom, :pred_confusion, :pred_engagement, :pred_frustration,
384
+ :user_boredom, :user_confusion, :user_engagement, :user_frustration,
385
+ 'app_sourcing', TRUE, :timestamp)
386
+ """)
387
+
388
+ with db_engine.connect() as conn:
389
+ conn.execute(query, {
390
+ 'img_name': img_name,
391
+ 's3_path': s3_key,
392
+ 'pred_boredom': annotation.predicted_boredom,
393
+ 'pred_confusion': annotation.predicted_confusion,
394
+ 'pred_engagement': annotation.predicted_engagement,
395
+ 'pred_frustration': annotation.predicted_frustration,
396
+ 'user_boredom': annotation.user_boredom,
397
+ 'user_confusion': annotation.user_confusion,
398
+ 'user_engagement': annotation.user_engagement,
399
+ 'user_frustration': annotation.user_frustration,
400
+ 'timestamp': datetime.now()
401
+ })
402
+ conn.commit()
403
+
404
+ print(f"✅ Insert NeonDB réussi : {img_name}")
405
+
406
+ # 5. Generate public URL (si tu as activé l'accès public)
407
+ # public_url = f"https://pub-{R2_ACCOUNT_ID}.r2.dev/{s3_key}"
408
+ # Ou None si pas d'accès public
409
+ public_url = None
410
+
411
+ return InsertResponse(
412
+ status="success",
413
+ message="Image uploaded to R2 and labels saved to NeonDB",
414
+ img_name=img_name,
415
+ s3_url=public_url
416
+ )
417
+
418
+ except SQLAlchemyError as e:
419
+ print(f"❌ Erreur NeonDB : {e}")
420
+ raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
421
+
422
+ except Exception as e:
423
+ print(f"❌ Erreur insert : {e}")
424
+ raise HTTPException(status_code=500, detail=str(e))
425
+
426
+ @app.get("/load", response_model=LoadResponse)
427
+ async def load_data(limit: int = 10):
428
+ """
429
+ Charge les données depuis NeonDB
430
+
431
+ Retourne :
432
+ - Nombre total d'échantillons
433
+ - Nombre d'échantillons validés
434
+ - Dernières prédictions (avec corrections utilisateur)
435
+ - Statistiques globales
436
+ """
437
+
438
+ if not db_engine:
439
+ raise HTTPException(status_code=503, detail="Database not available")
440
+
441
+ try:
442
+ with db_engine.connect() as conn:
443
+ # Total samples
444
+ total = conn.execute(text(
445
+ "SELECT COUNT(*) FROM emotion_labels"
446
+ )).scalar()
447
+
448
+ # Validated samples (ceux insérés via /insert)
449
+ validated = conn.execute(text(
450
+ "SELECT COUNT(*) FROM emotion_labels WHERE is_validated = TRUE"
451
+ )).scalar()
452
+
453
+ # Recent predictions
454
+ recent = conn.execute(text(f"""
455
+ SELECT
456
+ img_name,
457
+ s3_path,
458
+ predicted_boredom,
459
+ predicted_confusion,
460
+ predicted_engagement,
461
+ predicted_frustration,
462
+ user_boredom,
463
+ user_confusion,
464
+ user_engagement,
465
+ user_frustration,
466
+ timestamp
467
+ FROM emotion_labels
468
+ WHERE is_validated = TRUE
469
+ ORDER BY timestamp DESC
470
+ LIMIT :limit
471
+ """), {'limit': limit}).fetchall()
472
+
473
+ recent_list = [
474
+ {
475
+ 'img_name': row[0],
476
+ 's3_path': row[1],
477
+ 'predicted': {
478
+ 'boredom': float(row[2]),
479
+ 'confusion': float(row[3]),
480
+ 'engagement': float(row[4]),
481
+ 'frustration': float(row[5])
482
+ },
483
+ 'user_corrected': {
484
+ 'boredom': float(row[6]),
485
+ 'confusion': float(row[7]),
486
+ 'engagement': float(row[8]),
487
+ 'frustration': float(row[9])
488
+ },
489
+ 'timestamp': row[10].isoformat() if row[10] else None
490
+ }
491
+ for row in recent
492
+ ]
493
+
494
+ # Statistics (moyennes)
495
+ stats = conn.execute(text("""
496
+ SELECT
497
+ AVG(predicted_boredom) as avg_pred_boredom,
498
+ AVG(predicted_confusion) as avg_pred_confusion,
499
+ AVG(predicted_engagement) as avg_pred_engagement,
500
+ AVG(predicted_frustration) as avg_pred_frustration,
501
+ AVG(user_boredom) as avg_user_boredom,
502
+ AVG(user_confusion) as avg_user_confusion,
503
+ AVG(user_engagement) as avg_user_engagement,
504
+ AVG(user_frustration) as avg_user_frustration
505
+ FROM emotion_labels
506
+ WHERE is_validated = TRUE
507
+ """)).fetchone()
508
+
509
+ statistics = {
510
+ 'predictions': {
511
+ 'boredom': round(float(stats[0] or 0), 2),
512
+ 'confusion': round(float(stats[1] or 0), 2),
513
+ 'engagement': round(float(stats[2] or 0), 2),
514
+ 'frustration': round(float(stats[3] or 0), 2)
515
+ },
516
+ 'user_corrections': {
517
+ 'boredom': round(float(stats[4] or 0), 2),
518
+ 'confusion': round(float(stats[5] or 0), 2),
519
+ 'engagement': round(float(stats[6] or 0), 2),
520
+ 'frustration': round(float(stats[7] or 0), 2)
521
+ }
522
+ }
523
+
524
+ return LoadResponse(
525
+ total_samples=total or 0,
526
+ validated_samples=validated or 0,
527
+ recent_predictions=recent_list,
528
+ statistics=statistics
529
+ )
530
+
531
+ except SQLAlchemyError as e:
532
+ raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
533
+
534
 
535
  if __name__ == "__main__":
536
  import uvicorn