ModuMLTECH commited on
Commit
dab8981
·
verified ·
1 Parent(s): fd7b551

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +383 -404
app.py CHANGED
@@ -1,571 +1,550 @@
1
- import streamlit as st
2
- import cv2
3
  import os
4
  import time
5
- import numpy as np
6
- from ultralytics import YOLO
7
  import threading
 
 
 
 
8
  from PIL import Image
 
 
 
9
  import torch
10
- import queue
11
- from streamlit.runtime.scriptrunner import add_script_run_ctx
12
 
13
- # --- FONCTIONS UTILES ---
14
- def draw_text_with_background(image, text, position, font=cv2.FONT_HERSHEY_SIMPLEX,
15
- font_scale=1, font_thickness=2, text_color=(255, 255, 255), bg_color=(0, 0, 0), padding=5):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  """Ajoute du texte avec un fond sur une image OpenCV."""
17
- text_size = cv2.getTextSize(text, font, font_scale, font_thickness)[0]
18
  text_width, text_height = text_size
19
 
20
  x, y = position
21
- top_left = (x, y - text_height - padding)
22
- bottom_right = (x + text_width + padding * 2, y + padding)
 
 
 
23
 
24
- cv2.rectangle(image, top_left, bottom_right, bg_color, -1)
25
- cv2.putText(image, text, (x + padding, y), font, font_scale, text_color, font_thickness, cv2.LINE_AA)
 
 
 
 
 
 
 
 
 
26
 
27
- def check_camera_availability():
28
- """Fonction de diagnostic pour vérifier les caméras disponibles"""
29
- available_cameras = []
30
- for i in range(10): # Vérifier les 10 premiers indices de caméra
 
31
  cap = cv2.VideoCapture(i)
32
  if cap.isOpened():
33
- ret, frame = cap.read()
34
  if ret:
35
- available_cameras.append(i)
36
- cap.release()
37
-
38
- return available_cameras
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  def preview_polygons(poly1, poly2):
41
- """Crée une prévisualisation des polygones sur une image vide"""
42
- # Créer une image noire de taille standard
43
- preview_img = np.zeros((640, 1200, 3), dtype=np.uint8)
44
-
45
- # Dessiner le polygone 1 en vert
46
  if len(poly1) >= 3:
47
- cv2.polylines(preview_img, [np.array(poly1, np.int32)], isClosed=True, color=(0, 255, 0), thickness=2)
48
- cv2.fillPoly(preview_img, [np.array(poly1, np.int32)], color=(0, 255, 0, 0.3))
49
-
50
- # Ajouter des points et des annotations
51
- for i, point in enumerate(poly1):
52
- cv2.circle(preview_img, point, 5, (255, 255, 255), -1)
53
- draw_text_with_background(preview_img, f"P1-{i+1}: {point}",
54
- (point[0] + 10, point[1]), font_scale=0.5, bg_color=(0, 100, 0))
55
-
56
- # Dessiner le polygone 2 en rouge
57
  if len(poly2) >= 3:
58
- cv2.polylines(preview_img, [np.array(poly2, np.int32)], isClosed=True, color=(0, 0, 255), thickness=2)
59
- cv2.fillPoly(preview_img, [np.array(poly2, np.int32)], color=(0, 0, 255, 0.3))
60
-
61
- # Ajouter des points et des annotations
62
- for i, point in enumerate(poly2):
63
- cv2.circle(preview_img, point, 5, (255, 255, 255), -1)
64
- draw_text_with_background(preview_img, f"P2-{i+1}: {point}",
65
- (point[0] + 10, point[1]), font_scale=0.5, bg_color=(100, 0, 0))
66
-
67
- # Ajouter une légende
68
- draw_text_with_background(preview_img, "Zone 1 (Vert)", (10, 30), font_scale=0.7, bg_color=(0, 100, 0))
69
- draw_text_with_background(preview_img, "Zone 2 (Rouge)", (10, 60), font_scale=0.7, bg_color=(100, 0, 0))
70
-
71
- # Dessiner une grille pour aider à positionner
72
  grid_spacing = 100
73
  grid_color = (50, 50, 50)
74
-
75
- for x in range(0, preview_img.shape[1], grid_spacing):
76
- cv2.line(preview_img, (x, 0), (x, preview_img.shape[0]), grid_color, 1)
77
- # Ajouter le numéro de coordonnée X
78
- draw_text_with_background(preview_img, str(x), (x, 20), font_scale=0.5, bg_color=(30, 30, 30))
79
-
80
- for y in range(0, preview_img.shape[0], grid_spacing):
81
- cv2.line(preview_img, (0, y), (preview_img.shape[1], y), grid_color, 1)
82
- # Ajouter le numéro de coordonnée Y
83
- draw_text_with_background(preview_img, str(y), (5, y), font_scale=0.5, bg_color=(30, 30, 30))
84
-
85
- return preview_img
86
-
87
- # --- CLASSE YOLO OPTIMISÉE ---
88
  class YOLOVideoProcessor:
89
  def __init__(self, model_path, poly1, poly2, tracker_method="bot"):
90
- # Déterminer le meilleur device disponible
91
- self.device = 'cuda' if torch.cuda.is_available() else 'cpu'
92
-
93
- # Paramètres d'optimisation
94
- self.frame_skip = 2 # Traiter une image sur N
95
- self.downsample_factor = 0.5 # Réduire la taille des images de 50%
96
- self.img_size = 640 # Taille d'entrée fixe pour YOLO
97
- self.conf_threshold = 0.35 # Seuil de confiance plus élevé
98
-
99
- # Charger le modèle une seule fois et avec les bons paramètres
100
- self.model = YOLO(model_path, task="detect")
101
  self.model.to(self.device)
102
-
103
- # Autres paramètres
104
  self.tracker_method = tracker_method
 
 
 
105
  self.unique_region1_ids = set()
106
  self.unique_region2_ids = set()
 
 
107
  self.poly1 = poly1
108
  self.poly2 = poly2
 
 
109
  self.stop_processing = False
110
- self.last_processed_frame = None
111
- self.current_frame = 0
112
-
113
- # Préparer le tracker une seule fois
114
- self.tracker_config = "botsort.yaml" if self.tracker_method.lower() == "bot" else "bytetrack.yaml"
115
-
116
- # File d'attente pour la communication entre threads
117
  self.frame_queue = queue.Queue(maxsize=1)
118
  self.result_queue = queue.Queue(maxsize=1)
119
 
120
- def is_in_region(self, center, poly):
 
121
  poly_np = np.array(poly, dtype=np.int32)
122
  return cv2.pointPolygonTest(poly_np, center, False) >= 0
123
 
124
  def reset_counts(self):
125
- """Réinitialiser les compteurs"""
126
- self.unique_region1_ids = set()
127
- self.unique_region2_ids = set()
128
 
129
  def process_frame(self, frame):
130
- """Traite une image individuelle avec YOLO et le tracking, avec optimisations"""
131
  if frame is None:
132
  return None
133
-
134
- # Redimensionner l'image pour accélérer le traitement
135
- orig_height, orig_width = frame.shape[:2]
 
 
136
  if self.downsample_factor < 1.0:
137
- resized_width = int(orig_width * self.downsample_factor)
138
- resized_height = int(orig_height * self.downsample_factor)
139
- resized_frame = cv2.resize(frame, (resized_width, resized_height), interpolation=cv2.INTER_AREA)
140
  else:
141
  resized_frame = frame
142
-
143
- # Processus de détection avec YOLO
144
- with torch.no_grad(): # Désactiver le calcul des gradients pour économiser de la mémoire
145
  results = self.model.track(
146
- resized_frame,
147
- persist=True,
148
- tracker=self.tracker_config,
149
  conf=self.conf_threshold,
150
- imgsz=self.img_size
 
151
  )
152
 
153
- # Créer une copie du frame original pour l'affichage
154
- display_frame = frame.copy()
155
- frame_height, frame_width = display_frame.shape[:2]
156
-
157
- # Dessiner les polygones
158
- cv2.polylines(display_frame, [np.array(self.poly1, np.int32)], isClosed=True, color=(0, 255, 0), thickness=2)
159
- cv2.polylines(display_frame, [np.array(self.poly2, np.int32)], isClosed=True, color=(255, 0, 0), thickness=2)
160
 
161
- # Facteur d'échelle pour ajuster les coordonnées si on a redimensionné l'image
162
- scale_x = orig_width / resized_width if self.downsample_factor < 1.0 else 1.0
163
- scale_y = orig_height / resized_height if self.downsample_factor < 1.0 else 1.0
164
 
165
- track_ids = []
166
- if results and len(results) > 0 and len(results[0].boxes) > 0:
 
 
 
167
  try:
168
- boxes = results[0].boxes.xywh.cpu().numpy()
169
- track_ids = results[0].boxes.id.int().cpu().tolist()
170
-
171
- # Dessiner les détections et mettre à jour les compteurs
172
- for i, (box, track_id) in enumerate(zip(boxes, track_ids)):
173
- x, y, w, h = box
174
- # Ajuster les coordonnées au frame original
175
- center_x = int(x * scale_x)
176
- center_y = int(y * scale_y)
177
- center_point = (center_x, center_y)
178
-
179
- # Vérifier les régions
180
- if self.is_in_region(center_point, self.poly1):
181
- self.unique_region1_ids.add(track_id)
182
- if self.is_in_region(center_point, self.poly2):
183
- self.unique_region2_ids.add(track_id)
184
-
185
- # Optionnel: dessiner les boîtes de détection
186
- width = int(w * scale_x)
187
- height = int(h * scale_y)
188
- top_left = (center_x - width // 2, center_y - height // 2)
189
- bottom_right = (center_x + width // 2, center_y + height // 2)
190
- cv2.rectangle(display_frame, top_left, bottom_right, (0, 255, 0), 2)
191
-
192
- except AttributeError:
193
- pass
194
 
195
- # Affichage du comptage des véhicules
196
- draw_text_with_background(display_frame, f'Total Sens 1: {len(self.unique_region1_ids)}', (10, frame_height - 50))
197
- draw_text_with_background(display_frame, f'Total Sens 2: {len(self.unique_region2_ids)}', (frame_width - 300, frame_height - 50))
 
 
 
198
 
199
- return display_frame
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
  def process_webcam_frames(self):
202
- """Thread de traitement qui analyse les frames et les met en file d'attente"""
203
  while not self.stop_processing:
204
  try:
205
- # Récupérer le frame depuis la file d'attente
206
- frame = self.frame_queue.get(timeout=1)
207
-
208
- # Mesurer le temps de traitement
209
- start_time = time.time()
210
-
211
- # Traiter le frame
212
- processed_frame = self.process_frame(frame)
213
-
214
- # Calculer le FPS
215
- frame_time = time.time() - start_time
216
- fps = 1 / frame_time
217
-
218
- # Ajouter info FPS
219
- if processed_frame is not None:
220
- fps_text = f"FPS: {fps:.1f}"
221
- draw_text_with_background(processed_frame, fps_text, (10, 30))
222
-
223
- # Mettre le frame traité dans la file des résultats
224
- self.result_queue.put((processed_frame, len(self.unique_region1_ids), len(self.unique_region2_ids)))
225
-
226
- # Marquer la tâche comme terminée
227
- self.frame_queue.task_done()
228
-
229
  except queue.Empty:
230
- # Aucun frame disponible, attendre un peu
231
- time.sleep(0.01)
232
- except Exception as e:
233
- st.error(f"Erreur dans le thread de traitement: {e}")
234
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
 
236
  def process_webcam(self, camera_id=0, display_placeholder=None, count_placeholders=None):
237
- """Traite la vidéo en temps réel depuis une webcam avec multi-threading"""
238
- # Essayer différentes méthodes d'initialisation de la webcam
239
  cap = None
240
- backends_to_try = [
241
- cv2.CAP_ANY, # Essayer le backend par défaut d'abord
242
- cv2.CAP_DSHOW, # DirectShow (Windows)
243
- cv2.CAP_MSMF, # Media Foundation (Windows)
244
- cv2.CAP_V4L2, # Video4Linux (Linux)
245
- cv2.CAP_AVFOUNDATION # AVFoundation (macOS)
246
  ]
247
-
248
- for backend in backends_to_try:
 
249
  try:
250
  cap = cv2.VideoCapture(camera_id, backend)
251
  if cap.isOpened():
252
  if display_placeholder:
253
- display_placeholder.success(f"✅ Webcam connectée avec succès (backend: {backend})")
254
  break
255
  except Exception as e:
256
  if display_placeholder:
257
- display_placeholder.warning(f"Échec avec backend {backend}: {e}")
258
- continue
259
-
260
- # Si aucun backend n'a fonctionné, essayer une dernière fois sans spécifier de backend
261
  if cap is None or not cap.isOpened():
262
- try:
263
- cap = cv2.VideoCapture(camera_id)
264
- except Exception as e:
265
- if display_placeholder:
266
- display_placeholder.error(f"Erreur finale: {e}")
267
-
268
- # Vérifier si la caméra est ouverte
269
  if not cap.isOpened():
270
  if display_placeholder:
271
- display_placeholder.error("⚠️ Erreur : Impossible d'ouvrir la webcam. Essayez de redémarrer l'application ou utiliser une autre source vidéo.")
272
  return
273
-
274
- # Configuration de la webcam pour de meilleures performances (avec gestion d'erreurs)
275
  try:
276
  cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
277
  cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
278
  cap.set(cv2.CAP_PROP_FPS, 30)
279
- except:
280
- # En cas d'échec, continuer sans erreur
281
  if display_placeholder:
282
- display_placeholder.warning("⚠️ Impossible de configurer certains paramètres de la webcam. Utilisation des paramètres par défaut.")
283
-
284
- # Réinitialiser les compteurs pour la nouvelle session
285
  self.reset_counts()
286
  self.stop_processing = False
287
- frame_count = 0
288
-
289
- # Démarrer le thread de traitement des frames
290
- processing_thread = threading.Thread(target=self.process_webcam_frames)
291
- processing_thread.daemon = True
292
- processing_thread.start()
293
-
294
- # Horodatage pour limiter la fréquence de rafraîchissement de l'interface
295
- last_ui_update_time = time.time()
296
- ui_update_interval = 0.03 # ~30 FPS pour l'interface
297
-
298
  try:
299
- # Attendre un peu pour que la webcam s'initialise
300
- time.sleep(0.5)
301
-
302
- # Essayer de lire le premier frame pour s'assurer que la caméra fonctionne
303
- success, first_frame = cap.read()
304
- if not success:
305
- if display_placeholder:
306
- display_placeholder.error("⚠️ Impossible de lire les images de la webcam. Vérifiez votre caméra et ses permissions.")
307
- return
308
-
309
- # Afficher ce premier frame pour montrer que la connexion fonctionne
310
  if display_placeholder:
311
- first_frame_rgb = cv2.cvtColor(first_frame, cv2.COLOR_BGR2RGB)
312
- display_placeholder.image(first_frame_rgb, channels="RGB", use_column_width=True, caption="Webcam connectée avec succès!")
313
-
314
- # Boucle principale de capture
 
 
 
 
 
 
 
 
 
 
 
315
  while not self.stop_processing:
316
- success, frame = cap.read()
317
- if not success:
318
- # Essayer de reconnecter en cas d'erreur
319
- time.sleep(0.1)
320
  continue
321
-
322
- # Ne traiter qu'une image sur N (frame_skip)
323
- if frame_count % self.frame_skip == 0:
324
- # Vider la file si elle est pleine pour éviter le retard
325
- if self.frame_queue.full():
326
- try:
327
- self.frame_queue.get_nowait()
328
- self.frame_queue.task_done()
329
- except:
330
- pass
331
-
332
- # Mettre le frame dans la file pour traitement
333
  try:
334
- self.frame_queue.put(frame, block=False)
 
 
 
335
  except queue.Full:
336
- pass # Ignorer si la file est pleine
337
-
338
- # Mise à jour de l'interface à fréquence limitée
339
- current_time = time.time()
340
- if current_time - last_ui_update_time >= ui_update_interval:
341
  try:
342
- # Récupérer le dernier résultat disponible sans bloquer
343
- if not self.result_queue.empty():
344
- processed_frame, count1, count2 = self.result_queue.get_nowait()
345
-
346
- # Convertir l'image OpenCV en format compatible avec Streamlit
347
- processed_frame_rgb = cv2.cvtColor(processed_frame, cv2.COLOR_BGR2RGB)
348
- img = Image.fromarray(processed_frame_rgb)
349
-
350
- # Afficher l'image dans le placeholder Streamlit
351
- if display_placeholder:
352
- display_placeholder.image(img, channels="RGB", use_column_width=True)
353
-
354
- # Mettre à jour les compteurs
355
- if count_placeholders and len(count_placeholders) >= 2:
356
- count_placeholders[0].metric("Véhicules Sens 1 (Vert)", count1)
357
- count_placeholders[1].metric("Véhicules Sens 2 (Rouge)", count2)
358
-
359
- last_ui_update_time = current_time
360
  except queue.Empty:
361
  pass
362
- except Exception as e:
363
- if display_placeholder:
364
- display_placeholder.warning(f"Erreur lors de l'affichage: {e}")
365
-
366
- frame_count += 1
367
-
368
- # Pause légère pour éviter d'utiliser 100% du CPU
369
  time.sleep(0.001)
370
-
371
  except Exception as e:
372
  if display_placeholder:
373
- display_placeholder.error(f"Erreur dans la boucle principale: {e}")
374
  finally:
375
- # Nettoyage
376
  self.stop_processing = True
377
  cap.release()
378
- # Attendre que le thread de traitement se termine
379
- processing_thread.join(timeout=1.0)
380
  if display_placeholder:
381
  display_placeholder.success("✅ Flux vidéo arrêté.")
382
 
383
 
384
- # --- INTERFACE STREAMLIT ---
 
 
385
  def main():
386
  st.set_page_config(
387
  page_title="Détecteur de Véhicules en Temps Réel",
388
  page_icon="🚗",
389
  layout="wide",
390
- menu_items={"About": "Détection de véhicules avec YOLOv8"}
391
  )
392
-
393
  st.title("🚗 Détection et comptage de Véhicules en Temps Réel")
394
-
395
- # Session state pour gérer l'état de la webcam
396
- if 'webcam_active' not in st.session_state:
397
- st.session_state.webcam_active = False
398
- if 'processor' not in st.session_state:
399
- st.session_state.processor = None
400
- if 'processing_thread' not in st.session_state:
401
- st.session_state.processing_thread = None
402
-
403
- # Vérifier si le modèle existe déjà ou doit être téléchargé
404
  model_path = "best.pt"
405
  if not os.path.exists(model_path):
406
- with st.spinner("📥 Chargement du modèle YOLO... Cela peut prendre un moment."):
407
- # Utilisez hub.load pour télécharger le modèle depuis Hugging Face Hub
408
  try:
409
  from huggingface_hub import hf_hub_download
410
- model_path = hf_hub_download(repo_id="ModuMLTECH/projet_comptage_avance", filename="best.pt")
411
- st.success("✅ Modèle chargé avec succès!")
 
 
 
 
412
  except Exception as e:
413
- st.error(f"❌ Erreur lors du chargement du modèle: {e}")
414
- # Fallback: utiliser un modèle YOLO standard
415
- st.warning("⚠️ Utilisation du modèle YOLO standard à la place")
416
  model_path = "yolov8n.pt"
417
-
418
- # Paramètres dans la barre latérale
419
  with st.sidebar:
420
  st.header("🔹 Paramètres")
421
-
422
- # Entrée utilisateur pour les polygones
423
  st.subheader("📍 Polygone 1 (vert)")
424
  poly1_input = st.text_area("Entrez 4 points (x,y) séparés par des espaces", "465,350 609,350 520,630 3,630")
425
-
426
  st.subheader("📍 Polygone 2 (rouge)")
427
  poly2_input = st.text_area("Entrez 4 points (x,y) séparés par des espaces", "678,350 815,350 1203,630 743,630")
428
-
429
  tracker_method = st.selectbox("Méthode de tracking", ["bot", "byte"], index=0)
430
-
431
- # Paramètres d'optimisation
432
- st.subheader("🚀 Paramètres d'optimisation")
433
- frame_skip = st.slider("Skip de frames (plus élevé = plus rapide)", 1, 5, 2)
434
- downsample = st.slider("Facteur d'échelle (plus petit = plus rapide)", 0.3, 1.0, 0.5, 0.1)
435
  conf_threshold = st.slider("Seuil de confiance", 0.1, 0.9, 0.35, 0.05)
436
-
437
- # Informations système
438
- st.subheader("💻 Informations système")
439
  device_info = f"GPU: {'Disponible' if torch.cuda.is_available() else 'Non disponible'}"
440
  if torch.cuda.is_available():
441
  device_info += f" ({torch.cuda.get_device_name(0)})"
442
  st.info(device_info)
443
-
444
- def parse_polygon(input_text):
445
  try:
446
- return [tuple(map(int, point.split(','))) for point in input_text.split()]
447
- except:
 
 
 
 
448
  return []
449
-
450
  poly1 = parse_polygon(poly1_input)
451
  poly2 = parse_polygon(poly2_input)
452
-
453
- # Vérifier que les polygones sont valides
454
  valid_polygons = len(poly1) == 4 and len(poly2) == 4
455
-
456
- # --- PRÉVISUALISATION DES MASQUES ---
457
  st.header("🖼️ Prévisualisation des masques")
458
-
459
  if valid_polygons:
460
- preview_image = preview_polygons(poly1, poly2)
461
- preview_image_rgb = cv2.cvtColor(preview_image, cv2.COLOR_BGR2RGB)
462
- st.image(preview_image_rgb, use_column_width=True, caption="Prévisualisation des masques de détection")
463
- st.success("✅ Les polygones sont correctement définis.")
464
  else:
465
- st.warning("⚠️ Veuillez définir des polygones valides avec exactement 4 points chacun.")
466
 
467
- # Section principale de la détection en temps réel
468
  st.header("Détection en Temps Réel avec Webcam")
469
-
470
- # Vérifier les caméras disponibles
471
- available_cameras = check_camera_availability()
472
- if not available_cameras:
473
- st.warning("⚠️ Aucune caméra détectée automatiquement sur votre système. Vous pouvez toujours essayer les options ci-dessous.")
474
  else:
475
- st.success(f"✅ Caméras détectées aux indices: {available_cameras}")
476
-
477
- # Méthode améliorée de sélection de caméra
478
  camera_options = {"Webcam par défaut (0)": 0}
479
-
480
- # Ajouter plus d'options pour les caméras alternatives
481
- for i in range(1, 8): # Essayer jusqu'à 8 caméras différentes
482
  camera_options[f"Caméra alternative ({i})"] = i
483
-
484
- # Ajouter aussi des options pour les caméras virtuelles ou IP
485
  camera_options["Caméra IP (entrez l'URL)"] = "ip"
486
-
487
- selected_camera = st.selectbox("Sélectionnez la source vidéo", list(camera_options.keys()))
488
-
489
- # Permettre d'entrer une URL de caméra IP
490
- if camera_options[selected_camera] == "ip":
491
- ip_camera_url = st.text_input("URL de la caméra IP (RTSP, HTTP)", "http://adresse-ip:port/video")
492
- camera_id = ip_camera_url
493
  else:
494
- camera_id = camera_options[selected_camera]
495
-
496
- # Paramètres d'affichage
497
- display_quality = st.select_slider(
498
- "Qualité d'affichage",
499
- options=["Basse", "Moyenne", "Haute"],
500
- value="Moyenne"
501
- )
502
-
503
- # Affichage des placeholders
504
  video_container = st.container()
505
  video_placeholder = video_container.empty()
506
-
507
- # Crée une ligne pour les compteurs
508
- count_col1, count_col2 = st.columns(2)
509
- count_placeholders = [count_col1.empty(), count_col2.empty()]
510
-
511
- # Afficher les infos sur les performances
512
- st.info("ℹ️ **Optimisations appliquées:** Multi-threading, redimensionnement des images, et utilisation de CUDA si disponible")
513
-
514
- # Boutons pour démarrer/arrêter la webcam
515
  col_start, col_stop = st.columns(2)
516
-
517
- if col_start.button("▶️ Démarrer la détection en direct"):
518
  if not valid_polygons:
519
- st.error("❌ Les coordonnées des polygones doivent contenir **exactement 4 points**.")
520
  elif st.session_state.webcam_active:
521
- st.warning("⚠️ La webcam est déjà active !")
522
  else:
523
- video_placeholder.info("🔄 Tentative de connexion à la caméra... Veuillez patienter.")
524
-
525
- # Créer le processeur YOLO avec les paramètres d'optimisation
526
  processor = YOLOVideoProcessor(model_path, poly1, poly2, tracker_method)
527
  processor.frame_skip = frame_skip
528
  processor.downsample_factor = downsample
529
  processor.conf_threshold = conf_threshold
530
-
531
  st.session_state.processor = processor
532
  st.session_state.webcam_active = True
533
-
534
- # Démarrer le traitement dans un thread séparé
535
- processing_thread = threading.Thread(
536
  target=st.session_state.processor.process_webcam,
537
- args=(camera_id, video_placeholder, count_placeholders)
 
538
  )
539
- processing_thread.daemon = True
540
-
541
- # Ajouter le contexte Streamlit au thread pour éviter les erreurs
542
- add_script_run_ctx(processing_thread)
543
-
544
  try:
545
- processing_thread.start()
546
- st.session_state.processing_thread = processing_thread
 
 
 
 
 
547
  except Exception as e:
548
  st.error(f"Erreur au démarrage du thread: {e}")
549
  st.session_state.webcam_active = False
550
-
551
- if col_stop.button("⏹️ Arrêter la détection"):
552
  if st.session_state.webcam_active and st.session_state.processor:
553
  st.session_state.processor.stop_processing = True
554
  st.session_state.webcam_active = False
555
-
556
- # Attendre que le thread se termine
557
  if st.session_state.processing_thread:
558
  st.session_state.processing_thread.join(timeout=2.0)
559
  st.session_state.processing_thread = None
560
-
561
- time.sleep(0.5)
562
- video_placeholder.empty() # Effacer l'affichage vidéo
563
-
564
- # Réinitialiser les compteurs
565
- for placeholder in count_placeholders:
566
- placeholder.empty()
567
  else:
568
- st.warning("⚠️ Aucune détection en cours !")
569
 
570
  if __name__ == "__main__":
571
  main()
 
 
 
1
  import os
2
  import time
 
 
3
  import threading
4
+ import queue
5
+
6
+ import cv2
7
+ import numpy as np
8
  from PIL import Image
9
+
10
+ import streamlit as st
11
+ from ultralytics import YOLO
12
  import torch
 
 
13
 
14
+ # add_script_run_ctx peut ne pas exister selon la version de Streamlit ;
15
+ # on le rend optionnel pour éviter un crash au démarrage.
16
+ try:
17
+ from streamlit.runtime.scriptrunner import add_script_run_ctx
18
+ except Exception: # pragma: no cover
19
+ def add_script_run_ctx(_t): # fallback no-op
20
+ return _t
21
+
22
+
23
+ # =========================
24
+ # === FONCTIONS UTILES ===
25
+ # =========================
26
+ def draw_text_with_background(
27
+ image,
28
+ text,
29
+ position,
30
+ font=cv2.FONT_HERSHEY_SIMPLEX,
31
+ font_scale=1,
32
+ font_thickness=2,
33
+ text_color=(255, 255, 255),
34
+ bg_color=(0, 0, 0),
35
+ padding=5,
36
+ ):
37
  """Ajoute du texte avec un fond sur une image OpenCV."""
38
+ text_size, _ = cv2.getTextSize(text, font, font_scale, font_thickness)
39
  text_width, text_height = text_size
40
 
41
  x, y = position
42
+ # Sécuriser les bornes d'affichage
43
+ tl_x = max(0, x)
44
+ tl_y = max(0, y - text_height - padding)
45
+ br_x = min(image.shape[1] - 1, x + text_width + padding * 2)
46
+ br_y = min(image.shape[0] - 1, y + padding)
47
 
48
+ cv2.rectangle(image, (tl_x, tl_y), (br_x, br_y), bg_color, -1)
49
+ cv2.putText(
50
+ image,
51
+ text,
52
+ (tl_x + padding, min(y, image.shape[0] - 1)),
53
+ font,
54
+ font_scale,
55
+ text_color,
56
+ font_thickness,
57
+ cv2.LINE_AA,
58
+ )
59
 
60
+
61
+ def check_camera_availability(max_idx=10):
62
+ """Diagnostic rapide pour lister des webcams locales disponibles."""
63
+ available = []
64
+ for i in range(max_idx):
65
  cap = cv2.VideoCapture(i)
66
  if cap.isOpened():
67
+ ret, _ = cap.read()
68
  if ret:
69
+ available.append(i)
70
+ cap.release()
71
+ return available
72
+
73
+
74
+ def _alpha_fill_poly(base_img, pts, color_bgr=(0, 255, 0), alpha=0.25, thickness=2):
75
+ """
76
+ Dessine un polygone 'transparent' en copiant sur overlay puis en blend.
77
+ OpenCV ne supporte pas l'alpha directement dans cv2.fillPoly.
78
+ """
79
+ overlay = base_img.copy()
80
+ pts_np = np.array(pts, np.int32)
81
+
82
+ cv2.fillPoly(overlay, [pts_np], color_bgr)
83
+ cv2.addWeighted(overlay, alpha, base_img, 1 - alpha, 0, dst=base_img)
84
+ cv2.polylines(base_img, [pts_np], isClosed=True, color=color_bgr, thickness=thickness)
85
+
86
 
87
  def preview_polygons(poly1, poly2):
88
+ """Crée une prévisualisation des polygones sur une image noire."""
89
+ preview = np.zeros((640, 1200, 3), dtype=np.uint8)
90
+
91
+ # Zone 1 (vert)
 
92
  if len(poly1) >= 3:
93
+ _alpha_fill_poly(preview, poly1, (0, 200, 0), alpha=0.25)
94
+ for i, pt in enumerate(poly1):
95
+ cv2.circle(preview, pt, 5, (255, 255, 255), -1)
96
+ draw_text_with_background(
97
+ preview, f"P1-{i+1}: {pt}", (pt[0] + 10, pt[1]), font_scale=0.5, bg_color=(0, 100, 0)
98
+ )
99
+
100
+ # Zone 2 (rouge)
 
 
101
  if len(poly2) >= 3:
102
+ _alpha_fill_poly(preview, poly2, (0, 0, 200), alpha=0.25)
103
+ for i, pt in enumerate(poly2):
104
+ cv2.circle(preview, pt, 5, (255, 255, 255), -1)
105
+ draw_text_with_background(
106
+ preview, f"P2-{i+1}: {pt}", (pt[0] + 10, pt[1]), font_scale=0.5, bg_color=(100, 0, 0)
107
+ )
108
+
109
+ draw_text_with_background(preview, "Zone 1 (Vert)", (10, 30), font_scale=0.7, bg_color=(0, 100, 0))
110
+ draw_text_with_background(preview, "Zone 2 (Rouge)", (10, 60), font_scale=0.7, bg_color=(100, 0, 0))
111
+
112
+ # Grille
 
 
 
113
  grid_spacing = 100
114
  grid_color = (50, 50, 50)
115
+ for x in range(0, preview.shape[1], grid_spacing):
116
+ cv2.line(preview, (x, 0), (x, preview.shape[0]), grid_color, 1)
117
+ draw_text_with_background(preview, str(x), (x, 20), font_scale=0.5, bg_color=(30, 30, 30))
118
+ for y in range(0, preview.shape[0], grid_spacing):
119
+ cv2.line(preview, (0, y), (preview.shape[1], y), grid_color, 1)
120
+ draw_text_with_background(preview, str(y), (5, y), font_scale=0.5, bg_color=(30, 30, 30))
121
+
122
+ return preview
123
+
124
+
125
+ # ================================
126
+ # === CLASSE TRAITEMENT YOLOv8 ===
127
+ # ================================
 
128
  class YOLOVideoProcessor:
129
  def __init__(self, model_path, poly1, poly2, tracker_method="bot"):
130
+ # Device
131
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
132
+
133
+ # Paramètres par défaut (écrasés par l'UI ensuite)
134
+ self.frame_skip = 2
135
+ self.downsample_factor = 0.5
136
+ self.img_size = 640
137
+ self.conf_threshold = 0.35
138
+
139
+ # Chargement modèle
140
+ self.model = YOLO(model_path) # 'task' n'est pas requis
141
  self.model.to(self.device)
142
+
143
+ # Tracking
144
  self.tracker_method = tracker_method
145
+ self.tracker_config = "botsort.yaml" if tracker_method.lower() == "bot" else "bytetrack.yaml"
146
+
147
+ # Compteurs d'IDs uniques par zone
148
  self.unique_region1_ids = set()
149
  self.unique_region2_ids = set()
150
+
151
+ # Polygones
152
  self.poly1 = poly1
153
  self.poly2 = poly2
154
+
155
+ # Threads/queues
156
  self.stop_processing = False
 
 
 
 
 
 
 
157
  self.frame_queue = queue.Queue(maxsize=1)
158
  self.result_queue = queue.Queue(maxsize=1)
159
 
160
+ @staticmethod
161
+ def is_in_region(center, poly):
162
  poly_np = np.array(poly, dtype=np.int32)
163
  return cv2.pointPolygonTest(poly_np, center, False) >= 0
164
 
165
  def reset_counts(self):
166
+ self.unique_region1_ids.clear()
167
+ self.unique_region2_ids.clear()
 
168
 
169
  def process_frame(self, frame):
 
170
  if frame is None:
171
  return None
172
+
173
+ # Downscale contrôlé
174
+ orig_h, orig_w = frame.shape[:2]
175
+ resized_w = orig_w
176
+ resized_h = orig_h
177
  if self.downsample_factor < 1.0:
178
+ resized_w = max(1, int(orig_w * self.downsample_factor))
179
+ resized_h = max(1, int(orig_h * self.downsample_factor))
180
+ resized_frame = cv2.resize(frame, (resized_w, resized_h), interpolation=cv2.INTER_AREA)
181
  else:
182
  resized_frame = frame
183
+
184
+ # Inference + tracking
185
+ with torch.no_grad():
186
  results = self.model.track(
187
+ resized_frame,
188
+ persist=True,
189
+ tracker=self.tracker_config,
190
  conf=self.conf_threshold,
191
+ imgsz=self.img_size,
192
+ device=self.device,
193
  )
194
 
195
+ display = frame.copy()
 
 
 
 
 
 
196
 
197
+ # Dessiner polygones (transparent)
198
+ _alpha_fill_poly(display, self.poly1, (0, 200, 0), alpha=0.2, thickness=2)
199
+ _alpha_fill_poly(display, self.poly2, (0, 0, 200), alpha=0.2, thickness=2)
200
 
201
+ # Mise à l'échelle des boxes vers la taille originale
202
+ sx = orig_w / float(resized_w)
203
+ sy = orig_h / float(resized_h)
204
+
205
+ if results and len(results) > 0 and getattr(results[0], "boxes", None) is not None:
206
  try:
207
+ boxes_xywh = results[0].boxes.xywh.cpu().numpy()
208
+ # track ids peuvent être None sur la première frame
209
+ ids_tensor = results[0].boxes.id
210
+ track_ids = ids_tensor.int().cpu().tolist() if ids_tensor is not None else [None] * len(boxes_xywh)
211
+
212
+ for (x, y, w, h), tid in zip(boxes_xywh, track_ids):
213
+ # centre + bbox rescalés
214
+ cx = int(x * sx)
215
+ cy = int(y * sy)
216
+ ww = int(w * sx)
217
+ hh = int(h * sy)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
+ # Comptage par centre de la bbox
220
+ if tid is not None:
221
+ if self.is_in_region((cx, cy), self.poly1):
222
+ self.unique_region1_ids.add(tid)
223
+ if self.is_in_region((cx, cy), self.poly2):
224
+ self.unique_region2_ids.add(tid)
225
 
226
+ # Dessin bbox
227
+ tl = (max(0, cx - ww // 2), max(0, cy - hh // 2))
228
+ br = (min(display.shape[1] - 1, cx + ww // 2), min(display.shape[0] - 1, cy + hh // 2))
229
+ cv2.rectangle(display, tl, br, (0, 255, 0), 2)
230
+
231
+ except Exception as e:
232
+ # On ne casse pas l'affichage si une frame pose problème
233
+ draw_text_with_background(display, f"Tracking error: {e}", (10, 60), bg_color=(80, 0, 0))
234
+
235
+ # Affichage compteurs
236
+ h, w = display.shape[:2]
237
+ draw_text_with_background(display, f"Total Sens 1: {len(self.unique_region1_ids)}", (10, h - 50))
238
+ draw_text_with_background(display, f"Total Sens 2: {len(self.unique_region2_ids)}", (w - 300, h - 50))
239
+
240
+ return display
241
 
242
  def process_webcam_frames(self):
243
+ """Thread de traitement : lit les frames de frame_queue, pousse résultats dans result_queue."""
244
  while not self.stop_processing:
245
  try:
246
+ frame = self.frame_queue.get(timeout=0.5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  except queue.Empty:
248
+ continue
249
+
250
+ start = time.time()
251
+ processed = self.process_frame(frame)
252
+ fps = 1.0 / max(1e-6, (time.time() - start))
253
+
254
+ if processed is not None:
255
+ draw_text_with_background(processed, f"FPS: {fps:.1f}", (10, 30))
256
+
257
+ # Remplacer l'ancien résultat si plein
258
+ try:
259
+ if self.result_queue.full():
260
+ _ = self.result_queue.get_nowait()
261
+ self.result_queue.put_nowait(
262
+ (processed, len(self.unique_region1_ids), len(self.unique_region2_ids))
263
+ )
264
+ except queue.Full:
265
+ pass
266
+ finally:
267
+ self.frame_queue.task_done()
268
 
269
  def process_webcam(self, camera_id=0, display_placeholder=None, count_placeholders=None):
270
+ """Capture en direct avec multi-threading et rendu dans Streamlit."""
 
271
  cap = None
272
+ backends = [
273
+ cv2.CAP_ANY,
274
+ getattr(cv2, "CAP_DSHOW", cv2.CAP_ANY),
275
+ getattr(cv2, "CAP_MSMF", cv2.CAP_ANY),
276
+ getattr(cv2, "CAP_V4L2", cv2.CAP_ANY),
277
+ getattr(cv2, "CAP_AVFOUNDATION", cv2.CAP_ANY),
278
  ]
279
+
280
+ # Ouverture
281
+ for backend in backends:
282
  try:
283
  cap = cv2.VideoCapture(camera_id, backend)
284
  if cap.isOpened():
285
  if display_placeholder:
286
+ display_placeholder.success(f"✅ Webcam connectée (backend: {backend})")
287
  break
288
  except Exception as e:
289
  if display_placeholder:
290
+ display_placeholder.warning(f"Backend {backend} échec: {e}")
291
+
 
 
292
  if cap is None or not cap.isOpened():
293
+ # Dernière chance sans backend explicite
294
+ cap = cv2.VideoCapture(camera_id)
295
+
 
 
 
 
296
  if not cap.isOpened():
297
  if display_placeholder:
298
+ display_placeholder.error("⚠️ Impossible d'ouvrir la source vidéo.")
299
  return
300
+
301
+ # Paramètres caméra (best-effort)
302
  try:
303
  cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
304
  cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
305
  cap.set(cv2.CAP_PROP_FPS, 30)
306
+ except Exception:
 
307
  if display_placeholder:
308
+ display_placeholder.warning("⚠️ Impossible de régler certains paramètres caméra.")
309
+
310
+ # Reset état session
311
  self.reset_counts()
312
  self.stop_processing = False
313
+
314
+ # Thread de traitement
315
+ t = threading.Thread(target=self.process_webcam_frames, daemon=True)
 
 
 
 
 
 
 
 
316
  try:
317
+ add_script_run_ctx(t)
318
+ except Exception:
319
+ pass
320
+ t.start()
321
+
322
+ # Premier frame pour valider
323
+ time.sleep(0.3)
324
+ ok, first = cap.read()
325
+ if not ok:
 
 
326
  if display_placeholder:
327
+ display_placeholder.error("⚠️ Lecture impossible depuis la webcam (permissions ?).")
328
+ self.stop_processing = True
329
+ cap.release()
330
+ t.join(timeout=1.0)
331
+ return
332
+
333
+ if display_placeholder is not None:
334
+ display_placeholder.image(cv2.cvtColor(first, cv2.COLOR_BGR2RGB), channels="RGB", use_column_width=True,
335
+ caption="Webcam connectée !")
336
+
337
+ ui_update_interval = 0.03 # ~30 FPS
338
+ last_ui = 0.0
339
+ frame_idx = 0
340
+
341
+ try:
342
  while not self.stop_processing:
343
+ ok, frame = cap.read()
344
+ if not ok:
345
+ time.sleep(0.05)
 
346
  continue
347
+
348
+ # Envoi au thread de traitement (skip pour alléger)
349
+ if frame_idx % self.frame_skip == 0:
 
 
 
 
 
 
 
 
 
350
  try:
351
+ if self.frame_queue.full():
352
+ _ = self.frame_queue.get_nowait()
353
+ self.frame_queue.task_done()
354
+ self.frame_queue.put_nowait(frame)
355
  except queue.Full:
356
+ pass
357
+
358
+ # Affichage si résultat dispo
359
+ now = time.time()
360
+ if now - last_ui >= ui_update_interval:
361
  try:
362
+ processed, c1, c2 = self.result_queue.get_nowait()
363
+ if processed is not None and display_placeholder is not None:
364
+ rgb = cv2.cvtColor(processed, cv2.COLOR_BGR2RGB)
365
+ display_placeholder.image(Image.fromarray(rgb), channels="RGB", use_column_width=True)
366
+ if count_placeholders and len(count_placeholders) >= 2:
367
+ count_placeholders[0].metric("Véhicules Sens 1 (Vert)", c1)
368
+ count_placeholders[1].metric("Véhicules Sens 2 (Rouge)", c2)
 
 
 
 
 
 
 
 
 
 
 
369
  except queue.Empty:
370
  pass
371
+ last_ui = now
372
+
373
+ frame_idx += 1
 
 
 
 
374
  time.sleep(0.001)
375
+
376
  except Exception as e:
377
  if display_placeholder:
378
+ display_placeholder.error(f"Erreur boucle principale: {e}")
379
  finally:
 
380
  self.stop_processing = True
381
  cap.release()
382
+ t.join(timeout=1.0)
 
383
  if display_placeholder:
384
  display_placeholder.success("✅ Flux vidéo arrêté.")
385
 
386
 
387
+ # ==========================
388
+ # === INTERFACE STREAMLIT ===
389
+ # ==========================
390
  def main():
391
  st.set_page_config(
392
  page_title="Détecteur de Véhicules en Temps Réel",
393
  page_icon="🚗",
394
  layout="wide",
395
+ menu_items={"About": "Détection de véhicules avec YOLOv8"},
396
  )
397
+
398
  st.title("🚗 Détection et comptage de Véhicules en Temps Réel")
399
+
400
+ # Session state
401
+ st.session_state.setdefault("webcam_active", False)
402
+ st.session_state.setdefault("processor", None)
403
+ st.session_state.setdefault("processing_thread", None)
404
+
405
+ # Chargement du modèle
 
 
 
406
  model_path = "best.pt"
407
  if not os.path.exists(model_path):
408
+ with st.spinner("📥 Téléchargement du modèle YOLO"):
 
409
  try:
410
  from huggingface_hub import hf_hub_download
411
+
412
+ model_path = hf_hub_download(
413
+ repo_id="ModuMLTECH/projet_comptage_avance",
414
+ filename="best.pt",
415
+ )
416
+ st.success("✅ Modèle chargé depuis Hugging Face Hub.")
417
  except Exception as e:
418
+ st.error(f"❌ Erreur chargement modèle: {e}")
419
+ st.warning("⚠️ Fallback sur un modèle YOLO public (yolov8n.pt).")
 
420
  model_path = "yolov8n.pt"
421
+
422
+ # === SIDEBAR ===
423
  with st.sidebar:
424
  st.header("🔹 Paramètres")
425
+
 
426
  st.subheader("📍 Polygone 1 (vert)")
427
  poly1_input = st.text_area("Entrez 4 points (x,y) séparés par des espaces", "465,350 609,350 520,630 3,630")
428
+
429
  st.subheader("📍 Polygone 2 (rouge)")
430
  poly2_input = st.text_area("Entrez 4 points (x,y) séparés par des espaces", "678,350 815,350 1203,630 743,630")
431
+
432
  tracker_method = st.selectbox("Méthode de tracking", ["bot", "byte"], index=0)
433
+
434
+ st.subheader("🚀 Optimisation")
435
+ frame_skip = st.slider("Skip de frames", 1, 5, 2)
436
+ downsample = st.slider("Facteur d'échelle", 0.3, 1.0, 0.5, 0.1)
 
437
  conf_threshold = st.slider("Seuil de confiance", 0.1, 0.9, 0.35, 0.05)
438
+
439
+ st.subheader("💻 Système")
 
440
  device_info = f"GPU: {'Disponible' if torch.cuda.is_available() else 'Non disponible'}"
441
  if torch.cuda.is_available():
442
  device_info += f" ({torch.cuda.get_device_name(0)})"
443
  st.info(device_info)
444
+
445
+ def parse_polygon(txt):
446
  try:
447
+ pts = []
448
+ for token in txt.replace(";", " ").split():
449
+ x, y = token.split(",")
450
+ pts.append((int(x), int(y)))
451
+ return pts
452
+ except Exception:
453
  return []
454
+
455
  poly1 = parse_polygon(poly1_input)
456
  poly2 = parse_polygon(poly2_input)
 
 
457
  valid_polygons = len(poly1) == 4 and len(poly2) == 4
458
+
459
+ # Prévisualisation
460
  st.header("🖼️ Prévisualisation des masques")
 
461
  if valid_polygons:
462
+ prev = preview_polygons(poly1, poly2)
463
+ st.image(cv2.cvtColor(prev, cv2.COLOR_BGR2RGB), use_column_width=True, caption="Masques de détection")
464
+ st.success("✅ Polygones valides (4 points chacun).")
 
465
  else:
466
+ st.warning("⚠️ Définissez deux polygones valides de 4 points chacun.")
467
 
468
+ # Section webcam
469
  st.header("Détection en Temps Réel avec Webcam")
470
+
471
+ available = check_camera_availability()
472
+ if not available:
473
+ st.warning("⚠️ Aucune caméra locale détectée (vous pouvez tester une caméra IP).")
 
474
  else:
475
+ st.success(f"✅ Caméras détectées: {available}")
476
+
 
477
  camera_options = {"Webcam par défaut (0)": 0}
478
+ for i in range(1, 8):
 
 
479
  camera_options[f"Caméra alternative ({i})"] = i
 
 
480
  camera_options["Caméra IP (entrez l'URL)"] = "ip"
481
+
482
+ selected = st.selectbox("Source vidéo", list(camera_options.keys()))
483
+ if camera_options[selected] == "ip":
484
+ camera_id = st.text_input("URL RTSP/HTTP", "http://adresse-ip:port/video")
 
 
 
485
  else:
486
+ camera_id = camera_options[selected]
487
+
488
+ display_quality = st.select_slider("Qualité d'affichage", options=["Basse", "Moyenne", "Haute"], value="Moyenne")
489
+ # (placeholder pour gérer des resize/qualité plus tard si besoin)
490
+
 
 
 
 
 
491
  video_container = st.container()
492
  video_placeholder = video_container.empty()
493
+ col1, col2 = st.columns(2)
494
+ count_placeholders = [col1.empty(), col2.empty()]
495
+
496
+ st.info("ℹ️ Optimisations: multi-threading, resize adaptatif, CUDA si dispo.")
497
+
 
 
 
 
498
  col_start, col_stop = st.columns(2)
499
+
500
+ if col_start.button("▶️ Démarrer la détection"):
501
  if not valid_polygons:
502
+ st.error("❌ Les polygones doivent avoir exactement 4 points chacun.")
503
  elif st.session_state.webcam_active:
504
+ st.warning("⚠️ La webcam est déjà active.")
505
  else:
506
+ video_placeholder.info("🔄 Connexion à la source vidéo…")
507
+
 
508
  processor = YOLOVideoProcessor(model_path, poly1, poly2, tracker_method)
509
  processor.frame_skip = frame_skip
510
  processor.downsample_factor = downsample
511
  processor.conf_threshold = conf_threshold
512
+
513
  st.session_state.processor = processor
514
  st.session_state.webcam_active = True
515
+
516
+ thread = threading.Thread(
 
517
  target=st.session_state.processor.process_webcam,
518
+ args=(camera_id, video_placeholder, count_placeholders),
519
+ daemon=True,
520
  )
 
 
 
 
 
521
  try:
522
+ add_script_run_ctx(thread)
523
+ except Exception:
524
+ pass
525
+
526
+ try:
527
+ thread.start()
528
+ st.session_state.processing_thread = thread
529
  except Exception as e:
530
  st.error(f"Erreur au démarrage du thread: {e}")
531
  st.session_state.webcam_active = False
532
+
533
+ if col_stop.button("⏹️ Arrêter"):
534
  if st.session_state.webcam_active and st.session_state.processor:
535
  st.session_state.processor.stop_processing = True
536
  st.session_state.webcam_active = False
537
+
 
538
  if st.session_state.processing_thread:
539
  st.session_state.processing_thread.join(timeout=2.0)
540
  st.session_state.processing_thread = None
541
+
542
+ time.sleep(0.3)
543
+ video_placeholder.empty()
544
+ for ph in count_placeholders:
545
+ ph.empty()
 
 
546
  else:
547
+ st.warning("⚠️ Aucune détection en cours.")
548
 
549
  if __name__ == "__main__":
550
  main()