danielle2035 commited on
Commit
122447d
·
1 Parent(s): 0db0cc7

fix: rename requirements_hf.txt to requirements.txt

Browse files
Files changed (1) hide show
  1. app.py +200 -328
app.py CHANGED
@@ -1,7 +1,5 @@
1
  """
2
- app.py — TrackIQ Backend (v5COMPLET avec Dashboard + Logs + History)
3
- CORRECTIONS v10: /api/logs/rows, DELETE /api/logs/clear, format CSV prof,
4
- unique tracking, counts dans /api/history
5
  """
6
  import cv2, csv, json, threading, queue, uuid, mimetypes, subprocess, io
7
  from pathlib import Path
@@ -18,45 +16,46 @@ from ultralytics import YOLO
18
  app = Flask(__name__, static_folder="static", template_folder="templates")
19
  CORS(app)
20
 
21
- BASE = Path(__file__).parent
22
- UPLOAD_DIR = BASE / "uploads"; UPLOAD_DIR.mkdir(exist_ok=True)
23
- OUTPUT_DIR = BASE / "outputs"; OUTPUT_DIR.mkdir(exist_ok=True)
24
- LOG_DIR = BASE / "logs"; LOG_DIR.mkdir(exist_ok=True)
25
- MODELS_DIR = BASE / "models"; MODELS_DIR.mkdir(exist_ok=True)
26
 
27
- # Mapping COCO réelnom affiché
 
28
  COCO_TO_LABEL = {
29
- 0: "Person",
30
- 1: "Bicycle",
31
- 2: "Vehicle",
32
- 3: "Motorcycle",
33
- 5: "Bus",
34
- 7: "Truck",
35
- 9: "Traffic light",
36
  11: "Road sign",
37
  }
38
- # Remap Person → Human pour cohérence avec le frontend
39
- LABEL_REMAP = {"Person": "Human"}
40
- DEFAULT_CLASSES = ["Vehicle","Motorcycle","Truck","Bus","Human","Bicycle","Traffic light","Road sign"]
41
  CLASS_COLORS = {
42
- "Vehicle": (255,120,80),
43
- "Motorcycle": (80, 200,80),
44
- "Truck": (0, 180,255),
45
- "Bus": (220,80, 255),
46
- "Human": (236,72, 153),
47
- "Bicycle": (6, 182,212),
48
- "Traffic light": (239,68, 68),
49
- "Road sign": (249,115,22),
50
  }
51
 
52
- CONF = 0.40
 
53
  IOU = 0.45
54
- SKIP = 2 # traiter 1 frame sur (SKIP+1) pour la vitesse
55
- INFER_SZ = 320
56
 
57
- _jobs: Dict[str,dict] = {}
58
- _sse_queues: Dict[str,queue.Queue] = {}
59
- _stats_lock = threading.Lock()
60
  _global_stats = {
61
  "total_frames": 0,
62
  "total_detections": 0,
@@ -64,20 +63,15 @@ _global_stats = {
64
  "scenes": []
65
  }
66
 
67
- # FIX: stockage des rows détaillées (format prof) en mémoire
68
- _log_rows = []
69
- _log_rows_lock = threading.Lock()
70
-
71
  # ── Webcam state ──────────────────────────────────────────────────────────────
72
- _webcam_active = False
73
- _webcam_classes = []
74
- _webcam_frame_count = 0
75
- _webcam_detections = 0
76
  _webcam_detections_by_class = defaultdict(int)
77
- _webcam_unique_ids = defaultdict(set)
78
- _webcam_lock = threading.Lock()
79
 
80
- # ── Model ─────────────────────────────────────────────────────────────────────
81
  _model = None
82
  _model_ready = False
83
 
@@ -88,71 +82,81 @@ def _load_model_background():
88
  _model_ready = True
89
  print("✅ Modèle YOLO chargé !")
90
 
91
- def _get_model(key="yolo11n"):
 
92
  p = MODELS_DIR / f"{key}.pt"
93
  if not p.exists():
94
  m = YOLO(f"{key}.pt")
95
  import shutil
96
  dl = Path(f"{key}.pt")
97
- if dl.exists(): shutil.move(str(dl), str(p))
 
98
  return m
99
  return YOLO(str(p))
100
 
 
101
  threading.Thread(target=_load_model_background, daemon=True).start()
102
 
103
- # ── Helpers ───────────────────────────────────────────────────────────────────
104
- def _coco_to_frontend(cls_idx):
105
- name = COCO_TO_LABEL.get(cls_idx)
106
- if name is None:
107
- return None
108
- return LABEL_REMAP.get(name, name)
109
-
110
  def _draw_detections(frame, results, classes_filter=None):
 
111
  detection_count = 0
 
112
  if results and results[0].boxes:
113
  boxes = results[0].boxes
114
  for box in boxes:
115
- cls = int(box.cls[0])
116
- conf = float(box.conf[0])
117
- class_name = _coco_to_frontend(cls)
 
118
  if class_name is None:
119
  continue
 
120
  if classes_filter and class_name not in classes_filter:
121
  continue
 
122
  detection_count += 1
123
- x1,y1,x2,y2 = map(int, box.xyxy[0])
124
- color = CLASS_COLORS.get(class_name, (255,255,255))
125
  label = f"{class_name} {conf:.2f}"
126
- cv2.rectangle(frame, (x1,y1), (x2,y2), color, 2)
127
- cv2.putText(frame, label, (x1, max(y1-5,0)),
128
  cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
 
129
  return frame, detection_count
130
 
131
- # ── Pages HTML ───────────────────────────────────────────────────────────────
 
132
  @app.route("/")
133
- def index(): return render_template("index.html")
 
134
 
135
  @app.route("/home")
136
- def home(): return render_template("home.html")
 
137
 
138
  @app.route("/dashboard")
139
- def dashboard(): return render_template("dashboard.html")
 
140
 
141
  @app.route("/history")
142
- def history(): return render_template("history.html")
 
143
 
144
  @app.route("/logs")
145
- def logs(): return render_template("logs.html")
 
146
 
147
  @app.route("/static/<path:filename>")
148
- def serve_static(filename): return send_from_directory("static", filename)
 
 
 
149
 
150
- # ── Health ────────────────────────────────────────────────────────────────────
151
  @app.route("/health")
152
  def health():
153
  return jsonify({"status": "ok", "model_ready": _model_ready}), 200
154
 
155
- # ── Upload ────────────────────────────────────────────────────────────────────
156
  @app.route("/api/upload", methods=["POST"])
157
  def api_upload():
158
  if "video" not in request.files:
@@ -166,12 +170,10 @@ def api_upload():
166
  "path": str(dest),
167
  "name": f.filename,
168
  "detections": defaultdict(int),
169
- "frames": 0,
170
- "classes": DEFAULT_CLASSES,
171
  }
172
  return jsonify({"job_id": jid})
173
 
174
- # ── Run ───────────────────────────────────────────────────────────────────────
175
  @app.route("/api/run", methods=["POST"])
176
  def api_run():
177
  data = request.json or {}
@@ -180,181 +182,60 @@ def api_run():
180
  return jsonify({"error": "Unknown job_id"}), 404
181
  if not _model_ready:
182
  return jsonify({"error": "Model not ready yet, please wait"}), 503
183
- _jobs[jid]["classes"] = data.get("classes", DEFAULT_CLASSES)
184
- _sse_queues[jid] = queue.Queue(maxsize=300)
185
  threading.Thread(target=_worker, args=(jid,), daemon=True).start()
186
  return jsonify({"status": "started"})
187
 
188
- # ── Worker ────────────────────────────────────────────────────────────────────
189
  def _worker(jid):
190
- global _log_rows
191
- job = _jobs[jid]
192
  job["status"] = "running"
193
- q = _sse_queues[jid]
194
- path = job["path"]
195
- video_name = job["name"]
196
- sel_classes = job.get("classes", DEFAULT_CLASSES)
197
- scene_name = Path(video_name).stem or jid
198
-
199
- cap = cv2.VideoCapture(path)
200
- fps = cap.get(cv2.CAP_PROP_FPS) or 30
201
- W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
202
- H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
203
- total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 1
204
-
205
- unique_ids = defaultdict(set) # FIX: track unique IDs per class
206
- hist_xy = defaultdict(list) # FIX: history for direction/speed
207
- timeline = []
208
- new_rows = []
209
- frame_id = 0
210
 
211
  while True:
212
  ret, frame = cap.read()
213
  if not ret:
214
  break
215
- frame_id += 1
216
- if frame_id % (SKIP + 1) != 0:
217
- continue
218
 
219
- ts = round(frame_id / fps, 3)
220
-
221
- # FIX: persist=True pour IDs stables entre frames
222
- results = _model.track(frame, conf=CONF, iou=IOU, imgsz=INFER_SZ,
223
- persist=True, verbose=False)
224
-
225
- frame_counts = defaultdict(int)
226
- if results[0].boxes is not None:
227
- boxes = results[0].boxes
228
- has_ids = boxes.id is not None
229
- for i, box in enumerate(boxes):
230
  cls = int(box.cls[0])
231
- class_name = _coco_to_frontend(cls)
232
- if not class_name or class_name not in sel_classes:
233
- continue
234
- conf_val = float(box.conf[0])
235
- x1,y1,x2,y2 = map(int, box.xyxy[0])
236
- cx = (x1+x2)//2
237
- cy = (y1+y2)//2
238
- tid = int(boxes.id[i]) if has_ids else -1
239
- unique_ids[class_name].add(tid)
240
- frame_counts[class_name] += 1
241
-
242
- # Direction & speed
243
- h = hist_xy[tid]
244
- h.append((cx,cy))
245
- direction = "unknown"
246
- speed_px_s = 0.0
247
- if len(h) >= 2:
248
- dx = h[-1][0]-h[-2][0]
249
- dy = h[-1][1]-h[-2][1]
250
- dist = (dx**2+dy**2)**0.5
251
- speed_px_s = round(dist*fps/(SKIP+1), 2)
252
- direction = ("right" if dx>0 else "left") if abs(dx)>abs(dy) else ("down" if dy>0 else "up")
253
-
254
- # FIX: format CSV du prof
255
- new_rows.append({
256
- "scene_id": jid,
257
- "scene_name": scene_name,
258
- "video_name": video_name,
259
- "frame_id": frame_id,
260
- "timestamp_s": ts,
261
- "track_id": tid,
262
- "class_name": class_name,
263
- "confidence": round(conf_val, 4),
264
- "x1":x1,"y1":y1,"x2":x2,"y2":y2,
265
- "cx":cx,"cy":cy,
266
- "frame_width": W,
267
- "frame_height": H,
268
- "direction": direction,
269
- "speed_px_s": speed_px_s,
270
- "crossed_line": False,
271
- })
272
-
273
- timeline.append({"frame":frame_id,"ts":round(frame_id/fps,2),**dict(frame_counts)})
274
- uc = {k:len(v) for k,v in unique_ids.items()}
275
- pct = min(99.0, frame_id/total*100)
276
- try:
277
- q.put_nowait({
278
- "event": "progress", "pct": round(pct,1),
279
- "frame": frame_id, "total": total,
280
- "unique_counts": uc, "no_objects": len(frame_counts)==0,
281
- "hud": {"frame_str": f"F:{frame_id}/{total}", "counts": dict(frame_counts)},
282
- })
283
- except queue.Full:
284
- pass
285
 
286
  cap.release()
287
-
288
- with _log_rows_lock:
289
- _log_rows.extend(new_rows)
290
-
291
- final_unique = {k:len(v) for k,v in unique_ids.items()}
292
- stats = {
293
- "total_frames": frame_id,
294
- "total_unique": sum(final_unique.values()),
295
- "fps": round(fps,1),
296
- "duration_s": round(frame_id/fps,1),
297
- "unique_counts": final_unique,
298
- "video_name": video_name,
299
- "scene_name": scene_name,
300
- "timeline": timeline,
301
- }
302
  job["status"] = "done"
303
- job["frames"] = frame_id
304
- job["detections"] = final_unique
305
- job["stats"] = stats
306
-
307
- try: q.put_nowait({"event":"done","stats":stats})
308
- except queue.Full: pass
309
 
310
  with _stats_lock:
311
- _global_stats["total_frames"] += frame_id
312
- _global_stats["total_detections"] += sum(final_unique.values())
313
- for cls,cnt in final_unique.items():
314
  _global_stats["detections_by_class"][cls] += cnt
 
315
  _global_stats["scenes"].append({
316
- "scene_id": jid,
317
- "scene_name": scene_name,
318
- "video_name": video_name,
319
- "frames": frame_id,
320
- "total": sum(final_unique.values()),
321
- "unique_counts": final_unique,
322
- "generated_at": datetime.now().isoformat(),
323
- "duration_s": round(frame_id/fps,1),
324
- "fps": round(fps,1),
325
- "timeline": timeline,
326
  })
327
 
328
- # ── SSE ───────────────────────────────────────────────────────────────────────
329
- @app.route("/api/stream/<jid>")
330
- def api_stream(jid):
331
- if jid not in _jobs:
332
- return jsonify({"error":"not found"}),404
333
- def generate():
334
- import time
335
- deadline=time.time()+15
336
- while jid not in _sse_queues:
337
- if time.time()>deadline: yield 'data:{"event":"error"}\n\n'; return
338
- time.sleep(0.05)
339
- q=_sse_queues[jid]
340
- while True:
341
- try:
342
- item=q.get(timeout=60)
343
- yield f"data:{json.dumps(item)}\n\n"
344
- if item.get("event")=="done": break
345
- except: break
346
- return Response(stream_with_context(generate()),
347
- mimetype="text/event-stream",
348
- headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"})
349
-
350
- # ── Status ────────────────────────────────────────────────────────────────────
351
  @app.route("/api/status/<jid>")
352
  def api_status(jid):
353
- job=_jobs.get(jid)
354
- if not job: return jsonify({"error":"not found"}),404
355
- return jsonify({"status":job["status"],"stats":job.get("stats",{}),"frames":job.get("frames",0)})
 
356
 
357
  # ── Dashboard ─────────────────────────────────────────────────────────────────
 
358
  @app.route("/api/dashboard/stats")
359
  def api_dashboard_stats():
360
  with _stats_lock:
@@ -362,147 +243,138 @@ def api_dashboard_stats():
362
  "global_unique_counts": dict(_global_stats["detections_by_class"]),
363
  "scenes": _global_stats["scenes"],
364
  "total_frames": _global_stats["total_frames"],
365
- "total_detections": _global_stats["total_detections"],
366
- }),200
367
 
368
  # ── Logs ──────────────────────────────────────────────────────────────────────
 
369
  @app.route("/api/logs")
370
  def api_logs():
371
  with _stats_lock:
372
- return jsonify(_global_stats["scenes"]),200
373
-
374
- # FIX: route /api/logs/rows — retourne toutes les rows détaillées
375
- @app.route("/api/logs/rows")
376
- def api_logs_rows():
377
- scene_id = request.args.get("scene_id")
378
- with _log_rows_lock:
379
- rows = [r for r in _log_rows if r["scene_id"]==scene_id] if scene_id else list(_log_rows)
380
- return jsonify(rows),200
381
-
382
- # FIX: DELETE /api/logs/clear — vide tout
383
- @app.route("/api/logs/clear", methods=["DELETE"])
384
- def api_logs_clear():
385
- with _stats_lock:
386
- _global_stats["scenes"].clear()
387
- _global_stats["total_frames"] = 0
388
- _global_stats["total_detections"] = 0
389
- _global_stats["detections_by_class"].clear()
390
- with _log_rows_lock:
391
- _log_rows.clear()
392
- return jsonify({"status":"cleared"}),200
393
-
394
- # FIX: DELETE /api/logs/<scene_id> — supprime une scène
395
- @app.route("/api/logs/<scene_id>", methods=["DELETE"])
396
- def api_logs_delete(scene_id):
397
- with _stats_lock:
398
- _global_stats["scenes"] = [s for s in _global_stats["scenes"] if s["scene_id"]!=scene_id]
399
- with _log_rows_lock:
400
- before = len(_log_rows)
401
- _log_rows[:] = [r for r in _log_rows if r["scene_id"]!=scene_id]
402
- return jsonify({"deleted": before-len(_log_rows)}),200
403
 
404
- # FIX: CSV format prof
405
  @app.route("/api/logs/<scene_id>/csv")
406
  def api_logs_csv(scene_id):
407
- with _log_rows_lock:
408
- rows = [r for r in _log_rows if r["scene_id"]==scene_id]
409
- out = io.StringIO()
410
- w = csv.writer(out)
411
- # En-têtes format prof
412
- w.writerow(["frame","timestamp_sec","scene_name","group_id","video_name",
413
- "track_id","class_name","confidence",
414
- "bbox_x1","bbox_y1","bbox_x2","bbox_y2",
415
- "cx","cy","frame_width","frame_height",
416
- "crossed_line","direction","speed_px_s"])
417
- for r in rows:
418
- w.writerow([r["frame_id"],r["timestamp_s"],r["scene_name"],scene_id,
419
- r["video_name"],r["track_id"],r["class_name"],r["confidence"],
420
- r["x1"],r["y1"],r["x2"],r["y2"],
421
- r["cx"],r["cy"],r.get("frame_width",0),r.get("frame_height",0),
422
- r.get("crossed_line",False),r["direction"],r["speed_px_s"]])
423
- return Response(out.getvalue(), mimetype="text/csv",
424
- headers={"Content-Disposition":f"attachment;filename={scene_id}_logs.csv"})
425
 
426
  # ── History ───────────────────────────────────────────────────────────────────
 
427
  @app.route("/api/history")
428
  def api_history():
429
  with _stats_lock:
430
- return jsonify([{
431
- "name": s["video_name"],
432
- "url": f"/api/logs/{s['scene_id']}/csv",
433
- "date": s["generated_at"],
434
- "counts": s.get("unique_counts",{}), # FIX: inclus pour le HUD history.html
435
- } for s in reversed(_global_stats["scenes"])]),200
436
-
437
- # ── Webcam routes ─────────────────────────────────────────────────────────────
 
 
 
438
  @app.route("/api/webcam/start", methods=["POST"])
439
  def api_webcam_start():
440
- global _webcam_active,_webcam_classes,_webcam_frame_count,_webcam_detections,_webcam_detections_by_class,_webcam_unique_ids
441
- data=request.json or {}
442
- if not _model_ready: return jsonify({"error":"Model not ready"}),503
 
 
 
 
 
 
443
  with _webcam_lock:
444
- _webcam_active=True
445
- _webcam_classes=data.get("classes",DEFAULT_CLASSES)
446
- _webcam_frame_count=0
447
- _webcam_detections=0
448
- _webcam_detections_by_class=defaultdict(int)
449
- _webcam_unique_ids=defaultdict(set)
450
- return jsonify({"status":"started"}),200
 
451
 
452
  @app.route("/api/webcam/frame", methods=["POST"])
453
  def api_webcam_frame():
454
- global _webcam_frame_count,_webcam_detections,_webcam_detections_by_class,_webcam_unique_ids
 
455
  if not _webcam_active or not _model_ready:
456
- return jsonify({"error":"Webcam not active"}),503
 
457
  try:
458
- nparr = np.frombuffer(request.data, np.uint8)
 
 
 
 
459
  frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
460
- if frame is None: return jsonify({"error":"Decode failed"}),400
461
- # FIX: persist=True pour IDs stables webcam
462
- results = _model.track(frame, conf=CONF, iou=IOU, imgsz=INFER_SZ,
463
- persist=True, verbose=False)
464
- annotated, det_count = _draw_detections(frame, results, _webcam_classes)
 
 
 
 
465
  with _webcam_lock:
466
- _webcam_frame_count += 1
467
- _webcam_detections += det_count
468
- if results[0].boxes is not None:
469
- boxes = results[0].boxes
470
- has_ids = boxes.id is not None
471
- for i,box in enumerate(boxes):
472
- cls=int(box.cls[0])
473
- cn=_coco_to_frontend(cls)
474
- if cn and cn in _webcam_classes:
475
- _webcam_detections_by_class[cn] += 1
476
- if has_ids:
477
- _webcam_unique_ids[cn].add(int(boxes.id[i]))
478
- _,buf=cv2.imencode('.jpg', annotated)
479
- return Response(buf.tobytes(), mimetype='image/jpeg'),200
480
  except Exception as e:
481
- return jsonify({"error":str(e)}),500
 
482
 
483
  @app.route("/api/webcam/stop", methods=["POST"])
484
  def api_webcam_stop():
485
  global _webcam_active
486
- with _webcam_lock: _webcam_active=False
487
- return jsonify({"status":"stopped"}),200
 
 
488
 
489
  @app.route("/api/webcam/stats")
490
  def api_webcam_stats():
491
  with _webcam_lock:
492
- by_class = dict(_webcam_detections_by_class)
493
- unique_only = {k:len(v) for k,v in _webcam_unique_ids.items()}
494
- # FIX: current_counts = objets dans le frame actuel (unique_only ici)
495
  return jsonify({
496
- "active": _webcam_active,
497
- "frame": _webcam_frame_count,
498
- "detections": _webcam_detections,
499
- "detections_by_class": by_class,
500
- "frame_counts": unique_only, # live count par classe
501
- "unique_counts": unique_only, # alias
502
- }),200
503
 
504
  # ── Main ──────────────────────────────────────────────────────────────────────
 
505
  if __name__ == "__main__":
506
  import os
507
  port = int(os.environ.get("PORT", 7860))
508
- app.run(host="0.0.0.0", port=port, debug=False)
 
1
  """
2
+ app.py — TrackIQ Backend (v6CORRIGÉ : Human/Person, CONF, modèle)
 
 
3
  """
4
  import cv2, csv, json, threading, queue, uuid, mimetypes, subprocess, io
5
  from pathlib import Path
 
16
  app = Flask(__name__, static_folder="static", template_folder="templates")
17
  CORS(app)
18
 
19
+ BASE = Path(__file__).parent
20
+ UPLOAD_DIR = BASE / "uploads"; UPLOAD_DIR.mkdir(exist_ok=True)
21
+ OUTPUT_DIR = BASE / "outputs"; OUTPUT_DIR.mkdir(exist_ok=True)
22
+ LOG_DIR = BASE / "logs"; LOG_DIR.mkdir(exist_ok=True)
23
+ MODELS_DIR = BASE / "models"; MODELS_DIR.mkdir(exist_ok=True)
24
 
25
+ # ── CORRECTION 1 : "Person" "Human" pour matcher l'UI ─────────────────────
26
+ # Mapping COCO réel → nom affiché (index COCO officiel)
27
  COCO_TO_LABEL = {
28
+ 0: "Human", # ← WAS "Person" — doit correspondre à ce que l'UI envoie
29
+ 1: "Bicycle",
30
+ 2: "Vehicle",
31
+ 3: "Motorcycle",
32
+ 5: "Bus",
33
+ 7: "Truck",
34
+ 9: "Traffic light",
35
  11: "Road sign",
36
  }
37
+ DEFAULT_CLASSES = list(COCO_TO_LABEL.values())
38
+
 
39
  CLASS_COLORS = {
40
+ "Vehicle": (255, 120, 80),
41
+ "Motorcycle": (80, 200, 80),
42
+ "Truck": (0, 180, 255),
43
+ "Bus": (220, 80, 255),
44
+ "Human": (236, 72, 153), # ← WAS "Person"
45
+ "Bicycle": (6, 182, 212),
46
+ "Traffic light": (239, 68, 68),
47
+ "Road sign": (249, 115, 22),
48
  }
49
 
50
+ # ── CORRECTION 2 : seuil de confiance abaissé + résolution augmentée ─────────
51
+ CONF = 0.25 # ← WAS 0.40 (trop élevé pour webcam intérieure)
52
  IOU = 0.45
53
+ SKIP = 1
54
+ INFER_SZ = 640 # ← WAS 320 (plus de précision)
55
 
56
+ _jobs: Dict[str, dict] = {}
57
+ _sse_queues: Dict[str, queue.Queue] = {}
58
+ _stats_lock = threading.Lock()
59
  _global_stats = {
60
  "total_frames": 0,
61
  "total_detections": 0,
 
63
  "scenes": []
64
  }
65
 
 
 
 
 
66
  # ── Webcam state ──────────────────────────────────────────────────────────────
67
+ _webcam_active = False
68
+ _webcam_classes = []
69
+ _webcam_frame_count = 0
70
+ _webcam_detections = 0
71
  _webcam_detections_by_class = defaultdict(int)
72
+ _webcam_lock = threading.Lock()
 
73
 
74
+ # ── Chargement du modèle en arrière-plan ──────────────────────────────────────
75
  _model = None
76
  _model_ready = False
77
 
 
82
  _model_ready = True
83
  print("✅ Modèle YOLO chargé !")
84
 
85
+ # ── CORRECTION 3 : utiliser yolov8n (plus stable sur HuggingFace Spaces) ─────
86
+ def _get_model(key="yolov8n"): # ← WAS "yolo11n" (moins fiable sur HF)
87
  p = MODELS_DIR / f"{key}.pt"
88
  if not p.exists():
89
  m = YOLO(f"{key}.pt")
90
  import shutil
91
  dl = Path(f"{key}.pt")
92
+ if dl.exists():
93
+ shutil.move(str(dl), str(p))
94
  return m
95
  return YOLO(str(p))
96
 
97
+ # Lancer le chargement dès le démarrage
98
  threading.Thread(target=_load_model_background, daemon=True).start()
99
 
100
+ # ── Fonction utilitaire pour dessiner les détections ──────────────────────────
 
 
 
 
 
 
101
  def _draw_detections(frame, results, classes_filter=None):
102
+ """Dessine les boîtes de détection sur le frame"""
103
  detection_count = 0
104
+
105
  if results and results[0].boxes:
106
  boxes = results[0].boxes
107
  for box in boxes:
108
+ cls = int(box.cls[0])
109
+ conf = float(box.conf[0])
110
+
111
+ class_name = COCO_TO_LABEL.get(cls)
112
  if class_name is None:
113
  continue
114
+
115
  if classes_filter and class_name not in classes_filter:
116
  continue
117
+
118
  detection_count += 1
119
+ x1, y1, x2, y2 = map(int, box.xyxy[0])
120
+ color = CLASS_COLORS.get(class_name, (255, 255, 255))
121
  label = f"{class_name} {conf:.2f}"
122
+ cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
123
+ cv2.putText(frame, label, (x1, y1 - 5),
124
  cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
125
+
126
  return frame, detection_count
127
 
128
+ # ── Routes HTML ───────────────────────────────────────────────────────────────
129
+
130
  @app.route("/")
131
+ def index():
132
+ return render_template("index.html")
133
 
134
  @app.route("/home")
135
+ def home():
136
+ return render_template("home.html")
137
 
138
  @app.route("/dashboard")
139
+ def dashboard():
140
+ return render_template("dashboard.html")
141
 
142
  @app.route("/history")
143
+ def history():
144
+ return render_template("history.html")
145
 
146
  @app.route("/logs")
147
+ def logs():
148
+ return render_template("logs.html")
149
 
150
  @app.route("/static/<path:filename>")
151
+ def serve_static(filename):
152
+ return send_from_directory("static", filename)
153
+
154
+ # ── API Routes ────────────────────────────────────────────────────────────────
155
 
 
156
  @app.route("/health")
157
  def health():
158
  return jsonify({"status": "ok", "model_ready": _model_ready}), 200
159
 
 
160
  @app.route("/api/upload", methods=["POST"])
161
  def api_upload():
162
  if "video" not in request.files:
 
170
  "path": str(dest),
171
  "name": f.filename,
172
  "detections": defaultdict(int),
173
+ "frames": 0
 
174
  }
175
  return jsonify({"job_id": jid})
176
 
 
177
  @app.route("/api/run", methods=["POST"])
178
  def api_run():
179
  data = request.json or {}
 
182
  return jsonify({"error": "Unknown job_id"}), 404
183
  if not _model_ready:
184
  return jsonify({"error": "Model not ready yet, please wait"}), 503
 
 
185
  threading.Thread(target=_worker, args=(jid,), daemon=True).start()
186
  return jsonify({"status": "started"})
187
 
 
188
  def _worker(jid):
189
+ global _global_stats
190
+ job = _jobs[jid]
191
  job["status"] = "running"
192
+
193
+ cap = cv2.VideoCapture(job["path"])
194
+ frame_count = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
  while True:
197
  ret, frame = cap.read()
198
  if not ret:
199
  break
200
+ frame_count += 1
201
+ results = _model(frame, conf=CONF, iou=IOU, imgsz=INFER_SZ, verbose=False)
 
202
 
203
+ if results[0].boxes:
204
+ for box in results[0].boxes:
 
 
 
 
 
 
 
 
 
205
  cls = int(box.cls[0])
206
+ class_name = COCO_TO_LABEL.get(cls)
207
+ if class_name:
208
+ job["detections"][class_name] = job["detections"].get(class_name, 0) + 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
  cap.release()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  job["status"] = "done"
212
+ job["frames"] = frame_count
 
 
 
 
 
213
 
214
  with _stats_lock:
215
+ _global_stats["total_frames"] += frame_count
216
+ _global_stats["total_detections"] += sum(job["detections"].values())
217
+ for cls, cnt in job["detections"].items():
218
  _global_stats["detections_by_class"][cls] += cnt
219
+
220
  _global_stats["scenes"].append({
221
+ "scene_id": jid,
222
+ "video_name": job["name"],
223
+ "frames": frame_count,
224
+ "total": sum(job["detections"].values()),
225
+ "unique_counts": dict(job["detections"]),
226
+ "generated_at": datetime.now().isoformat(),
227
+ "duration_s": frame_count / 30
 
 
 
228
  })
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  @app.route("/api/status/<jid>")
231
  def api_status(jid):
232
+ job = _jobs.get(jid)
233
+ if not job:
234
+ return jsonify({"error": "not found"}), 404
235
+ return jsonify({"status": job["status"]})
236
 
237
  # ── Dashboard ─────────────────────────────────────────────────────────────────
238
+
239
  @app.route("/api/dashboard/stats")
240
  def api_dashboard_stats():
241
  with _stats_lock:
 
243
  "global_unique_counts": dict(_global_stats["detections_by_class"]),
244
  "scenes": _global_stats["scenes"],
245
  "total_frames": _global_stats["total_frames"],
246
+ "total_detections": _global_stats["total_detections"]
247
+ }), 200
248
 
249
  # ── Logs ──────────────────────────────────────────────────────────────────────
250
+
251
  @app.route("/api/logs")
252
  def api_logs():
253
  with _stats_lock:
254
+ return jsonify(_global_stats["scenes"]), 200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
 
 
256
  @app.route("/api/logs/<scene_id>/csv")
257
  def api_logs_csv(scene_id):
258
+ job = _jobs.get(scene_id)
259
+ if not job:
260
+ return jsonify({"error": "not found"}), 404
261
+
262
+ output = io.StringIO()
263
+ writer = csv.writer(output)
264
+ writer.writerow(["scene_id", "video_name", "class_name", "count",
265
+ "generated_at", "total_frames"])
266
+ for class_name, count in job.get("detections", {}).items():
267
+ writer.writerow([scene_id, job["name"], class_name, count,
268
+ datetime.now().isoformat(), job.get("frames", 0)])
269
+
270
+ return Response(
271
+ output.getvalue(),
272
+ mimetype="text/csv",
273
+ headers={"Content-Disposition": f"attachment;filename={scene_id}_logs.csv"}
274
+ ), 200
 
275
 
276
  # ── History ───────────────────────────────────────────────────────────────────
277
+
278
  @app.route("/api/history")
279
  def api_history():
280
  with _stats_lock:
281
+ history = []
282
+ for scene in _global_stats["scenes"]:
283
+ history.append({
284
+ "name": scene["video_name"],
285
+ "url": f"/api/logs/{scene['scene_id']}/csv",
286
+ "date": scene["generated_at"]
287
+ })
288
+ return jsonify(history), 200
289
+
290
+ # ── Webcam ────────────────────────────────────────────────────────────────────
291
+
292
  @app.route("/api/webcam/start", methods=["POST"])
293
  def api_webcam_start():
294
+ global _webcam_active, _webcam_classes, _webcam_frame_count
295
+ global _webcam_detections, _webcam_detections_by_class
296
+
297
+ data = request.json or {}
298
+ classes = data.get("classes", DEFAULT_CLASSES)
299
+
300
+ if not _model_ready:
301
+ return jsonify({"error": "Model not ready"}), 503
302
+
303
  with _webcam_lock:
304
+ _webcam_active = True
305
+ _webcam_classes = classes
306
+ _webcam_frame_count = 0
307
+ _webcam_detections = 0
308
+ _webcam_detections_by_class = defaultdict(int)
309
+
310
+ print(f"✅ Webcam started with classes: {classes}")
311
+ return jsonify({"status": "started"}), 200
312
 
313
  @app.route("/api/webcam/frame", methods=["POST"])
314
  def api_webcam_frame():
315
+ global _webcam_frame_count, _webcam_detections, _webcam_detections_by_class
316
+
317
  if not _webcam_active or not _model_ready:
318
+ return jsonify({"error": "Webcam not active"}), 503
319
+
320
  try:
321
+ img_data = request.data
322
+ if not img_data:
323
+ return jsonify({"error": "No image data"}), 400
324
+
325
+ nparr = np.frombuffer(img_data, np.uint8)
326
  frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
327
+
328
+ if frame is None:
329
+ return jsonify({"error": "Could not decode image"}), 400
330
+
331
+ # Inférence YOLO avec les paramètres corrigés
332
+ results = _model(frame, conf=CONF, iou=IOU, imgsz=INFER_SZ, verbose=False)
333
+
334
+ annotated, detection_count = _draw_detections(frame, results, _webcam_classes)
335
+
336
  with _webcam_lock:
337
+ _webcam_frame_count += 1
338
+ _webcam_detections += detection_count
339
+
340
+ if results[0].boxes:
341
+ for box in results[0].boxes:
342
+ cls = int(box.cls[0])
343
+ class_name = COCO_TO_LABEL.get(cls)
344
+ if class_name and class_name in _webcam_classes:
345
+ _webcam_detections_by_class[class_name] += 1
346
+
347
+ ret, buffer = cv2.imencode('.jpg', annotated)
348
+ return Response(buffer.tobytes(), mimetype='image/jpeg'), 200
349
+
 
350
  except Exception as e:
351
+ print(f"❌ Webcam frame error: {e}")
352
+ return jsonify({"error": str(e)}), 500
353
 
354
  @app.route("/api/webcam/stop", methods=["POST"])
355
  def api_webcam_stop():
356
  global _webcam_active
357
+ with _webcam_lock:
358
+ _webcam_active = False
359
+ print("⏹️ Webcam stopped")
360
+ return jsonify({"status": "stopped"}), 200
361
 
362
  @app.route("/api/webcam/stats")
363
  def api_webcam_stats():
364
  with _webcam_lock:
365
+ by_class = dict(_webcam_detections_by_class)
 
 
366
  return jsonify({
367
+ "active": _webcam_active,
368
+ "frame": _webcam_frame_count,
369
+ "detections": _webcam_detections,
370
+ "detections_by_class": by_class,
371
+ "frame_counts": by_class,
372
+ "unique_counts": by_class
373
+ }), 200
374
 
375
  # ── Main ──────────────────────────────────────────────────────────────────────
376
+
377
  if __name__ == "__main__":
378
  import os
379
  port = int(os.environ.get("PORT", 7860))
380
+ app.run(host="0.0.0.0", port=port, debug=False)