Spaces:
Sleeping
Sleeping
Commit ·
122447d
1
Parent(s): 0db0cc7
fix: rename requirements_hf.txt to requirements.txt
Browse files
app.py
CHANGED
|
@@ -1,7 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
app.py — TrackIQ Backend (
|
| 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
|
| 22 |
-
UPLOAD_DIR = BASE / "uploads";
|
| 23 |
-
OUTPUT_DIR = BASE / "outputs";
|
| 24 |
-
LOG_DIR = BASE / "logs";
|
| 25 |
-
MODELS_DIR = BASE / "models";
|
| 26 |
|
| 27 |
-
#
|
|
|
|
| 28 |
COCO_TO_LABEL = {
|
| 29 |
-
0:
|
| 30 |
-
1:
|
| 31 |
-
2:
|
| 32 |
-
3:
|
| 33 |
-
5:
|
| 34 |
-
7:
|
| 35 |
-
9:
|
| 36 |
11: "Road sign",
|
| 37 |
}
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
DEFAULT_CLASSES = ["Vehicle","Motorcycle","Truck","Bus","Human","Bicycle","Traffic light","Road sign"]
|
| 41 |
CLASS_COLORS = {
|
| 42 |
-
"Vehicle": (255,120,80),
|
| 43 |
-
"Motorcycle": (80,
|
| 44 |
-
"Truck": (0,
|
| 45 |
-
"Bus": (220,80,
|
| 46 |
-
"Human": (236,72,
|
| 47 |
-
"Bicycle": (6,
|
| 48 |
-
"Traffic light": (239,68,
|
| 49 |
-
"Road sign": (249,115,22),
|
| 50 |
}
|
| 51 |
|
| 52 |
-
|
|
|
|
| 53 |
IOU = 0.45
|
| 54 |
-
SKIP =
|
| 55 |
-
INFER_SZ = 320
|
| 56 |
|
| 57 |
-
_jobs: Dict[str,dict] = {}
|
| 58 |
-
_sse_queues: Dict[str,queue.Queue] = {}
|
| 59 |
-
_stats_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
|
| 73 |
-
_webcam_classes
|
| 74 |
-
_webcam_frame_count
|
| 75 |
-
_webcam_detections
|
| 76 |
_webcam_detections_by_class = defaultdict(int)
|
| 77 |
-
|
| 78 |
-
_webcam_lock = threading.Lock()
|
| 79 |
|
| 80 |
-
# ──
|
| 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 |
-
|
|
|
|
| 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():
|
|
|
|
| 98 |
return m
|
| 99 |
return YOLO(str(p))
|
| 100 |
|
|
|
|
| 101 |
threading.Thread(target=_load_model_background, daemon=True).start()
|
| 102 |
|
| 103 |
-
# ──
|
| 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
|
| 116 |
-
conf
|
| 117 |
-
|
|
|
|
| 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,
|
| 128 |
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
|
|
|
|
| 129 |
return frame, detection_count
|
| 130 |
|
| 131 |
-
# ──
|
|
|
|
| 132 |
@app.route("/")
|
| 133 |
-
def index():
|
|
|
|
| 134 |
|
| 135 |
@app.route("/home")
|
| 136 |
-
def home():
|
|
|
|
| 137 |
|
| 138 |
@app.route("/dashboard")
|
| 139 |
-
def dashboard():
|
|
|
|
| 140 |
|
| 141 |
@app.route("/history")
|
| 142 |
-
def history():
|
|
|
|
| 143 |
|
| 144 |
@app.route("/logs")
|
| 145 |
-
def logs():
|
|
|
|
| 146 |
|
| 147 |
@app.route("/static/<path:filename>")
|
| 148 |
-
def serve_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
|
| 191 |
-
job
|
| 192 |
job["status"] = "running"
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 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 |
-
|
| 216 |
-
|
| 217 |
-
continue
|
| 218 |
|
| 219 |
-
|
| 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 =
|
| 232 |
-
if
|
| 233 |
-
|
| 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"]
|
| 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"]
|
| 312 |
-
_global_stats["total_detections"]
|
| 313 |
-
for cls,cnt in
|
| 314 |
_global_stats["detections_by_class"][cls] += cnt
|
|
|
|
| 315 |
_global_stats["scenes"].append({
|
| 316 |
-
"scene_id":
|
| 317 |
-
"
|
| 318 |
-
"
|
| 319 |
-
"
|
| 320 |
-
"
|
| 321 |
-
"
|
| 322 |
-
"
|
| 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:
|
| 355 |
-
|
|
|
|
| 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 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 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 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
| 438 |
@app.route("/api/webcam/start", methods=["POST"])
|
| 439 |
def api_webcam_start():
|
| 440 |
-
global _webcam_active,_webcam_classes,_webcam_frame_count
|
| 441 |
-
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
with _webcam_lock:
|
| 444 |
-
_webcam_active=True
|
| 445 |
-
_webcam_classes=
|
| 446 |
-
_webcam_frame_count=0
|
| 447 |
-
_webcam_detections=0
|
| 448 |
-
_webcam_detections_by_class=defaultdict(int)
|
| 449 |
-
|
| 450 |
-
|
|
|
|
| 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
|
|
|
|
| 455 |
if not _webcam_active or not _model_ready:
|
| 456 |
-
return jsonify({"error":"Webcam not active"}),503
|
|
|
|
| 457 |
try:
|
| 458 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
with _webcam_lock:
|
| 466 |
-
_webcam_frame_count
|
| 467 |
-
_webcam_detections
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
return Response(buf.tobytes(), mimetype='image/jpeg'),200
|
| 480 |
except Exception as e:
|
| 481 |
-
|
|
|
|
| 482 |
|
| 483 |
@app.route("/api/webcam/stop", methods=["POST"])
|
| 484 |
def api_webcam_stop():
|
| 485 |
global _webcam_active
|
| 486 |
-
with _webcam_lock:
|
| 487 |
-
|
|
|
|
|
|
|
| 488 |
|
| 489 |
@app.route("/api/webcam/stats")
|
| 490 |
def api_webcam_stats():
|
| 491 |
with _webcam_lock:
|
| 492 |
-
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":
|
| 497 |
-
"frame":
|
| 498 |
-
"detections":
|
| 499 |
-
"detections_by_class":
|
| 500 |
-
"frame_counts":
|
| 501 |
-
"unique_counts":
|
| 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 (v6 — CORRIGÉ : 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)
|