commit 18e8d6eef123b23a482d50f89dd40ad1569ceca1 Author: AliceT Date: Sun Oct 19 11:53:24 2025 +0200 Premier commit du programme sur la nouvelle instance Gitea Import des fichiers : Modifications qui seront validées : nouveau fichier : MOT20-02.mp4 nouveau fichier : README.md nouveau fichier : autoinstall.sh nouveau fichier : autostart.sh nouveau fichier : checking-camera.py nouveau fichier : main.py nouveau fichier : models/yolo11n.pt nouveau fichier : models/yolo12n.pt nouveau fichier : requirements.txt nouveau fichier : track/botsort.yaml nouveau fichier : track/bytetrack.yaml diff --git a/MOT20-02.mp4 b/MOT20-02.mp4 new file mode 100644 index 0000000..549f6a5 Binary files /dev/null and b/MOT20-02.mp4 differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..421d533 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Vision par ordinateur (Édition Python) + +--- + +## Prérequis + +- Ordinateur fixe ou portable, architecture de processeur x86/AMD64 +- Système d'exploitation Linux +- Python 3.13.2 + +```bash +sudo apt install -y make build-essential libssl-dev zlib1g-dev \ +libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm \ +libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev \ +libffi-dev liblzma-dev tk-dev libncurses5-dev libncursesw5-dev + +# Où + +sudo dnf install gcc zlib-devel bzip2 bzip2-devel readline-devel \ +sqlite sqlite-devel openssl-devel xz xz-devel libffi-devel \ +findutils tk-devel ncurses-devel +``` +```bash +curl -fsSL https://pyenv.run | bash + +echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc +echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc +echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc +echo 'eval "$(pyenv virtualenv-init -)"' >> ~/.bashrc + +pyenv install 3.13.2 +cd répertoire/du/projet +pyenv local 3.13.2 +``` + +--- +Créer un environnement virtuel Python. +```bash +python -m venv venv +``` +Activer l'environnement virtuel au terminal courant. +```bash +source venv/bin/activate +``` +Mettre à jour Pip (pour éviter les erreurs d'installation de librairies). +```bash +pip install --upgrade pip +``` +Installer les librairies nécessaires au déroulement du programme. +```bash +pip install -r requirements.txt +``` +Exécuter le dialogue d'aide sur le programme principale. +```bash +> python main.py --help +usage: main.py [-h] [-i INPUT] [-o OUTPUT] [-f] [-u API_URL] [-m MODELNAME] [-v] + +options: + -i, --input INPUT Chemin vers la vidéo à lire ('0' est à utiliser pour la webcam par défaut) + -o, --output OUTPUT Emplacement pour enregistrer le processus dans une vidéo (Attention au RGPD, désactivé si aucun) + -f, --force Forcer le redimensionnement du flux d'entrée. + -u, --api-url API_URL + Chemin réseau vers l'API (désactivé si aucun) + -m, --model MODELNAME + Nom complet (ou chemin) du modèle de détection à utiliser + -v, --verbose Activer la sortie verbeuse du programme. +``` + +--- + +## Démarrage automatique + +Dans le répertoire source du projet, il y a un script bash permettant l'installation et le démarrage automatique. +Il faut d'abord essayer le programme manuellement (avec les instructions précédentes). Si le programme est satisfaisant, le script d'auto-installation est pertinent. + +Exécuter le script +```bash +./auto-install.sh +``` + +Le script propose quels paramètres sont à activer automatiquement (pour l'exécution à l'ouverture de la session) +```bash +> ./autoinstall.sh +Voulez-vous indiquer un flux d'entrée ? (oui/non): oui +Entrez la valeur d'entrée : 4 +Voulez-vous une verbosité accrue ? (oui/non): non +Voulez-vous forcer le redimenssionnement de l'image ? (oui/non): oui +Voulez-vous renseigner l'URL de l'API ? (oui/non): oui +Entrez l'URL de l'API : http://localhost:1880 +``` + +Redémarrer l'ordinateur hôte. +```bash +sudo reboot now +``` + +En cas d'erreurs ou de doutes, regarder la journalisation. +```bash +less ~/vision-par-ordinateur-python/main.log +``` diff --git a/autoinstall.sh b/autoinstall.sh new file mode 100755 index 0000000..fe257ad --- /dev/null +++ b/autoinstall.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +# Initialize variables +START_FILE="$HOME/.config/autostart/vision-par-ordinateur-python.desktop" +PROJECT_DIR="$HOME/vision-par-ordinateur-python" +INPUT="" +VERBOSE="" +FORCE="" +API_URL="" + +# Load PyEnv for user +export PYENV_ROOT="$HOME/.pyenv" +[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH" +eval "$(pyenv init - bash)" +eval "$(pyenv virtualenv-init -)" + +# Prompt for INPUT +read -p "Voulez-vous indiquer un flux d'entrée ? (oui/non): " input_choice +if [[ "$input_choice" == "oui" || "$input_choice" == "o" ]]; then + read -p "Entrez la valeur d'entrée : " INPUT + INPUT="-i $INPUT" +fi + +# Prompt for VERBOSE +read -p "Voulez-vous une verbosité accrue ? (oui/non): " verbose_choice +if [[ "$verbose_choice" == "oui" || "$verbose_choice" == "o" ]]; then + VERBOSE="-v" +fi + +# Prompt for FORCE +read -p "Voulez-vous forcer le redimenssionnement de l'image ? (oui/non): " force_choice +if [[ "$force_choice" == "oui" || "$force_choice" == "o" ]]; then + FORCE="-f" +fi + +# Prompt for API_URL +read -p "Voulez-vous renseigner l'URL de l'API ? (oui/non): " api_choice +if [[ "$api_choice" == "oui" || "$api_choice" == "o" ]]; then + read -p "Entrez l'URL de l'API : " API_URL + API_URL="--api-url $API_URL" +fi + +# Construct the final command +COMMAND="python main.py $INPUT $VERBOSE $FORCE $API_URL >> $HOME/vision-par-ordinateur-python/main.log 2>&1" + +# Output the command to the autostart.sh file +echo -e "$COMMAND" >> autostart.sh + +# Make the script executable +chmod +x autostart.sh + +# Make the project directory and copy content +mkdir "${PROJECT_DIR}" +cp -r main.py autostart.sh requirements.txt models track "${PROJECT_DIR}" + +# Install venv and requirements +cd "${PROJECT_DIR}" +pyenv local 3.13.2 +python -m venv venv +source venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt + +# Auto start program settings +mkdir -p ~/.config/autostart +touch "${START_FILE}" +echo -e "[Desktop Entry]\nType=Application\nExec=${HOME}/vision-par-ordinateur-python/autostart.sh\nHidden=false\nNoDisplay=false\nX-GNOME-Autostart-enabled=true\nName=Vision par ordinateur\nComment=Lance le script Python pour la vision par ordinateur" >> "${START_FILE}" + +echo -e "L'installation du programme et du démarrage automatique est terminé.\nVeuillez redémarrer l'ordinateur pour démarrer la vision par ordinateur automatiquement." diff --git a/autostart.sh b/autostart.sh new file mode 100755 index 0000000..4748643 --- /dev/null +++ b/autostart.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +cd ~ +export PYENV_ROOT="$HOME/.pyenv" +[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH" +eval "$(pyenv init - bash)" +eval "$(pyenv virtualenv-init -)" + +cd ~/vision-par-ordinateur-python +source venv/bin/activate + diff --git a/checking-camera.py b/checking-camera.py new file mode 100644 index 0000000..9f5016c --- /dev/null +++ b/checking-camera.py @@ -0,0 +1,4 @@ +from cv2_enumerate_cameras import enumerate_cameras + +for camera_info in enumerate_cameras(): + print(f'{camera_info.index}: {camera_info.name}') diff --git a/main.py b/main.py new file mode 100644 index 0000000..d819444 --- /dev/null +++ b/main.py @@ -0,0 +1,285 @@ +import time +import json +import asyncio +import aiohttp +import ssl +import requests +from datetime import date +from collections import defaultdict +import cv2 +import numpy as np +import argparse +import sys +from ultralytics import YOLO + +# Declare video file to read with parameter +parser = argparse.ArgumentParser() +parser.add_argument("-i", "--input", dest="input", default=0, help="Chemin vers la vidéo à lire ('0' est à utiliser pour la webcam par défaut)") +parser.add_argument("-o", "--output", dest="output", default="", help="Emplacement pour enregistrer le processus dans une vidéo (Attention au RGPD, désactivé si aucun)") +parser.add_argument("-f", "--force", action='store_true', help="Forcer le redimensionnement du flux d'entrée") +parser.add_argument("-u", "--api-url", dest="API_URL", default="", help="Chemin réseau vers l'API (désactivé si aucun)") +parser.add_argument("-m", "--model", dest="modelname", default="yolo12n.pt", help="Nom complet du modèle de détection à utiliser (répertoire 'models/')") +parser.add_argument('-v', '--verbose', action='store_true', help='Activer la sortie verbeuse du programme.') +args = parser.parse_args() + +# Initialize verbose mode +if args.verbose: + print("Mode verbeux activé") + time_mid_list = [] + time_end_list = [] + fps_list = [] + +# Initialize API variables +API_URL_EVENT = args.API_URL + "/api/c_auto" +API_URL_POS = args.API_URL + "/api/p_auto" +API_URL_STATUS = args.API_URL + "/api/status" + +# Declare interval variable to get status +STATUS_CHECK_INTERVAL = 10 + +# Define function to get API event status +async def get_status(session): + try: + async with session.get(API_URL_STATUS) as response: + response.raise_for_status() + data = await response.text() + return int(data) + except aiohttp.ClientError as e: + print(f"Impossible d'obtenir l'état: {e}") + +# Define event API requests function +async def send_event(session, action, people_count, person_id, verbose=False): + payload = [{"action": action, "people": people_count}, {"id": person_id}] + try: + async with session.post(API_URL_EVENT, json=payload) as response: + response.raise_for_status() + if verbose: + print(f"Requête action {action} pour l'id:{person_id} avec {people_count} visitors") + except aiohttp.ClientError as e: + print(f"Impossible d'envoyer l'action {action} de l'id:{person_id}: {e}") + +# Define position API requests function +async def send_position(session, positions, verbose=False): + try: + async with session.post(API_URL_POS, json=positions) as response: + response.raise_for_status() + if verbose: + print(f"Positions envoyées : {len(positions)}") + except aiohttp.ClientError as e: + print(f"Impossible d'envoyer les positions: {e}") + +# Store the track history +track_history = defaultdict(lambda: []) + +# Create asyncio event loop +async def main_loop(): + # Initialize FPS measurement + if args.verbose: + prev_time_fps = time.time() + + # Define for the first time last_position_sent + try: + last_position_sent + except NameError: + last_position_sent = int(time.time()) + + # Patch public variable + current_people = set() + + # Define for the first time last_status_check + try: + last_status_check + except NameError: + last_status_check = int(time.time()) - STATUS_CHECK_INTERVAL + + # Variable for status and det/track by default + if args.API_URL: + status = 0 + else: + status = 1 + model = None + cap = None + video_writer = None + + async with aiohttp.ClientSession() as session: + while True: + # Check status at the defined interval + if args.API_URL: + if time.time() - last_status_check >= STATUS_CHECK_INTERVAL: + status = await get_status(session) + last_status_check = time.time() + if args.verbose: + print(f"État mis à jour: {status}") + + # Clear everything and wait for other status + if status == 0 or not status: + if cap: + cap.release() + cap = None + if video_writer: + video_writer.release() + video_writer = None + cv2.destroyAllWindows() + if args.verbose: + print("Ressources libérées/détruites. Dans l'attente de l'état 1 pour redémarrer.") + # Prevent CPU overload + await asyncio.sleep(1) + continue + + # Skip if status is 0 + if status == 2: + if current_people and args.API_URL: + for person_id in current_people: + await send_event(session, "exit", 0, person_id, args.verbose) + current_people.clear() + if args.verbose: + print("État 2 reçu: Envoi de l'action de sortie pour tous les ID encore actifs.") + await asyncio.sleep(1) + continue + + # Initialize model and video capture if status is 1 + if status == 1 and not cap: + model = YOLO(f"models/{args.modelname}") + if args.verbose: + print(f"Modèle YOLO chargé: models/{args.modelname}") + + try: + input_value = int(args.input) + cap = cv2.VideoCapture(input_value) + except ValueError: + cap = cv2.VideoCapture(args.input, cv2.CAP_FFMPEG) + + if not cap.isOpened(): + print("Erreur: Impossible d'ouvrir le flux vidéo.") + sys.exit(1) + + if args.force: + cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) + + if args.output: + w, h, fps = (int(cap.get(x)) for x in (cv2.CAP_PROP_FRAME_WIDTH, cv2.CAP_PROP_FRAME_HEIGHT, cv2.CAP_PROP_FPS)) + video_writer = cv2.VideoWriter(args.output, cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h)) + + if args.verbose: + print("Capture vidéo initialisée.") + + # Skip if no video capture is open + if not cap: + await asyncio.sleep(1) + continue + + # Read frame and process + success, frame = cap.read() + if not success: + print("Erreur: Impossible de lire le flux vidéo. Libération des ressources.") + status = 0 # Force reset + continue + + # declare start timing variable + if args.verbose: + time_start = time.time() + + # Run YOLO tracking on the frame + results = model.track(frame, persist=True, classes=[0], tracker="track/botsort.yaml", verbose=False) + annotated_frame = results[0].plot() + + # declare mid timing variable + if args.verbose: + time_mid = time.time() + + new_people = set() + tasks = [] + pos_tasks = [] + + if results[0].boxes.id is not None: + boxes = results[0].boxes.xywh.cpu() + track_ids = results[0].boxes.id.int().cpu().tolist() + + for box, track_id in zip(boxes, track_ids): + x, y, w, h = box + track = track_history[track_id] + track.append((float(x), float(y))) + if len(track) > 30: + track.pop(0) + + new_people.add(track_id) + + # Detect entries and exits + entered = new_people - current_people + exited = current_people - new_people + + # Send event to API for each ID + if args.API_URL and status == 1: + for person_id in entered: + tasks.append(send_event(session, "enter", len(new_people), person_id, args.verbose)) + for person_id in exited: + tasks.append(send_event(session, "exit", len(new_people), person_id, args.verbose)) + + # Send position to API for each ID + if args.API_URL and status == 1: + if int(time.time()) >= (last_position_sent+1): + payload_positions = [] + height_img, width_img = annotated_frame.shape[:2] + boxes_n = results[0].boxes.xywh.cpu() + for box, track_id in zip(boxes_n, track_ids): + x, y, w, h = [float(coord.item()) for coord in box] + x = (x-(w/2)) / width_img + y = (y-(h/2)) / height_img + w = w / width_img + h = h / height_img + payload_positions.append([{"pos_x": round(x, 4), "pos_y": round(y, 4), "w": round(w, 4), "h": round(h, 4)}, {"id": track_id}]) + last_position_sent = int(time.time()) + pos_tasks.append(send_position(session, payload_positions, args.verbose)) + + current_people = new_people + else: + # No people detected, send exit event for all remaining + if current_people and status == 1: + if args.API_URL: + for person_id in current_people: + tasks.append(send_event(session, "exit", 0, person_id, args.verbose)) + current_people = set() + + # Await all API calls concurrently + if tasks: + await asyncio.gather(*tasks) + if pos_tasks: + await asyncio.gather(*pos_tasks) + + # Math FPS + if args.verbose: + time_end = time.time() + fps_display = int(1 / (time_end - prev_time_fps)) + prev_time_fps = time_end + + # Math MS + time_mid_temp = round((time_mid - time_start)*1000 , 1) + time_end_temp = round((time_end - time_mid)*1000 , 1) + time_mid_list.append(time_mid_temp) + time_end_list.append(time_end_temp) + fps_list.append(fps_display) + + # Print iteration measurements + print(f"Temps de réponse détection/suivi: {time_mid_temp}\nTemps de réponse dessin: {time_end_temp}\nTemps de travail total: {round(time_mid_temp + time_end_temp, 4)}\nImages par seconde: {fps_display}\n-------------------------") + + # Prepare OpenCV to print + cv2.putText(annotated_frame, f"Nombre visiteurs: {len(current_people)}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) + cv2.imshow("Vision par ordinateur - Python", annotated_frame) + if args.output: + video_writer.write(annotated_frame) + + if cv2.waitKey(1) & 0xFF == ord("q"): + if cap: + cap.release() + if video_writer: + video_writer.release() + cv2.destroyAllWindows() + break + +# Run the async main loop +asyncio.run(main_loop()) + +# Print Summary +if args.verbose and time_mid_list: + print(f"Rapport de performances:\nTemps de réponse moyen détection/suivi: {round(sum(time_mid_list) / len(time_mid_list), 1)}\nTemps de réponse moyen dessin: {round(sum(time_end_list) / len(time_end_list), 1)}\nTemps de réponse moyen: {round((sum(time_mid_list) / len(time_mid_list)) + (sum(time_end_list) / len(time_end_list)), 1)}\nMoyenne images par seconde: {round(sum(fps_list) / len(fps_list), 2)}") \ No newline at end of file diff --git a/models/yolo11n.pt b/models/yolo11n.pt new file mode 100644 index 0000000..45b273b Binary files /dev/null and b/models/yolo11n.pt differ diff --git a/models/yolo12n.pt b/models/yolo12n.pt new file mode 100644 index 0000000..0bf3d92 Binary files /dev/null and b/models/yolo12n.pt differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c857d68 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,62 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.12.7 +aiosignal==1.3.2 +attrs==25.3.0 +certifi==2025.4.26 +charset-normalizer==3.4.2 +contourpy==1.3.2 +cycler==0.12.1 +filelock==3.18.0 +fonttools==4.58.1 +frozenlist==1.6.0 +fsspec==2025.5.1 +idna==3.10 +Jinja2==3.1.6 +kiwisolver==1.4.8 +lap==0.5.12 +MarkupSafe==3.0.2 +matplotlib==3.10.3 +mpmath==1.3.0 +multidict==6.4.4 +networkx==3.5 +numpy==2.2.6 +nvidia-cublas-cu12==12.6.4.1 +nvidia-cuda-cupti-cu12==12.6.80 +nvidia-cuda-nvrtc-cu12==12.6.77 +nvidia-cuda-runtime-cu12==12.6.77 +nvidia-cudnn-cu12==9.5.1.17 +nvidia-cufft-cu12==11.3.0.4 +nvidia-cufile-cu12==1.11.1.6 +nvidia-curand-cu12==10.3.7.77 +nvidia-cusolver-cu12==11.7.1.2 +nvidia-cusparse-cu12==12.5.4.2 +nvidia-cusparselt-cu12==0.6.3 +nvidia-nccl-cu12==2.26.2 +nvidia-nvjitlink-cu12==12.6.85 +nvidia-nvtx-cu12==12.6.77 +opencv-python==4.11.0.86 +packaging==25.0 +pandas==2.2.3 +pillow==11.2.1 +propcache==0.3.1 +psutil==7.0.0 +py-cpuinfo==9.0.0 +pyparsing==3.2.3 +python-dateutil==2.9.0.post0 +pytz==2025.2 +PyYAML==6.0.2 +requests==2.32.3 +scipy==1.15.3 +setuptools==80.9.0 +six==1.17.0 +sympy==1.14.0 +torch==2.7.0 +torchvision==0.22.0 +tqdm==4.67.1 +triton==3.3.0 +typing_extensions==4.14.0 +tzdata==2025.2 +ultralytics==8.3.148 +ultralytics-thop==2.0.14 +urllib3==2.4.0 +yarl==1.20.0 diff --git a/track/botsort.yaml b/track/botsort.yaml new file mode 100644 index 0000000..aedcee4 --- /dev/null +++ b/track/botsort.yaml @@ -0,0 +1,21 @@ +# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license + +# Default Ultralytics settings for BoT-SORT tracker when using mode="track" +# For documentation and examples see https://docs.ultralytics.com/modes/track/ +# For BoT-SORT source code see https://github.com/NirAharon/BoT-SORT + +tracker_type: botsort # tracker type, ['botsort', 'bytetrack'] +track_high_thresh: 0.25 # threshold for the first association +track_low_thresh: 0.1 # threshold for the second association +new_track_thresh: 0.25 # threshold for init new track if the detection does not match any tracks +track_buffer: 30 # buffer to calculate the time when to remove tracks +match_thresh: 0.8 # threshold for matching tracks +fuse_score: True # Whether to fuse confidence scores with the iou distances before matching +# min_box_area: 10 # threshold for min box areas(for tracker evaluation, not used for now) + +# BoT-SORT settings +gmc_method: sparseOptFlow # method of global motion compensation +# ReID model related thresh (not supported yet) +proximity_thresh: 0.5 +appearance_thresh: 0.25 +with_reid: False diff --git a/track/bytetrack.yaml b/track/bytetrack.yaml new file mode 100644 index 0000000..62071a3 --- /dev/null +++ b/track/bytetrack.yaml @@ -0,0 +1,14 @@ +# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license + +# Default Ultralytics settings for ByteTrack tracker when using mode="track" +# For documentation and examples see https://docs.ultralytics.com/modes/track/ +# For ByteTrack source code see https://github.com/ifzhang/ByteTrack + +tracker_type: bytetrack # tracker type, ['botsort', 'bytetrack'] +track_high_thresh: 0.25 # threshold for the first association +track_low_thresh: 0.1 # threshold for the second association +new_track_thresh: 0.25 # threshold for init new track if the detection does not match any tracks +track_buffer: 30 # buffer to calculate the time when to remove tracks +match_thresh: 0.8 # threshold for matching tracks +fuse_score: True # Whether to fuse confidence scores with the iou distances before matching +# min_box_area: 10 # threshold for min box areas(for tracker evaluation, not used for now)