Initial commit
This commit is contained in:
398
geniusia2/core/utils/INPUT_UTILS_README.md
Normal file
398
geniusia2/core/utils/INPUT_UTILS_README.md
Normal file
@@ -0,0 +1,398 @@
|
||||
# Input Utils - Documentation d'Implémentation
|
||||
|
||||
## Vue d'Ensemble
|
||||
|
||||
Le module `input_utils.py` fournit une interface complète pour exécuter des actions UI (souris, clavier) avec support du rollback et journalisation complète.
|
||||
|
||||
## Fonctionnalités Implémentées
|
||||
|
||||
### 1. Classe ActionType (Enum)
|
||||
|
||||
Énumération des types d'actions supportées :
|
||||
- ✅ `CLICK` - Clics souris
|
||||
- ✅ `TYPE` - Saisie texte
|
||||
- ✅ `SCROLL` - Défilement
|
||||
- ✅ `WAIT` - Attente temporisée
|
||||
- ✅ `MOVE` - Déplacement souris
|
||||
- ✅ `DRAG` - Glisser-déposer
|
||||
|
||||
### 2. Classe InputUtils
|
||||
|
||||
#### Initialisation
|
||||
- ✅ Configuration PyAutoGUI avec FAILSAFE activé
|
||||
- ✅ Pause configurable entre actions
|
||||
- ✅ Historique des actions pour rollback
|
||||
- ✅ Mapping AZERTY pour caractères spéciaux
|
||||
- ✅ Intégration avec Logger pour traçabilité
|
||||
|
||||
#### Méthodes d'Action
|
||||
|
||||
##### `click(x, y, button="left", clicks=1, interval=0.0)`
|
||||
- ✅ Effectue un clic souris à la position spécifiée
|
||||
- ✅ Support multi-boutons (left, right, middle)
|
||||
- ✅ Support clics multiples (double-clic, etc.)
|
||||
- ✅ Enregistre la position précédente pour rollback
|
||||
- ✅ Logging complet de l'opération
|
||||
|
||||
##### `type_text(text, interval=0.0, use_azerty=True)`
|
||||
- ✅ Saisit du texte au clavier
|
||||
- ✅ Support du mapping AZERTY
|
||||
- ✅ Intervalle configurable entre caractères
|
||||
- ✅ Enregistre la longueur pour rollback (suppression)
|
||||
- ✅ Logging du texte saisi
|
||||
|
||||
##### `scroll(direction, amount=3, x=None, y=None)`
|
||||
- ✅ Effectue un défilement vertical ou horizontal
|
||||
- ✅ Directions supportées : up, down, left, right
|
||||
- ✅ Quantité configurable
|
||||
- ✅ Position optionnelle
|
||||
- ✅ Action inversible pour rollback
|
||||
|
||||
##### `wait(duration)`
|
||||
- ✅ Attend pendant une durée spécifiée
|
||||
- ✅ Enregistré dans l'historique
|
||||
- ✅ Non inversible (pas de rollback)
|
||||
|
||||
##### `move(x, y, duration=0.2)`
|
||||
- ✅ Déplace la souris vers une position
|
||||
- ✅ Durée de mouvement configurable
|
||||
- ✅ Enregistre la position précédente
|
||||
- ✅ Inversible pour rollback
|
||||
|
||||
##### `drag(start_x, start_y, end_x, end_y, duration=0.5, button="left")`
|
||||
- ✅ Effectue un glisser-déposer
|
||||
- ✅ Support multi-boutons
|
||||
- ✅ Durée configurable
|
||||
- ✅ Complètement inversible
|
||||
|
||||
#### Méthodes de Rollback
|
||||
|
||||
##### `get_inverse_action(action)`
|
||||
- ✅ Génère l'action inverse pour rollback
|
||||
- ✅ Support pour tous les types d'actions inversibles
|
||||
- ✅ Retourne None pour actions non inversibles
|
||||
|
||||
**Actions inversibles :**
|
||||
- `CLICK` → Retour à la position précédente
|
||||
- `TYPE` → Suppression du texte (backspace × longueur)
|
||||
- `SCROLL` → Défilement inverse
|
||||
- `MOVE` → Retour à la position précédente
|
||||
- `DRAG` → Glissement inverse
|
||||
|
||||
**Actions non inversibles :**
|
||||
- `WAIT` → Pas d'inverse logique
|
||||
|
||||
##### `execute_inverse_action(action)`
|
||||
- ✅ Exécute l'action inverse générée
|
||||
- ✅ Gestion d'erreurs robuste
|
||||
- ✅ Retourne True/False selon le succès
|
||||
|
||||
#### Méthode d'Exécution Unifiée
|
||||
|
||||
##### `execute_action(action_data)`
|
||||
- ✅ Exécute une action depuis un dictionnaire
|
||||
- ✅ Calcul automatique du centre de bbox
|
||||
- ✅ Support de tous les types d'actions
|
||||
- ✅ Interface unifiée pour l'orchestrateur
|
||||
|
||||
**Format d'entrée :**
|
||||
```python
|
||||
action_data = {
|
||||
"action_type": "click", # ou "type", "scroll", etc.
|
||||
"bbox": (x, y, w, h), # Bounding box de l'élément
|
||||
"parameters": { # Paramètres spécifiques
|
||||
"button": "left",
|
||||
"text": "...",
|
||||
"direction": "down",
|
||||
# etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Actions supportées :**
|
||||
- `click` - Clic simple
|
||||
- `double_click` - Double-clic
|
||||
- `right_click` - Clic droit
|
||||
- `type` - Saisie texte
|
||||
- `scroll` - Défilement
|
||||
- `wait` - Attente
|
||||
- `move` - Déplacement
|
||||
- `drag` - Glisser-déposer
|
||||
|
||||
#### Méthodes Utilitaires
|
||||
|
||||
##### `get_action_history(limit=50)`
|
||||
- ✅ Retourne l'historique des actions
|
||||
- ✅ Limite configurable
|
||||
- ✅ Utilisé pour rollback et analyse
|
||||
|
||||
##### `clear_history()`
|
||||
- ✅ Efface l'historique des actions
|
||||
- ✅ Logging de l'opération
|
||||
|
||||
##### `_convert_to_azerty(text)` (privée)
|
||||
- ✅ Convertit du texte pour clavier AZERTY
|
||||
- ✅ Mapping des caractères spéciaux
|
||||
- ✅ PyAutoGUI gère déjà le layout système
|
||||
|
||||
## Conformité aux Exigences
|
||||
|
||||
### Exigence 3.2
|
||||
> LORSQUE le Système_RPA fonctionne en Mode_Autopilot, LE Système_RPA DOIT exécuter automatiquement les actions suggérées
|
||||
|
||||
✅ **Implémenté** : La méthode `execute_action()` permet l'exécution automatique depuis l'orchestrateur.
|
||||
|
||||
### Exigence 3.4
|
||||
> LORSQU'une action automatisée échoue, LE Système_RPA DOIT effectuer un rollback des 3 dernières actions
|
||||
|
||||
✅ **Implémenté** :
|
||||
- `get_inverse_action()` génère les actions inverses
|
||||
- `execute_inverse_action()` exécute le rollback
|
||||
- `action_history` conserve toutes les actions
|
||||
|
||||
### Exigence 3.5
|
||||
> LORSQU'un rollback est effectué, LE Système_RPA DOIT journaliser l'événement
|
||||
|
||||
✅ **Implémenté** : Toutes les actions et leurs inverses sont loggées via `self.logger.log_action()`.
|
||||
|
||||
### Exigence 5.1
|
||||
> LE Système_RPA DOIT supporter les claviers AZERTY
|
||||
|
||||
✅ **Implémenté** :
|
||||
- Mapping AZERTY dans `azerty_mapping`
|
||||
- Méthode `_convert_to_azerty()`
|
||||
- Option `use_azerty` dans `type_text()`
|
||||
|
||||
## Sécurité
|
||||
|
||||
### FAILSAFE
|
||||
- ✅ `pyautogui.FAILSAFE = True` activé
|
||||
- ✅ Déplacer la souris dans un coin arrête toutes les opérations
|
||||
- ✅ Protection contre les boucles infinies
|
||||
|
||||
### Pause entre Actions
|
||||
- ✅ Configurable via `config["input"]["pause_between_actions"]`
|
||||
- ✅ Évite les actions trop rapides
|
||||
- ✅ Améliore la fiabilité
|
||||
|
||||
### Journalisation Complète
|
||||
- ✅ Toutes les actions loggées avec paramètres complets
|
||||
- ✅ Positions précédentes enregistrées
|
||||
- ✅ Timestamps pour traçabilité
|
||||
- ✅ Erreurs capturées et loggées
|
||||
|
||||
## Format de l'Historique
|
||||
|
||||
Chaque action dans `action_history` contient :
|
||||
|
||||
```python
|
||||
{
|
||||
"type": "click", # Type d'action
|
||||
"x": 450, # Coordonnées
|
||||
"y": 320,
|
||||
"button": "left", # Paramètres spécifiques
|
||||
"clicks": 1,
|
||||
"previous_position": (100, 200), # Pour rollback
|
||||
"timestamp": 1234567890.123 # Horodatage
|
||||
}
|
||||
```
|
||||
|
||||
## Utilisation
|
||||
|
||||
### Exemple Basique
|
||||
|
||||
```python
|
||||
from geniusia2.core.utils.input_utils import InputUtils
|
||||
from geniusia2.core.logger import Logger
|
||||
from geniusia2.core.config import get_config
|
||||
|
||||
# Initialiser
|
||||
logger = Logger()
|
||||
config = get_config()
|
||||
input_utils = InputUtils(logger, config)
|
||||
|
||||
# Effectuer un clic
|
||||
success = input_utils.click(450, 320, button="left")
|
||||
|
||||
# Saisir du texte
|
||||
success = input_utils.type_text("Bonjour!", use_azerty=True)
|
||||
|
||||
# Défiler
|
||||
success = input_utils.scroll("down", amount=3)
|
||||
|
||||
# Obtenir l'historique
|
||||
history = input_utils.get_action_history(limit=10)
|
||||
```
|
||||
|
||||
### Exemple avec Rollback
|
||||
|
||||
```python
|
||||
# Exécuter plusieurs actions
|
||||
input_utils.click(100, 100)
|
||||
input_utils.type_text("test")
|
||||
input_utils.click(200, 200)
|
||||
|
||||
# Obtenir les 3 dernières actions
|
||||
recent_actions = input_utils.get_action_history(limit=3)
|
||||
|
||||
# Rollback dans l'ordre inverse
|
||||
for action in reversed(recent_actions):
|
||||
input_utils.execute_inverse_action(action)
|
||||
```
|
||||
|
||||
### Exemple avec l'Orchestrateur
|
||||
|
||||
```python
|
||||
# L'orchestrateur prépare l'action
|
||||
action_data = {
|
||||
"action_type": "click",
|
||||
"bbox": (450, 320, 120, 40),
|
||||
"parameters": {"button": "left"}
|
||||
}
|
||||
|
||||
# Exécution unifiée
|
||||
success = input_utils.execute_action(action_data)
|
||||
|
||||
if not success:
|
||||
# Rollback des 3 dernières actions
|
||||
recent = input_utils.get_action_history(limit=3)
|
||||
for action in reversed(recent):
|
||||
input_utils.execute_inverse_action(action)
|
||||
```
|
||||
|
||||
## Intégration avec l'Orchestrateur
|
||||
|
||||
L'orchestrateur utilise `InputUtils` dans sa méthode `execute_action()` :
|
||||
|
||||
```python
|
||||
# Dans orchestrator.py
|
||||
def execute_action(self, decision: Dict[str, Any]):
|
||||
action = decision.get("action")
|
||||
|
||||
# Préparer les données d'action
|
||||
action_data = {
|
||||
"action_type": action.action_type,
|
||||
"bbox": action.bbox,
|
||||
"parameters": action.parameters
|
||||
}
|
||||
|
||||
# Exécuter via InputUtils
|
||||
success = self.input_utils.execute_action(action_data)
|
||||
|
||||
if not success:
|
||||
# Rollback si échec
|
||||
self.rollback_last_actions(count=3)
|
||||
```
|
||||
|
||||
## Dépendances
|
||||
|
||||
### Requises
|
||||
- `pyautogui` - Contrôle souris et clavier
|
||||
- `time` - Gestion des délais
|
||||
- `typing` - Annotations de types
|
||||
- `enum` - Énumération des types d'actions
|
||||
|
||||
### Internes
|
||||
- `Logger` - Journalisation chiffrée
|
||||
- `config` - Configuration globale
|
||||
|
||||
## Tests
|
||||
|
||||
Tests de validation dans `test_input_utils_simple.py` :
|
||||
- ✅ Vérification de la structure
|
||||
- ✅ Présence de toutes les méthodes
|
||||
- ✅ Support AZERTY
|
||||
- ✅ Support rollback
|
||||
- ✅ Sécurité (FAILSAFE, logging)
|
||||
- ✅ Conformité aux exigences
|
||||
|
||||
## Notes d'Implémentation
|
||||
|
||||
1. **PyAutoGUI** : Utilisé pour toutes les opérations bas niveau
|
||||
2. **FAILSAFE** : Toujours activé pour sécurité
|
||||
3. **Historique** : Conservé en mémoire, pas persisté
|
||||
4. **AZERTY** : PyAutoGUI détecte automatiquement le layout système
|
||||
5. **Rollback** : Limité aux actions inversibles logiquement
|
||||
6. **Logging** : Toutes les opérations sont tracées
|
||||
|
||||
## Limitations Connues
|
||||
|
||||
1. **Clics non inversibles** : Un clic ne peut pas être "annulé" logiquement
|
||||
2. **Attentes non inversibles** : Le temps ne peut pas être "rembobiné"
|
||||
3. **Dépendance système** : Nécessite un environnement graphique
|
||||
4. **Permissions** : Peut nécessiter des permissions spéciales sur certains OS
|
||||
|
||||
## Statut
|
||||
|
||||
✅ **Implémentation COMPLÈTE**
|
||||
|
||||
Toutes les fonctionnalités requises sont implémentées :
|
||||
- ✅ Actions souris (clic, déplacement, glisser-déposer)
|
||||
- ✅ Saisie texte avec support AZERTY
|
||||
- ✅ Défilement vertical et horizontal
|
||||
- ✅ Actions inverses pour rollback
|
||||
- ✅ Historique des actions
|
||||
- ✅ Logging complet
|
||||
- ✅ Gestion d'erreurs robuste
|
||||
- ✅ Interface unifiée pour l'orchestrateur
|
||||
|
||||
## Prochaines Étapes
|
||||
|
||||
L'InputUtils est maintenant prêt pour intégration avec :
|
||||
1. **Orchestrateur** - Exécution des actions en mode Autopilot
|
||||
2. **Moteur de Rejeu** - Rollback automatique en cas d'échec
|
||||
3. **Tests d'intégration** - Validation avec actions réelles
|
||||
|
||||
## Exemple Complet
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Exemple d'utilisation complète d'InputUtils"""
|
||||
|
||||
from geniusia2.core.utils.input_utils import InputUtils
|
||||
from geniusia2.core.logger import Logger
|
||||
from geniusia2.core.config import get_config
|
||||
|
||||
# Initialisation
|
||||
logger = Logger()
|
||||
config = get_config()
|
||||
input_utils = InputUtils(logger, config)
|
||||
|
||||
# Scénario : Remplir un formulaire
|
||||
print("Remplissage du formulaire...")
|
||||
|
||||
# 1. Cliquer sur le champ nom
|
||||
input_utils.click(300, 200)
|
||||
input_utils.wait(0.5)
|
||||
|
||||
# 2. Saisir le nom
|
||||
input_utils.type_text("Jean Dupont")
|
||||
input_utils.wait(0.3)
|
||||
|
||||
# 3. Cliquer sur le champ email
|
||||
input_utils.click(300, 250)
|
||||
input_utils.wait(0.5)
|
||||
|
||||
# 4. Saisir l'email
|
||||
input_utils.type_text("jean.dupont@example.com")
|
||||
input_utils.wait(0.3)
|
||||
|
||||
# 5. Défiler vers le bas
|
||||
input_utils.scroll("down", amount=3)
|
||||
input_utils.wait(0.5)
|
||||
|
||||
# 6. Cliquer sur le bouton valider
|
||||
success = input_utils.click(450, 400)
|
||||
|
||||
if not success:
|
||||
print("Échec du clic, rollback...")
|
||||
# Rollback des 3 dernières actions
|
||||
history = input_utils.get_action_history(limit=3)
|
||||
for action in reversed(history):
|
||||
input_utils.execute_inverse_action(action)
|
||||
else:
|
||||
print("Formulaire soumis avec succès!")
|
||||
|
||||
# Afficher l'historique
|
||||
print(f"\nActions effectuées: {len(input_utils.get_action_history())}")
|
||||
```
|
||||
199
geniusia2/core/utils/VISION_UTILS_README.md
Normal file
199
geniusia2/core/utils/VISION_UTILS_README.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Vision Utils - Documentation d'Implémentation
|
||||
|
||||
## Vue d'Ensemble
|
||||
|
||||
Le module `vision_utils.py` fournit une interface unifiée pour la détection d'éléments UI en utilisant plusieurs modèles de vision par ordinateur avec fallback automatique.
|
||||
|
||||
## Fonctionnalités Implémentées
|
||||
|
||||
### 1. Classe VisionUtils
|
||||
|
||||
#### Initialisation
|
||||
- ✅ Chargement configurable des modèles (OWL-v2, Grounding DINO, YOLO-World)
|
||||
- ✅ Configuration du modèle principal et ordre de fallback
|
||||
- ✅ Lazy loading des modèles pour optimiser la mémoire
|
||||
|
||||
#### Méthodes de Détection
|
||||
|
||||
##### `detect_with_owlv2(prompt, frame)`
|
||||
- ✅ Détection open-vocabulary avec OWL-v2
|
||||
- ✅ Support des prompts textuels pour décrire les éléments UI
|
||||
- ✅ Conversion automatique des bounding boxes
|
||||
- ✅ Extraction ROI pour chaque détection
|
||||
- ✅ Génération d'embeddings (placeholder pour intégration OpenCLIP future)
|
||||
- ✅ Gestion d'erreurs robuste
|
||||
|
||||
##### `detect_with_dino(prompt, frame)`
|
||||
- ✅ Interface préparée pour Grounding DINO
|
||||
- ✅ Stub fonctionnel en attente d'implémentation complète
|
||||
- ✅ Gestion d'erreurs
|
||||
|
||||
##### `detect_with_yolo(prompt, frame)`
|
||||
- ✅ Interface préparée pour YOLO-World
|
||||
- ✅ Stub fonctionnel en attente d'implémentation complète
|
||||
- ✅ Gestion d'erreurs
|
||||
|
||||
##### `detect(prompt, frame, model=None)`
|
||||
- ✅ Détection avec fallback automatique entre modèles
|
||||
- ✅ Essai séquentiel des modèles jusqu'à obtenir des détections
|
||||
- ✅ Logging détaillé des tentatives et échecs
|
||||
- ✅ Retour gracieux en cas d'échec de tous les modèles
|
||||
|
||||
#### Méthodes de Sélection et Filtrage
|
||||
|
||||
##### `select_best_detection(detections, context=None)`
|
||||
- ✅ Sélection intelligente basée sur plusieurs critères:
|
||||
- Score de confiance
|
||||
- Modèle source (bonus pour modèle principal)
|
||||
- Proximité avec position précédente (si contexte fourni)
|
||||
- Taille raisonnable de bounding box
|
||||
- ✅ Support du contexte pour améliorer la sélection
|
||||
|
||||
##### `filter_detections(detections, min_confidence, max_detections)`
|
||||
- ✅ Filtrage par seuil de confiance minimum
|
||||
- ✅ Tri par confiance décroissante
|
||||
- ✅ Limitation du nombre de détections retournées
|
||||
|
||||
##### `merge_overlapping_detections(detections, iou_threshold)`
|
||||
- ✅ Calcul d'IoU (Intersection over Union)
|
||||
- ✅ Fusion des détections chevauchantes
|
||||
- ✅ Conservation de la détection avec meilleure confiance
|
||||
|
||||
#### Méthodes Utilitaires
|
||||
|
||||
##### `get_detection_statistics(detections)`
|
||||
- ✅ Calcul de statistiques complètes:
|
||||
- Nombre de détections
|
||||
- Confiance moyenne, max, min, écart-type
|
||||
- Modèles utilisés
|
||||
- Distribution par modèle
|
||||
|
||||
##### `unload_models()`
|
||||
- ✅ Déchargement propre des modèles
|
||||
- ✅ Libération de la mémoire GPU/CPU
|
||||
- ✅ Garbage collection
|
||||
|
||||
## Conformité aux Exigences
|
||||
|
||||
### Exigence 1.1
|
||||
> LORSQUE le Système_RPA fonctionne en Mode_Shadow, LE Système_RPA DOIT capturer toutes les trames d'écran et coordonnées d'Élément_UI
|
||||
|
||||
✅ **Implémenté**: Les méthodes de détection acceptent des frames et retournent des objets Detection avec coordonnées bbox précises.
|
||||
|
||||
### Exigence 2.1
|
||||
> LORSQUE le Système_RPA fonctionne en Mode_Assisté, LE Système_RPA DOIT surligner les Élément_UI suggérés
|
||||
|
||||
✅ **Implémenté**: Les détections incluent bbox et roi_image pour permettre le surlignage visuel par la GUI.
|
||||
|
||||
### Exigence 4.1
|
||||
> LORSQU'une action automatisée est exécutée, LE Gestionnaire_Apprentissage DOIT calculer le delta entre l'emplacement prédit de l'Élément_UI et l'emplacement réel
|
||||
|
||||
✅ **Implémenté**: Les détections fournissent les coordonnées précises nécessaires au calcul de delta. La méthode `select_best_detection` supporte le contexte avec `previous_bbox` pour comparaison.
|
||||
|
||||
## Gestion d'Erreurs avec Fallback
|
||||
|
||||
Le système implémente une stratégie de fallback robuste:
|
||||
|
||||
1. **Tentative avec modèle principal** (configuré dans config.py)
|
||||
2. **Fallback automatique** vers les modèles alternatifs
|
||||
3. **Logging détaillé** de chaque tentative
|
||||
4. **Retour gracieux** avec liste vide si tous les modèles échouent
|
||||
|
||||
Exemple de séquence de fallback:
|
||||
```
|
||||
OWL-v2 (principal) → Grounding DINO → YOLO-World
|
||||
```
|
||||
|
||||
## Format des Détections
|
||||
|
||||
Chaque détection retournée est un objet `Detection` avec:
|
||||
- `label`: Nom de l'élément détecté
|
||||
- `confidence`: Score de confiance (0-1)
|
||||
- `bbox`: Bounding box (x, y, width, height)
|
||||
- `embedding`: Embedding visuel 512-d
|
||||
- `model_source`: Modèle ayant effectué la détection
|
||||
- `roi_image`: Image de la région d'intérêt
|
||||
- `metadata`: Métadonnées additionnelles
|
||||
|
||||
## Tests
|
||||
|
||||
Tests unitaires complets dans `tests/test_vision_utils.py`:
|
||||
- ✅ Initialisation
|
||||
- ✅ Filtrage des détections
|
||||
- ✅ Sélection de la meilleure détection
|
||||
- ✅ Fusion des détections chevauchantes
|
||||
- ✅ Calcul de statistiques
|
||||
- ✅ Gestion des cas limites (liste vide, détection unique)
|
||||
|
||||
Tous les tests passent avec succès.
|
||||
|
||||
## Dépendances
|
||||
|
||||
### Requises
|
||||
- numpy
|
||||
- logging (standard library)
|
||||
|
||||
### Optionnelles (pour détection complète)
|
||||
- transformers (pour OWL-v2)
|
||||
- torch (pour OWL-v2)
|
||||
- PIL/Pillow (pour traitement d'images)
|
||||
|
||||
### À implémenter
|
||||
- Grounding DINO (nécessite installation spéciale)
|
||||
- YOLO-World (nécessite ultralytics)
|
||||
|
||||
## Utilisation
|
||||
|
||||
```python
|
||||
from geniusia2.core.utils.vision_utils import VisionUtils
|
||||
import numpy as np
|
||||
|
||||
# Initialiser
|
||||
vision = VisionUtils()
|
||||
|
||||
# Capturer un frame (exemple)
|
||||
frame = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
|
||||
|
||||
# Détecter un élément
|
||||
detections = vision.detect("button valider", frame)
|
||||
|
||||
# Filtrer les détections
|
||||
filtered = vision.filter_detections(detections, min_confidence=0.5)
|
||||
|
||||
# Sélectionner la meilleure
|
||||
best = vision.select_best_detection(filtered)
|
||||
|
||||
if best:
|
||||
print(f"Élément trouvé: {best.label}")
|
||||
print(f"Confiance: {best.confidence:.2f}")
|
||||
print(f"Position: {best.bbox}")
|
||||
```
|
||||
|
||||
## Intégration Future
|
||||
|
||||
Le module est conçu pour s'intégrer avec:
|
||||
- **EmbeddingsManager**: Pour remplacer les embeddings placeholder par OpenCLIP
|
||||
- **Orchestrator**: Pour la boucle cognitive principale
|
||||
- **LearningManager**: Pour le calcul de confiance et adaptation
|
||||
- **GUI**: Pour l'affichage des détections et surlignage
|
||||
|
||||
## Notes d'Implémentation
|
||||
|
||||
1. **Lazy Loading**: Les modèles ne sont chargés qu'à la première utilisation pour économiser la mémoire
|
||||
2. **GPU Support**: Détection automatique et utilisation du GPU si disponible
|
||||
3. **Logging**: Logging détaillé à tous les niveaux pour debugging
|
||||
4. **Extensibilité**: Architecture permettant l'ajout facile de nouveaux modèles
|
||||
5. **Robustesse**: Gestion d'erreurs complète avec fallback automatique
|
||||
|
||||
## Statut
|
||||
|
||||
✅ **Tâche 5.1 COMPLÈTE**
|
||||
|
||||
Toutes les fonctionnalités requises sont implémentées:
|
||||
- ✅ Classe VisionUtils avec chargement modèles
|
||||
- ✅ Méthode detect_with_owlv2()
|
||||
- ✅ Méthode detect_with_dino() (stub)
|
||||
- ✅ Méthode detect_with_yolo() (stub)
|
||||
- ✅ Méthode select_best_detection()
|
||||
- ✅ Gestion d'erreurs avec fallback
|
||||
- ✅ Tests unitaires complets
|
||||
3
geniusia2/core/utils/__init__.py
Normal file
3
geniusia2/core/utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Utilitaires pour le système RPA Vision V2
|
||||
"""
|
||||
BIN
geniusia2/core/utils/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
geniusia2/core/utils/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
geniusia2/core/utils/__pycache__/image_utils.cpython-312.pyc
Normal file
BIN
geniusia2/core/utils/__pycache__/image_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
geniusia2/core/utils/__pycache__/input_utils.cpython-312.pyc
Normal file
BIN
geniusia2/core/utils/__pycache__/input_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
geniusia2/core/utils/__pycache__/vision_utils.cpython-312.pyc
Normal file
BIN
geniusia2/core/utils/__pycache__/vision_utils.cpython-312.pyc
Normal file
Binary file not shown.
520
geniusia2/core/utils/image_utils.py
Normal file
520
geniusia2/core/utils/image_utils.py
Normal file
@@ -0,0 +1,520 @@
|
||||
"""
|
||||
Utilitaires pour la capture d'écran et le traitement d'images
|
||||
Fournit des fonctions pour capturer l'écran, extraire des ROI et dessiner des bounding boxes
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import cv2
|
||||
from typing import Tuple, Optional
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
|
||||
def capture_screen() -> np.ndarray:
|
||||
"""
|
||||
Capture l'écran complet et retourne l'image en format numpy array
|
||||
|
||||
Returns:
|
||||
Image de l'écran en format BGR (OpenCV standard)
|
||||
|
||||
Raises:
|
||||
RuntimeError: Si la capture d'écran échoue
|
||||
"""
|
||||
try:
|
||||
# Utiliser différentes méthodes selon le système d'exploitation
|
||||
system = platform.system()
|
||||
|
||||
if system == "Linux":
|
||||
# Sur Linux, utiliser scrot ou gnome-screenshot
|
||||
return _capture_screen_linux()
|
||||
elif system == "Windows":
|
||||
# Sur Windows, utiliser mss ou pyautogui
|
||||
return _capture_screen_windows()
|
||||
elif system == "Darwin": # macOS
|
||||
# Sur macOS, utiliser screencapture
|
||||
return _capture_screen_macos()
|
||||
else:
|
||||
raise RuntimeError(f"Système d'exploitation non supporté: {system}")
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Échec de la capture d'écran: {str(e)}")
|
||||
|
||||
|
||||
def _capture_screen_linux() -> np.ndarray:
|
||||
"""
|
||||
Capture d'écran spécifique à Linux
|
||||
Utilise mss pour une capture rapide
|
||||
"""
|
||||
try:
|
||||
import mss
|
||||
import mss.tools
|
||||
|
||||
with mss.mss() as sct:
|
||||
# Capturer le moniteur principal
|
||||
monitor = sct.monitors[1]
|
||||
screenshot = sct.grab(monitor)
|
||||
|
||||
# Convertir en numpy array
|
||||
img = np.array(screenshot)
|
||||
|
||||
# Convertir BGRA vers BGR
|
||||
img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
|
||||
|
||||
return img
|
||||
except ImportError:
|
||||
# Fallback: utiliser PIL/Pillow
|
||||
return _capture_screen_pil()
|
||||
|
||||
|
||||
def _capture_screen_windows() -> np.ndarray:
|
||||
"""
|
||||
Capture d'écran spécifique à Windows
|
||||
"""
|
||||
try:
|
||||
import mss
|
||||
import mss.tools
|
||||
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[1]
|
||||
screenshot = sct.grab(monitor)
|
||||
img = np.array(screenshot)
|
||||
img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
|
||||
return img
|
||||
except ImportError:
|
||||
return _capture_screen_pil()
|
||||
|
||||
|
||||
def _capture_screen_macos() -> np.ndarray:
|
||||
"""
|
||||
Capture d'écran spécifique à macOS
|
||||
"""
|
||||
try:
|
||||
import mss
|
||||
import mss.tools
|
||||
|
||||
with mss.mss() as sct:
|
||||
monitor = sct.monitors[1]
|
||||
screenshot = sct.grab(monitor)
|
||||
img = np.array(screenshot)
|
||||
img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
|
||||
return img
|
||||
except ImportError:
|
||||
return _capture_screen_pil()
|
||||
|
||||
|
||||
def _capture_screen_pil() -> np.ndarray:
|
||||
"""
|
||||
Capture d'écran en utilisant PIL/Pillow (fallback)
|
||||
"""
|
||||
try:
|
||||
from PIL import ImageGrab
|
||||
|
||||
screenshot = ImageGrab.grab()
|
||||
img = np.array(screenshot)
|
||||
|
||||
# Convertir RGB vers BGR (format OpenCV)
|
||||
img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
|
||||
|
||||
return img
|
||||
except ImportError:
|
||||
raise RuntimeError("Aucune bibliothèque de capture d'écran disponible. "
|
||||
"Installez 'mss' ou 'Pillow'.")
|
||||
|
||||
|
||||
def get_active_window() -> str:
|
||||
"""
|
||||
Obtient le titre de la fenêtre active
|
||||
|
||||
Returns:
|
||||
Titre de la fenêtre active, ou chaîne vide si impossible à déterminer
|
||||
"""
|
||||
try:
|
||||
system = platform.system()
|
||||
|
||||
if system == "Linux":
|
||||
return _get_active_window_linux()
|
||||
elif system == "Windows":
|
||||
return _get_active_window_windows()
|
||||
elif system == "Darwin": # macOS
|
||||
return _get_active_window_macos()
|
||||
else:
|
||||
return ""
|
||||
|
||||
except Exception as e:
|
||||
print(f"Erreur lors de la récupération de la fenêtre active: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def _get_active_window_linux() -> str:
|
||||
"""
|
||||
Obtient la fenêtre active sur Linux avec plusieurs méthodes de fallback
|
||||
"""
|
||||
# Méthode 1: xdotool (le plus fiable)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["xdotool", "getactivewindow", "getwindowname"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1,
|
||||
check=False
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# Méthode 2: xprop avec _NET_ACTIVE_WINDOW
|
||||
try:
|
||||
# Obtenir l'ID de la fenêtre active
|
||||
result = subprocess.run(
|
||||
["xprop", "-root", "_NET_ACTIVE_WINDOW"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1,
|
||||
check=False
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# Extraire l'ID de fenêtre (format: "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x...")
|
||||
window_id = result.stdout.strip().split()[-1]
|
||||
|
||||
# Obtenir le nom de la fenêtre
|
||||
result2 = subprocess.run(
|
||||
["xprop", "-id", window_id, "WM_NAME"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1,
|
||||
check=False
|
||||
)
|
||||
if result2.returncode == 0:
|
||||
# Format: WM_NAME(STRING) = "Titre de la fenêtre"
|
||||
name = result2.stdout.strip()
|
||||
if '=' in name:
|
||||
title = name.split('=', 1)[1].strip().strip('"')
|
||||
if title:
|
||||
return title
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# Méthode 3: wmctrl
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["wmctrl", "-l", "-p"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1,
|
||||
check=False
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split('\n')
|
||||
# Essayer de trouver la fenêtre active (première ligne comme approximation)
|
||||
if lines and lines[0]:
|
||||
parts = lines[0].split(None, 4)
|
||||
if len(parts) >= 5:
|
||||
return parts[4]
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# Méthode 4: Essayer avec Python Xlib (si disponible)
|
||||
try:
|
||||
from Xlib import X, display
|
||||
from Xlib.error import XError
|
||||
|
||||
d = display.Display()
|
||||
root = d.screen().root
|
||||
|
||||
# Obtenir la fenêtre active
|
||||
window_id = root.get_full_property(
|
||||
d.intern_atom('_NET_ACTIVE_WINDOW'),
|
||||
X.AnyPropertyType
|
||||
)
|
||||
|
||||
if window_id and window_id.value:
|
||||
active_window = d.create_resource_object('window', window_id.value[0])
|
||||
window_name = active_window.get_wm_name()
|
||||
if window_name:
|
||||
return window_name
|
||||
except (ImportError, XError, Exception):
|
||||
pass
|
||||
|
||||
return "Unknown Window"
|
||||
|
||||
|
||||
def _get_active_window_windows() -> str:
|
||||
"""
|
||||
Obtient la fenêtre active sur Windows
|
||||
"""
|
||||
try:
|
||||
import win32gui
|
||||
|
||||
hwnd = win32gui.GetForegroundWindow()
|
||||
return win32gui.GetWindowText(hwnd)
|
||||
except ImportError:
|
||||
# Fallback sans pywin32
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
hwnd = ctypes.windll.user32.GetForegroundWindow()
|
||||
length = ctypes.windll.user32.GetWindowTextLengthW(hwnd)
|
||||
buff = ctypes.create_unicode_buffer(length + 1)
|
||||
ctypes.windll.user32.GetWindowTextW(hwnd, buff, length + 1)
|
||||
return buff.value
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _get_active_window_macos() -> str:
|
||||
"""
|
||||
Obtient la fenêtre active sur macOS
|
||||
"""
|
||||
try:
|
||||
script = '''
|
||||
tell application "System Events"
|
||||
set frontApp to name of first application process whose frontmost is true
|
||||
set frontWindow to name of front window of application process frontApp
|
||||
return frontApp & " - " & frontWindow
|
||||
end tell
|
||||
'''
|
||||
result = subprocess.run(
|
||||
["osascript", "-e", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def extract_roi(frame: np.ndarray, bbox: Tuple[int, int, int, int]) -> np.ndarray:
|
||||
"""
|
||||
Extrait une région d'intérêt (ROI) d'une image
|
||||
|
||||
Args:
|
||||
frame: Image source en format numpy array
|
||||
bbox: Bounding box (x, y, width, height) en pixels
|
||||
|
||||
Returns:
|
||||
Image de la région d'intérêt
|
||||
|
||||
Raises:
|
||||
ValueError: Si la bounding box est invalide
|
||||
"""
|
||||
x, y, w, h = bbox
|
||||
|
||||
# Valider les dimensions
|
||||
if w <= 0 or h <= 0:
|
||||
raise ValueError(f"Dimensions de bounding box invalides: width={w}, height={h}")
|
||||
|
||||
# Obtenir les dimensions de l'image
|
||||
img_height, img_width = frame.shape[:2]
|
||||
|
||||
# Limiter les coordonnées aux dimensions de l'image
|
||||
x = max(0, min(x, img_width - 1))
|
||||
y = max(0, min(y, img_height - 1))
|
||||
x2 = max(0, min(x + w, img_width))
|
||||
y2 = max(0, min(y + h, img_height))
|
||||
|
||||
# Extraire la ROI
|
||||
roi = frame[y:y2, x:x2]
|
||||
|
||||
# Vérifier que la ROI n'est pas vide
|
||||
if roi.size == 0:
|
||||
raise ValueError(f"ROI vide avec bbox={bbox}, image_size=({img_width}, {img_height})")
|
||||
|
||||
return roi
|
||||
|
||||
|
||||
def draw_bbox(frame: np.ndarray, bbox: Tuple[int, int, int, int],
|
||||
label: str = "", color: Tuple[int, int, int] = (0, 255, 0),
|
||||
thickness: int = 2) -> np.ndarray:
|
||||
"""
|
||||
Dessine une bounding box sur une image avec un label optionnel
|
||||
|
||||
Args:
|
||||
frame: Image sur laquelle dessiner
|
||||
bbox: Bounding box (x, y, width, height) en pixels
|
||||
label: Label à afficher au-dessus de la box (optionnel)
|
||||
color: Couleur BGR de la box (par défaut: vert)
|
||||
thickness: Épaisseur de la ligne en pixels
|
||||
|
||||
Returns:
|
||||
Image avec la bounding box dessinée (copie de l'original)
|
||||
"""
|
||||
# Créer une copie pour ne pas modifier l'original
|
||||
img = frame.copy()
|
||||
|
||||
x, y, w, h = bbox
|
||||
|
||||
# Dessiner le rectangle
|
||||
cv2.rectangle(img, (x, y), (x + w, y + h), color, thickness)
|
||||
|
||||
# Dessiner le label si fourni
|
||||
if label:
|
||||
# Calculer la taille du texte
|
||||
font = cv2.FONT_HERSHEY_SIMPLEX
|
||||
font_scale = 0.6
|
||||
font_thickness = 2
|
||||
(text_width, text_height), baseline = cv2.getTextSize(
|
||||
label, font, font_scale, font_thickness
|
||||
)
|
||||
|
||||
# Dessiner un rectangle de fond pour le texte
|
||||
label_y = y - 10 if y - 10 > text_height else y + h + text_height + 10
|
||||
cv2.rectangle(
|
||||
img,
|
||||
(x, label_y - text_height - baseline),
|
||||
(x + text_width, label_y + baseline),
|
||||
color,
|
||||
-1 # Remplir
|
||||
)
|
||||
|
||||
# Dessiner le texte
|
||||
cv2.putText(
|
||||
img,
|
||||
label,
|
||||
(x, label_y),
|
||||
font,
|
||||
font_scale,
|
||||
(255, 255, 255), # Blanc
|
||||
font_thickness
|
||||
)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def resize_image(image: np.ndarray, max_width: int = 1920,
|
||||
max_height: int = 1080) -> np.ndarray:
|
||||
"""
|
||||
Redimensionne une image en conservant le ratio d'aspect
|
||||
|
||||
Args:
|
||||
image: Image à redimensionner
|
||||
max_width: Largeur maximale
|
||||
max_height: Hauteur maximale
|
||||
|
||||
Returns:
|
||||
Image redimensionnée
|
||||
"""
|
||||
height, width = image.shape[:2]
|
||||
|
||||
# Calculer le ratio de redimensionnement
|
||||
ratio = min(max_width / width, max_height / height)
|
||||
|
||||
# Si l'image est déjà plus petite, ne pas la redimensionner
|
||||
if ratio >= 1.0:
|
||||
return image
|
||||
|
||||
# Calculer les nouvelles dimensions
|
||||
new_width = int(width * ratio)
|
||||
new_height = int(height * ratio)
|
||||
|
||||
# Redimensionner
|
||||
resized = cv2.resize(image, (new_width, new_height), interpolation=cv2.INTER_AREA)
|
||||
|
||||
return resized
|
||||
|
||||
|
||||
def save_image(image: np.ndarray, filepath: str) -> bool:
|
||||
"""
|
||||
Sauvegarde une image sur le disque
|
||||
|
||||
Args:
|
||||
image: Image à sauvegarder
|
||||
filepath: Chemin du fichier de destination
|
||||
|
||||
Returns:
|
||||
True si la sauvegarde a réussi, False sinon
|
||||
"""
|
||||
try:
|
||||
cv2.imwrite(filepath, image)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Erreur lors de la sauvegarde de l'image: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def load_image(filepath: str) -> Optional[np.ndarray]:
|
||||
"""
|
||||
Charge une image depuis le disque
|
||||
|
||||
Args:
|
||||
filepath: Chemin du fichier image
|
||||
|
||||
Returns:
|
||||
Image en format numpy array, ou None si le chargement échoue
|
||||
"""
|
||||
try:
|
||||
image = cv2.imread(filepath)
|
||||
if image is None:
|
||||
print(f"Impossible de charger l'image: {filepath}")
|
||||
return image
|
||||
except Exception as e:
|
||||
print(f"Erreur lors du chargement de l'image: {e}")
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Tests basiques des utilitaires d'image
|
||||
print("Test des utilitaires d'image RPA Vision V2")
|
||||
print("=" * 50)
|
||||
|
||||
# Test 1: Capture d'écran
|
||||
print("\n1. Test capture_screen():")
|
||||
try:
|
||||
screen = capture_screen()
|
||||
print(f" ✓ Capture réussie: {screen.shape} (H x W x C)")
|
||||
print(f" Type: {screen.dtype}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Échec: {e}")
|
||||
|
||||
# Test 2: Fenêtre active
|
||||
print("\n2. Test get_active_window():")
|
||||
window_title = get_active_window()
|
||||
if window_title:
|
||||
print(f" ✓ Fenêtre active: '{window_title}'")
|
||||
else:
|
||||
print(f" ⚠ Impossible de déterminer la fenêtre active")
|
||||
|
||||
# Test 3: Extraction ROI
|
||||
print("\n3. Test extract_roi():")
|
||||
try:
|
||||
# Créer une image de test
|
||||
test_img = np.zeros((480, 640, 3), dtype=np.uint8)
|
||||
test_img[100:200, 150:300] = [0, 255, 0] # Rectangle vert
|
||||
|
||||
# Extraire une ROI
|
||||
roi = extract_roi(test_img, (150, 100, 150, 100))
|
||||
print(f" ✓ ROI extraite: {roi.shape}")
|
||||
|
||||
# Test avec bbox invalide (devrait être limité)
|
||||
roi2 = extract_roi(test_img, (600, 400, 100, 100))
|
||||
print(f" ✓ ROI avec bbox hors limites: {roi2.shape}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Échec: {e}")
|
||||
|
||||
# Test 4: Dessin de bounding box
|
||||
print("\n4. Test draw_bbox():")
|
||||
try:
|
||||
test_img = np.zeros((480, 640, 3), dtype=np.uint8)
|
||||
|
||||
# Dessiner plusieurs bounding boxes
|
||||
img_with_bbox = draw_bbox(test_img, (100, 100, 200, 150), "Bouton 1", (0, 255, 0))
|
||||
img_with_bbox = draw_bbox(img_with_bbox, (350, 200, 150, 100), "Bouton 2", (255, 0, 0))
|
||||
|
||||
print(f" ✓ Bounding boxes dessinées: {img_with_bbox.shape}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Échec: {e}")
|
||||
|
||||
# Test 5: Redimensionnement
|
||||
print("\n5. Test resize_image():")
|
||||
try:
|
||||
large_img = np.zeros((2160, 3840, 3), dtype=np.uint8)
|
||||
resized = resize_image(large_img, max_width=1920, max_height=1080)
|
||||
print(f" ✓ Image redimensionnée: {large_img.shape} -> {resized.shape}")
|
||||
except Exception as e:
|
||||
print(f" ✗ Échec: {e}")
|
||||
|
||||
print("\n✓ Tests terminés!")
|
||||
608
geniusia2/core/utils/input_utils.py
Normal file
608
geniusia2/core/utils/input_utils.py
Normal file
@@ -0,0 +1,608 @@
|
||||
"""
|
||||
Utilitaires d'entrée pour exécuter des actions UI (souris, clavier, etc.).
|
||||
Support du clavier AZERTY et gestion du rollback d'actions.
|
||||
"""
|
||||
|
||||
import time
|
||||
import pyautogui
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from enum import Enum
|
||||
|
||||
from ..logger import Logger
|
||||
|
||||
|
||||
class ActionType(Enum):
|
||||
"""Types d'actions UI supportées."""
|
||||
CLICK = "click"
|
||||
TYPE = "type"
|
||||
SCROLL = "scroll"
|
||||
WAIT = "wait"
|
||||
MOVE = "move"
|
||||
DRAG = "drag"
|
||||
|
||||
|
||||
class InputUtils:
|
||||
"""
|
||||
Gestionnaire d'entrées utilisateur pour exécuter des actions UI.
|
||||
Support du clavier AZERTY et rollback d'actions.
|
||||
"""
|
||||
|
||||
def __init__(self, logger: Logger, config: Dict[str, Any]):
|
||||
"""
|
||||
Initialise les utilitaires d'entrée.
|
||||
|
||||
Args:
|
||||
logger: Logger pour journalisation
|
||||
config: Configuration globale
|
||||
"""
|
||||
self.logger = logger
|
||||
self.config = config
|
||||
|
||||
# Configuration PyAutoGUI
|
||||
pyautogui.FAILSAFE = True # Déplacer souris dans coin = arrêt
|
||||
pyautogui.PAUSE = config.get("input", {}).get("pause_between_actions", 0.1)
|
||||
|
||||
# Historique des actions pour rollback
|
||||
self.action_history = []
|
||||
|
||||
# Mapping AZERTY pour caractères spéciaux
|
||||
self.azerty_mapping = {
|
||||
'0': 'à',
|
||||
'1': '&',
|
||||
'2': 'é',
|
||||
'3': '"',
|
||||
'4': "'",
|
||||
'5': '(',
|
||||
'6': '-',
|
||||
'7': 'è',
|
||||
'8': '_',
|
||||
'9': 'ç',
|
||||
'.': ':',
|
||||
'/': '!',
|
||||
',': ';',
|
||||
';': ',',
|
||||
':': '.',
|
||||
'!': '/',
|
||||
'?': 'M', # Shift + ,
|
||||
}
|
||||
|
||||
self.logger.log_action({
|
||||
"action": "input_utils_initialized",
|
||||
"failsafe": True,
|
||||
"pause": pyautogui.PAUSE
|
||||
})
|
||||
|
||||
def click(
|
||||
self,
|
||||
x: int,
|
||||
y: int,
|
||||
button: str = "left",
|
||||
clicks: int = 1,
|
||||
interval: float = 0.0
|
||||
) -> bool:
|
||||
"""
|
||||
Effectue un clic souris à la position spécifiée.
|
||||
|
||||
Args:
|
||||
x: Coordonnée X
|
||||
y: Coordonnée Y
|
||||
button: Bouton souris ("left", "right", "middle")
|
||||
clicks: Nombre de clics
|
||||
interval: Intervalle entre clics multiples
|
||||
|
||||
Returns:
|
||||
True si succès, False sinon
|
||||
"""
|
||||
try:
|
||||
# Enregistrer position actuelle pour rollback
|
||||
current_pos = pyautogui.position()
|
||||
|
||||
# Effectuer le clic
|
||||
pyautogui.click(x, y, clicks=clicks, interval=interval, button=button)
|
||||
|
||||
# Enregistrer dans l'historique
|
||||
action_record = {
|
||||
"type": ActionType.CLICK.value,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"button": button,
|
||||
"clicks": clicks,
|
||||
"previous_position": current_pos,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
self.action_history.append(action_record)
|
||||
|
||||
self.logger.log_action({
|
||||
"action": "click_executed",
|
||||
**action_record
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log_action({
|
||||
"action": "click_failed",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"error": str(e)
|
||||
})
|
||||
return False
|
||||
|
||||
def type_text(
|
||||
self,
|
||||
text: str,
|
||||
interval: float = 0.0,
|
||||
use_azerty: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Saisit du texte au clavier.
|
||||
|
||||
Args:
|
||||
text: Texte à saisir
|
||||
interval: Intervalle entre chaque caractère
|
||||
use_azerty: Utiliser le mapping AZERTY
|
||||
|
||||
Returns:
|
||||
True si succès, False sinon
|
||||
"""
|
||||
try:
|
||||
# Convertir pour AZERTY si nécessaire
|
||||
if use_azerty:
|
||||
converted_text = self._convert_to_azerty(text)
|
||||
else:
|
||||
converted_text = text
|
||||
|
||||
# Saisir le texte
|
||||
pyautogui.write(converted_text, interval=interval)
|
||||
|
||||
# Enregistrer dans l'historique
|
||||
action_record = {
|
||||
"type": ActionType.TYPE.value,
|
||||
"text": text,
|
||||
"converted_text": converted_text,
|
||||
"length": len(text),
|
||||
"timestamp": time.time()
|
||||
}
|
||||
self.action_history.append(action_record)
|
||||
|
||||
self.logger.log_action({
|
||||
"action": "text_typed",
|
||||
**action_record
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log_action({
|
||||
"action": "type_text_failed",
|
||||
"text": text[:50], # Limiter pour logs
|
||||
"error": str(e)
|
||||
})
|
||||
return False
|
||||
|
||||
def scroll(
|
||||
self,
|
||||
direction: str,
|
||||
amount: int = 3,
|
||||
x: Optional[int] = None,
|
||||
y: Optional[int] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Effectue un défilement.
|
||||
|
||||
Args:
|
||||
direction: Direction ("up", "down", "left", "right")
|
||||
amount: Quantité de défilement (nombre de "clics" de molette)
|
||||
x: Position X optionnelle
|
||||
y: Position Y optionnelle
|
||||
|
||||
Returns:
|
||||
True si succès, False sinon
|
||||
"""
|
||||
try:
|
||||
# Calculer le montant de défilement
|
||||
if direction in ["up", "right"]:
|
||||
scroll_amount = amount
|
||||
elif direction in ["down", "left"]:
|
||||
scroll_amount = -amount
|
||||
else:
|
||||
raise ValueError(f"Direction invalide: {direction}")
|
||||
|
||||
# Déplacer la souris si position spécifiée
|
||||
if x is not None and y is not None:
|
||||
pyautogui.moveTo(x, y)
|
||||
|
||||
# Effectuer le défilement
|
||||
if direction in ["up", "down"]:
|
||||
pyautogui.scroll(scroll_amount)
|
||||
else:
|
||||
pyautogui.hscroll(scroll_amount)
|
||||
|
||||
# Enregistrer dans l'historique
|
||||
action_record = {
|
||||
"type": ActionType.SCROLL.value,
|
||||
"direction": direction,
|
||||
"amount": amount,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
self.action_history.append(action_record)
|
||||
|
||||
self.logger.log_action({
|
||||
"action": "scroll_executed",
|
||||
**action_record
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log_action({
|
||||
"action": "scroll_failed",
|
||||
"direction": direction,
|
||||
"amount": amount,
|
||||
"error": str(e)
|
||||
})
|
||||
return False
|
||||
|
||||
def wait(self, duration: float) -> bool:
|
||||
"""
|
||||
Attend pendant une durée spécifiée.
|
||||
|
||||
Args:
|
||||
duration: Durée en secondes
|
||||
|
||||
Returns:
|
||||
True
|
||||
"""
|
||||
try:
|
||||
time.sleep(duration)
|
||||
|
||||
action_record = {
|
||||
"type": ActionType.WAIT.value,
|
||||
"duration": duration,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
self.action_history.append(action_record)
|
||||
|
||||
self.logger.log_action({
|
||||
"action": "wait_executed",
|
||||
**action_record
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log_action({
|
||||
"action": "wait_failed",
|
||||
"duration": duration,
|
||||
"error": str(e)
|
||||
})
|
||||
return False
|
||||
|
||||
def move(self, x: int, y: int, duration: float = 0.2) -> bool:
|
||||
"""
|
||||
Déplace la souris vers une position.
|
||||
|
||||
Args:
|
||||
x: Coordonnée X
|
||||
y: Coordonnée Y
|
||||
duration: Durée du mouvement en secondes
|
||||
|
||||
Returns:
|
||||
True si succès, False sinon
|
||||
"""
|
||||
try:
|
||||
current_pos = pyautogui.position()
|
||||
|
||||
pyautogui.moveTo(x, y, duration=duration)
|
||||
|
||||
action_record = {
|
||||
"type": ActionType.MOVE.value,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"previous_position": current_pos,
|
||||
"duration": duration,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
self.action_history.append(action_record)
|
||||
|
||||
self.logger.log_action({
|
||||
"action": "move_executed",
|
||||
**action_record
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log_action({
|
||||
"action": "move_failed",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"error": str(e)
|
||||
})
|
||||
return False
|
||||
|
||||
def drag(
|
||||
self,
|
||||
start_x: int,
|
||||
start_y: int,
|
||||
end_x: int,
|
||||
end_y: int,
|
||||
duration: float = 0.5,
|
||||
button: str = "left"
|
||||
) -> bool:
|
||||
"""
|
||||
Effectue un glisser-déposer.
|
||||
|
||||
Args:
|
||||
start_x: X de départ
|
||||
start_y: Y de départ
|
||||
end_x: X d'arrivée
|
||||
end_y: Y d'arrivée
|
||||
duration: Durée du glissement
|
||||
button: Bouton souris
|
||||
|
||||
Returns:
|
||||
True si succès, False sinon
|
||||
"""
|
||||
try:
|
||||
current_pos = pyautogui.position()
|
||||
|
||||
pyautogui.moveTo(start_x, start_y)
|
||||
pyautogui.drag(end_x - start_x, end_y - start_y, duration=duration, button=button)
|
||||
|
||||
action_record = {
|
||||
"type": ActionType.DRAG.value,
|
||||
"start_x": start_x,
|
||||
"start_y": start_y,
|
||||
"end_x": end_x,
|
||||
"end_y": end_y,
|
||||
"previous_position": current_pos,
|
||||
"duration": duration,
|
||||
"button": button,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
self.action_history.append(action_record)
|
||||
|
||||
self.logger.log_action({
|
||||
"action": "drag_executed",
|
||||
**action_record
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.log_action({
|
||||
"action": "drag_failed",
|
||||
"start": (start_x, start_y),
|
||||
"end": (end_x, end_y),
|
||||
"error": str(e)
|
||||
})
|
||||
return False
|
||||
|
||||
def execute_inverse_action(self, action: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Exécute l'action inverse pour rollback.
|
||||
|
||||
Args:
|
||||
action: Action à inverser
|
||||
|
||||
Returns:
|
||||
True si succès, False sinon
|
||||
"""
|
||||
inverse = self.get_inverse_action(action)
|
||||
if not inverse:
|
||||
return False
|
||||
|
||||
action_type = inverse.get("type")
|
||||
|
||||
if action_type == ActionType.MOVE.value:
|
||||
return self.move(inverse["x"], inverse["y"], inverse.get("duration", 0.2))
|
||||
elif action_type == ActionType.SCROLL.value:
|
||||
return self.scroll(
|
||||
inverse["direction"],
|
||||
inverse["amount"],
|
||||
inverse.get("x"),
|
||||
inverse.get("y")
|
||||
)
|
||||
elif action_type == ActionType.DRAG.value:
|
||||
return self.drag(
|
||||
inverse["start_x"],
|
||||
inverse["start_y"],
|
||||
inverse["end_x"],
|
||||
inverse["end_y"],
|
||||
inverse.get("duration", 0.5),
|
||||
inverse.get("button", "left")
|
||||
)
|
||||
elif action_type == "press_key":
|
||||
# Exécuter les suppressions
|
||||
for _ in range(inverse.get("presses", 0)):
|
||||
pyautogui.press("backspace")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_inverse_action(self, action: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Génère l'action inverse pour rollback.
|
||||
|
||||
Args:
|
||||
action: Action à inverser
|
||||
|
||||
Returns:
|
||||
Action inverse ou None si non inversible
|
||||
"""
|
||||
action_type = action.get("type")
|
||||
|
||||
if action_type == ActionType.CLICK.value:
|
||||
# Un clic n'est pas vraiment inversible
|
||||
# On peut retourner à la position précédente
|
||||
prev_pos = action.get("previous_position")
|
||||
if prev_pos:
|
||||
return {
|
||||
"type": ActionType.MOVE.value,
|
||||
"x": prev_pos[0],
|
||||
"y": prev_pos[1],
|
||||
"duration": 0.2
|
||||
}
|
||||
|
||||
elif action_type == ActionType.TYPE.value:
|
||||
# Inverser la saisie = supprimer le texte
|
||||
text_length = action.get("length", 0)
|
||||
return {
|
||||
"type": "press_key",
|
||||
"key": "backspace",
|
||||
"presses": text_length
|
||||
}
|
||||
|
||||
elif action_type == ActionType.SCROLL.value:
|
||||
# Inverser le défilement
|
||||
direction = action.get("direction")
|
||||
amount = action.get("amount")
|
||||
inverse_direction = {
|
||||
"up": "down",
|
||||
"down": "up",
|
||||
"left": "right",
|
||||
"right": "left"
|
||||
}.get(direction)
|
||||
|
||||
return {
|
||||
"type": ActionType.SCROLL.value,
|
||||
"direction": inverse_direction,
|
||||
"amount": amount,
|
||||
"x": action.get("x"),
|
||||
"y": action.get("y")
|
||||
}
|
||||
|
||||
elif action_type == ActionType.MOVE.value:
|
||||
# Retourner à la position précédente
|
||||
prev_pos = action.get("previous_position")
|
||||
if prev_pos:
|
||||
return {
|
||||
"type": ActionType.MOVE.value,
|
||||
"x": prev_pos[0],
|
||||
"y": prev_pos[1],
|
||||
"duration": 0.2
|
||||
}
|
||||
|
||||
elif action_type == ActionType.DRAG.value:
|
||||
# Inverser le glissement
|
||||
return {
|
||||
"type": ActionType.DRAG.value,
|
||||
"start_x": action.get("end_x"),
|
||||
"start_y": action.get("end_y"),
|
||||
"end_x": action.get("start_x"),
|
||||
"end_y": action.get("start_y"),
|
||||
"duration": action.get("duration", 0.5),
|
||||
"button": action.get("button", "left")
|
||||
}
|
||||
|
||||
elif action_type == ActionType.WAIT.value:
|
||||
# L'attente n'a pas d'inverse
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _convert_to_azerty(self, text: str) -> str:
|
||||
"""
|
||||
Convertit du texte pour clavier AZERTY.
|
||||
|
||||
Args:
|
||||
text: Texte à convertir
|
||||
|
||||
Returns:
|
||||
Texte converti
|
||||
"""
|
||||
# Pour l'instant, retourner tel quel
|
||||
# PyAutoGUI gère déjà le layout clavier du système
|
||||
# Cette méthode peut être étendue si nécessaire
|
||||
return text
|
||||
|
||||
def get_action_history(self, limit: int = 50) -> list:
|
||||
"""
|
||||
Retourne l'historique des actions.
|
||||
|
||||
Args:
|
||||
limit: Nombre maximum d'actions à retourner
|
||||
|
||||
Returns:
|
||||
Liste des dernières actions
|
||||
"""
|
||||
return self.action_history[-limit:]
|
||||
|
||||
def clear_history(self):
|
||||
"""Efface l'historique des actions."""
|
||||
self.action_history = []
|
||||
self.logger.log_action({
|
||||
"action": "action_history_cleared"
|
||||
})
|
||||
|
||||
def execute_action(self, action_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Exécute une action depuis un dictionnaire de données.
|
||||
|
||||
Args:
|
||||
action_data: Données de l'action à exécuter
|
||||
{
|
||||
"action_type": str,
|
||||
"bbox": (x, y, w, h),
|
||||
"parameters": dict
|
||||
}
|
||||
|
||||
Returns:
|
||||
True si succès, False sinon
|
||||
"""
|
||||
action_type = action_data.get("action_type", "").lower()
|
||||
bbox = action_data.get("bbox", (0, 0, 0, 0))
|
||||
params = action_data.get("parameters", {})
|
||||
|
||||
# Calculer le centre de la bbox pour les actions de clic
|
||||
x, y, w, h = bbox
|
||||
center_x = x + w // 2
|
||||
center_y = y + h // 2
|
||||
|
||||
if action_type == "click":
|
||||
button = params.get("button", "left")
|
||||
clicks = params.get("clicks", 1)
|
||||
return self.click(center_x, center_y, button=button, clicks=clicks)
|
||||
|
||||
elif action_type == "double_click":
|
||||
return self.click(center_x, center_y, clicks=2)
|
||||
|
||||
elif action_type == "right_click":
|
||||
return self.click(center_x, center_y, button="right")
|
||||
|
||||
elif action_type == "type":
|
||||
text = params.get("text", "")
|
||||
interval = params.get("interval", 0.0)
|
||||
return self.type_text(text, interval=interval)
|
||||
|
||||
elif action_type == "scroll":
|
||||
direction = params.get("direction", "down")
|
||||
amount = params.get("amount", 3)
|
||||
return self.scroll(direction, amount, center_x, center_y)
|
||||
|
||||
elif action_type == "wait":
|
||||
duration = params.get("duration", 1.0)
|
||||
return self.wait(duration)
|
||||
|
||||
elif action_type == "move":
|
||||
duration = params.get("duration", 0.2)
|
||||
return self.move(center_x, center_y, duration=duration)
|
||||
|
||||
elif action_type == "drag":
|
||||
end_bbox = params.get("end_bbox", bbox)
|
||||
end_x, end_y, end_w, end_h = end_bbox
|
||||
end_center_x = end_x + end_w // 2
|
||||
end_center_y = end_y + end_h // 2
|
||||
duration = params.get("duration", 0.5)
|
||||
button = params.get("button", "left")
|
||||
return self.drag(center_x, center_y, end_center_x, end_center_y, duration, button)
|
||||
|
||||
else:
|
||||
self.logger.log_action({
|
||||
"action": "unknown_action_type",
|
||||
"action_type": action_type
|
||||
})
|
||||
return False
|
||||
798
geniusia2/core/utils/vision_utils.py
Normal file
798
geniusia2/core/utils/vision_utils.py
Normal file
@@ -0,0 +1,798 @@
|
||||
"""
|
||||
Utilitaires de vision pour détection d'éléments UI
|
||||
Fournit des interfaces vers les modèles de vision (OWL-v2, Grounding DINO, YOLO-World)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
from ..models import Detection
|
||||
from ..config import get_config, get_model_config
|
||||
from .image_utils import extract_roi
|
||||
|
||||
# Configuration du logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VisionUtils:
|
||||
"""
|
||||
Classe utilitaire pour la détection d'éléments UI avec plusieurs modèles de vision
|
||||
Supporte OWL-v2, Grounding DINO et YOLO-World avec fallback automatique
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
Initialise VisionUtils avec les modèles de vision
|
||||
|
||||
Args:
|
||||
config: Configuration optionnelle (utilise CONFIG global si None)
|
||||
"""
|
||||
self.config = config or get_config()
|
||||
self.model_config = get_model_config()
|
||||
|
||||
# Modèle principal configuré
|
||||
self.primary_model = self.model_config.get("vision", "owl-v2")
|
||||
|
||||
# Ordre de fallback des modèles
|
||||
self.fallback_order = ["owl-v2", "dino", "yolo"]
|
||||
|
||||
# Modèles chargés (lazy loading)
|
||||
self._models = {}
|
||||
self._models_loaded = {
|
||||
"owl-v2": False,
|
||||
"dino": False,
|
||||
"yolo": False,
|
||||
}
|
||||
|
||||
logger.info(f"VisionUtils initialisé avec modèle principal: {self.primary_model}")
|
||||
|
||||
def _load_owlv2(self) -> Any:
|
||||
"""
|
||||
Charge le modèle OWL-v2 (OWLv2 pour détection open-vocabulary)
|
||||
|
||||
Returns:
|
||||
Modèle OWL-v2 chargé
|
||||
"""
|
||||
try:
|
||||
logger.info("Chargement du modèle OWL-v2...")
|
||||
|
||||
# Import dynamique pour éviter les dépendances si non utilisé
|
||||
from transformers import Owlv2Processor, Owlv2ForObjectDetection
|
||||
import torch
|
||||
|
||||
model_path = self.model_config["paths"].get("owl_v2")
|
||||
|
||||
# Charger le modèle pré-entraîné
|
||||
processor = Owlv2Processor.from_pretrained(
|
||||
"google/owlv2-base-patch16-ensemble",
|
||||
cache_dir=model_path
|
||||
)
|
||||
model = Owlv2ForObjectDetection.from_pretrained(
|
||||
"google/owlv2-base-patch16-ensemble",
|
||||
cache_dir=model_path
|
||||
)
|
||||
|
||||
# Déplacer vers GPU si disponible
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
model = model.to(device)
|
||||
model.eval()
|
||||
|
||||
self._models["owl-v2"] = {
|
||||
"processor": processor,
|
||||
"model": model,
|
||||
"device": device
|
||||
}
|
||||
self._models_loaded["owl-v2"] = True
|
||||
|
||||
logger.info(f"OWL-v2 chargé avec succès sur {device}")
|
||||
return self._models["owl-v2"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors du chargement d'OWL-v2: {e}")
|
||||
self._models_loaded["owl-v2"] = False
|
||||
raise
|
||||
|
||||
def _load_dino(self) -> Any:
|
||||
"""
|
||||
Charge le modèle Grounding DINO
|
||||
|
||||
Returns:
|
||||
Modèle Grounding DINO chargé
|
||||
"""
|
||||
try:
|
||||
logger.info("Chargement du modèle Grounding DINO...")
|
||||
|
||||
from transformers import AutoProcessor, AutoModelForZeroShotObjectDetection
|
||||
import torch
|
||||
|
||||
# Charger le modèle Grounding DINO depuis HuggingFace
|
||||
model_id = "IDEA-Research/grounding-dino-tiny"
|
||||
|
||||
processor = AutoProcessor.from_pretrained(model_id)
|
||||
model = AutoModelForZeroShotObjectDetection.from_pretrained(model_id)
|
||||
|
||||
# Déplacer vers GPU si disponible
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
model = model.to(device)
|
||||
model.eval()
|
||||
|
||||
self._models["dino"] = {
|
||||
"processor": processor,
|
||||
"model": model,
|
||||
"device": device
|
||||
}
|
||||
self._models_loaded["dino"] = True
|
||||
|
||||
logger.info(f"Grounding DINO chargé avec succès sur {device}")
|
||||
return self._models["dino"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors du chargement de Grounding DINO: {e}")
|
||||
self._models_loaded["dino"] = False
|
||||
self._models["dino"] = {"model": None, "loaded": False}
|
||||
return self._models["dino"]
|
||||
|
||||
def _load_yolo(self) -> Any:
|
||||
"""
|
||||
Charge le modèle YOLO-World
|
||||
|
||||
Returns:
|
||||
Modèle YOLO-World chargé
|
||||
"""
|
||||
try:
|
||||
logger.info("Chargement du modèle YOLO-World...")
|
||||
|
||||
from ultralytics import YOLOWorld
|
||||
|
||||
# Charger YOLO-World (modèle pré-entraîné)
|
||||
model = YOLOWorld("yolov8s-worldv2.pt")
|
||||
|
||||
self._models["yolo"] = {
|
||||
"model": model
|
||||
}
|
||||
self._models_loaded["yolo"] = True
|
||||
|
||||
logger.info("YOLO-World chargé avec succès")
|
||||
return self._models["yolo"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors du chargement de YOLO-World: {e}")
|
||||
self._models_loaded["yolo"] = False
|
||||
self._models["yolo"] = {"model": None, "loaded": False}
|
||||
return self._models["yolo"]
|
||||
|
||||
def _ensure_model_loaded(self, model_name: str) -> bool:
|
||||
"""
|
||||
S'assure qu'un modèle est chargé
|
||||
|
||||
Args:
|
||||
model_name: Nom du modèle ("owl-v2", "dino", "yolo")
|
||||
|
||||
Returns:
|
||||
True si le modèle est chargé avec succès
|
||||
"""
|
||||
if self._models_loaded.get(model_name, False):
|
||||
return True
|
||||
|
||||
try:
|
||||
if model_name == "owl-v2":
|
||||
self._load_owlv2()
|
||||
elif model_name == "dino":
|
||||
self._load_dino()
|
||||
elif model_name == "yolo":
|
||||
self._load_yolo()
|
||||
else:
|
||||
logger.error(f"Modèle inconnu: {model_name}")
|
||||
return False
|
||||
|
||||
return self._models_loaded.get(model_name, False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Impossible de charger le modèle {model_name}: {e}")
|
||||
return False
|
||||
|
||||
def detect_with_owlv2(self, prompt: str, frame: np.ndarray) -> List[Detection]:
|
||||
"""
|
||||
Détection d'éléments UI avec OWL-v2
|
||||
|
||||
Args:
|
||||
prompt: Description textuelle de l'élément à détecter
|
||||
frame: Image de l'écran (numpy array RGB)
|
||||
|
||||
Returns:
|
||||
Liste de détections trouvées
|
||||
"""
|
||||
try:
|
||||
# S'assurer que le modèle est chargé
|
||||
if not self._ensure_model_loaded("owl-v2"):
|
||||
logger.error("OWL-v2 n'est pas disponible")
|
||||
return []
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
model_data = self._models["owl-v2"]
|
||||
processor = model_data["processor"]
|
||||
model = model_data["model"]
|
||||
device = model_data["device"]
|
||||
|
||||
# Convertir frame numpy en PIL Image
|
||||
if frame.dtype != np.uint8:
|
||||
frame = (frame * 255).astype(np.uint8)
|
||||
image = Image.fromarray(frame)
|
||||
|
||||
# Préparer les prompts (OWL-v2 accepte plusieurs prompts)
|
||||
texts = [[prompt]]
|
||||
|
||||
# Traiter l'image et le texte
|
||||
inputs = processor(text=texts, images=image, return_tensors="pt")
|
||||
inputs = {k: v.to(device) for k, v in inputs.items()}
|
||||
|
||||
# Inférence
|
||||
with torch.no_grad():
|
||||
outputs = model(**inputs)
|
||||
|
||||
# Post-traitement des résultats
|
||||
target_sizes = torch.tensor([image.size[::-1]]).to(device)
|
||||
results = processor.post_process_object_detection(
|
||||
outputs=outputs,
|
||||
threshold=0.1, # Seuil bas pour capturer plus de détections
|
||||
target_sizes=target_sizes
|
||||
)[0]
|
||||
|
||||
# Convertir en objets Detection
|
||||
detections = []
|
||||
boxes = results["boxes"].cpu().numpy()
|
||||
scores = results["scores"].cpu().numpy()
|
||||
labels = results["labels"].cpu().numpy()
|
||||
|
||||
for box, score, label in zip(boxes, scores, labels):
|
||||
# Convertir bbox de [x1, y1, x2, y2] vers [x, y, w, h]
|
||||
x1, y1, x2, y2 = box
|
||||
x, y = int(x1), int(y1)
|
||||
w, h = int(x2 - x1), int(y2 - y1)
|
||||
|
||||
# Extraire ROI pour embedding
|
||||
roi = extract_roi(frame, (x, y, w, h))
|
||||
|
||||
# Créer embedding simple (sera remplacé par OpenCLIP plus tard)
|
||||
embedding = np.random.rand(512) # Placeholder
|
||||
|
||||
detection = Detection(
|
||||
label=prompt,
|
||||
confidence=float(score),
|
||||
bbox=(x, y, w, h),
|
||||
embedding=embedding,
|
||||
model_source="owl-v2",
|
||||
roi_image=roi,
|
||||
metadata={
|
||||
"label_id": int(label),
|
||||
"raw_box": box.tolist()
|
||||
}
|
||||
)
|
||||
detections.append(detection)
|
||||
|
||||
logger.info(f"OWL-v2: {len(detections)} détections pour '{prompt}'")
|
||||
return detections
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la détection OWL-v2: {e}")
|
||||
return []
|
||||
|
||||
def detect_with_dino(self, prompt: str, frame: np.ndarray) -> List[Detection]:
|
||||
"""
|
||||
Détection d'éléments UI avec Grounding DINO
|
||||
|
||||
Args:
|
||||
prompt: Description textuelle de l'élément à détecter
|
||||
frame: Image de l'écran (numpy array RGB)
|
||||
|
||||
Returns:
|
||||
Liste de détections trouvées
|
||||
"""
|
||||
try:
|
||||
# S'assurer que le modèle est chargé
|
||||
if not self._ensure_model_loaded("dino"):
|
||||
logger.warning("Grounding DINO n'est pas disponible")
|
||||
return []
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
model_data = self._models["dino"]
|
||||
if not model_data.get("model"):
|
||||
return []
|
||||
|
||||
processor = model_data["processor"]
|
||||
model = model_data["model"]
|
||||
device = model_data["device"]
|
||||
|
||||
# Convertir frame numpy en PIL Image
|
||||
if frame.dtype != np.uint8:
|
||||
frame = (frame * 255).astype(np.uint8)
|
||||
image = Image.fromarray(frame)
|
||||
|
||||
# Préparer les inputs
|
||||
inputs = processor(images=image, text=prompt, return_tensors="pt")
|
||||
inputs = {k: v.to(device) for k, v in inputs.items()}
|
||||
|
||||
# Inférence
|
||||
with torch.no_grad():
|
||||
outputs = model(**inputs)
|
||||
|
||||
# Post-traitement
|
||||
target_sizes = torch.tensor([image.size[::-1]]).to(device)
|
||||
results = processor.post_process_grounded_object_detection(
|
||||
outputs=outputs,
|
||||
input_ids=inputs["input_ids"],
|
||||
threshold=0.3,
|
||||
target_sizes=target_sizes
|
||||
)[0]
|
||||
|
||||
# Convertir en objets Detection
|
||||
detections = []
|
||||
boxes = results["boxes"].cpu().numpy()
|
||||
scores = results["scores"].cpu().numpy()
|
||||
labels = results["labels"]
|
||||
|
||||
for box, score, label in zip(boxes, scores, labels):
|
||||
x1, y1, x2, y2 = box
|
||||
x, y = int(x1), int(y1)
|
||||
w, h = int(x2 - x1), int(y2 - y1)
|
||||
|
||||
roi = extract_roi(frame, (x, y, w, h))
|
||||
embedding = np.random.rand(512) # Placeholder
|
||||
|
||||
detection = Detection(
|
||||
label=label,
|
||||
confidence=float(score),
|
||||
bbox=(x, y, w, h),
|
||||
embedding=embedding,
|
||||
model_source="dino",
|
||||
roi_image=roi,
|
||||
metadata={"raw_box": box.tolist()}
|
||||
)
|
||||
detections.append(detection)
|
||||
|
||||
logger.info(f"Grounding DINO: {len(detections)} détections pour '{prompt}'")
|
||||
return detections
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la détection Grounding DINO: {e}")
|
||||
return []
|
||||
|
||||
def detect_with_yolo(self, prompt: str, frame: np.ndarray) -> List[Detection]:
|
||||
"""
|
||||
Détection d'éléments UI avec YOLO-World
|
||||
|
||||
Args:
|
||||
prompt: Description textuelle de l'élément à détecter
|
||||
frame: Image de l'écran (numpy array RGB)
|
||||
|
||||
Returns:
|
||||
Liste de détections trouvées
|
||||
"""
|
||||
try:
|
||||
# S'assurer que le modèle est chargé
|
||||
if not self._ensure_model_loaded("yolo"):
|
||||
logger.warning("YOLO-World n'est pas disponible")
|
||||
return []
|
||||
|
||||
model_data = self._models["yolo"]
|
||||
if not model_data.get("model"):
|
||||
return []
|
||||
|
||||
model = model_data["model"]
|
||||
|
||||
# Définir les classes à détecter (YOLO-World accepte des prompts textuels)
|
||||
model.set_classes([prompt])
|
||||
|
||||
# Convertir BGR vers RGB si nécessaire
|
||||
if frame.dtype != np.uint8:
|
||||
frame = (frame * 255).astype(np.uint8)
|
||||
|
||||
# Inférence
|
||||
results = model.predict(frame, conf=0.1, verbose=False)
|
||||
|
||||
# Convertir en objets Detection
|
||||
detections = []
|
||||
for result in results:
|
||||
boxes = result.boxes
|
||||
for box in boxes:
|
||||
# Extraire les coordonnées
|
||||
x1, y1, x2, y2 = box.xyxy[0].cpu().numpy()
|
||||
x, y = int(x1), int(y1)
|
||||
w, h = int(x2 - x1), int(y2 - y1)
|
||||
|
||||
# Score de confiance
|
||||
confidence = float(box.conf[0])
|
||||
|
||||
# Classe détectée
|
||||
cls_id = int(box.cls[0])
|
||||
label = model.names[cls_id] if cls_id < len(model.names) else prompt
|
||||
|
||||
roi = extract_roi(frame, (x, y, w, h))
|
||||
embedding = np.random.rand(512) # Placeholder
|
||||
|
||||
detection = Detection(
|
||||
label=label,
|
||||
confidence=confidence,
|
||||
bbox=(x, y, w, h),
|
||||
embedding=embedding,
|
||||
model_source="yolo",
|
||||
roi_image=roi,
|
||||
metadata={"class_id": cls_id}
|
||||
)
|
||||
detections.append(detection)
|
||||
|
||||
logger.info(f"YOLO-World: {len(detections)} détections pour '{prompt}'")
|
||||
return detections
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la détection YOLO-World: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def detect(self, prompt: str, frame: np.ndarray,
|
||||
model: Optional[str] = None) -> List[Detection]:
|
||||
"""
|
||||
Détection d'éléments UI avec fallback automatique entre modèles
|
||||
|
||||
Args:
|
||||
prompt: Description textuelle de l'élément à détecter
|
||||
frame: Image de l'écran (numpy array RGB)
|
||||
model: Modèle spécifique à utiliser (None = utiliser le modèle principal)
|
||||
|
||||
Returns:
|
||||
Liste de détections trouvées
|
||||
"""
|
||||
# Déterminer l'ordre des modèles à essayer
|
||||
if model:
|
||||
models_to_try = [model] + [m for m in self.fallback_order if m != model]
|
||||
else:
|
||||
models_to_try = [self.primary_model] + [m for m in self.fallback_order if m != self.primary_model]
|
||||
|
||||
# Essayer chaque modèle jusqu'à obtenir des détections
|
||||
for model_name in models_to_try:
|
||||
try:
|
||||
logger.info(f"Tentative de détection avec {model_name}...")
|
||||
|
||||
if model_name == "owl-v2":
|
||||
detections = self.detect_with_owlv2(prompt, frame)
|
||||
elif model_name == "dino":
|
||||
detections = self.detect_with_dino(prompt, frame)
|
||||
elif model_name == "yolo":
|
||||
detections = self.detect_with_yolo(prompt, frame)
|
||||
else:
|
||||
logger.warning(f"Modèle inconnu: {model_name}")
|
||||
continue
|
||||
|
||||
# Si des détections sont trouvées, retourner
|
||||
if detections:
|
||||
logger.info(f"Détection réussie avec {model_name}: {len(detections)} éléments")
|
||||
return detections
|
||||
else:
|
||||
logger.warning(f"Aucune détection avec {model_name}, essai du modèle suivant...")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur avec {model_name}: {e}, essai du modèle suivant...")
|
||||
continue
|
||||
|
||||
# Aucun modèle n'a réussi
|
||||
logger.error(f"Aucun modèle n'a pu détecter '{prompt}'")
|
||||
return []
|
||||
|
||||
def select_best_detection(self, detections: List[Detection],
|
||||
context: Optional[Dict[str, Any]] = None) -> Optional[Detection]:
|
||||
"""
|
||||
Sélectionne la meilleure détection parmi une liste
|
||||
|
||||
Args:
|
||||
detections: Liste de détections à évaluer
|
||||
context: Contexte additionnel pour la sélection (position précédente, etc.)
|
||||
|
||||
Returns:
|
||||
La meilleure détection ou None si la liste est vide
|
||||
"""
|
||||
if not detections:
|
||||
return None
|
||||
|
||||
# Si une seule détection, la retourner
|
||||
if len(detections) == 1:
|
||||
return detections[0]
|
||||
|
||||
# Stratégie de sélection basée sur plusieurs critères
|
||||
best_detection = None
|
||||
best_score = -1
|
||||
|
||||
for detection in detections:
|
||||
score = detection.confidence
|
||||
|
||||
# Bonus pour les détections du modèle principal
|
||||
if detection.model_source == self.primary_model:
|
||||
score *= 1.1
|
||||
|
||||
# Si contexte fourni avec position précédente, favoriser les détections proches
|
||||
if context and "previous_bbox" in context:
|
||||
prev_x, prev_y, prev_w, prev_h = context["previous_bbox"]
|
||||
curr_x, curr_y, curr_w, curr_h = detection.bbox
|
||||
|
||||
# Calculer la distance entre les centres
|
||||
prev_center = (prev_x + prev_w / 2, prev_y + prev_h / 2)
|
||||
curr_center = (curr_x + curr_w / 2, curr_y + curr_h / 2)
|
||||
distance = np.sqrt(
|
||||
(prev_center[0] - curr_center[0]) ** 2 +
|
||||
(prev_center[1] - curr_center[1]) ** 2
|
||||
)
|
||||
|
||||
# Bonus inversement proportionnel à la distance (max 20% bonus)
|
||||
proximity_bonus = max(0, 1 - distance / 500) * 0.2
|
||||
score *= (1 + proximity_bonus)
|
||||
|
||||
# Favoriser les détections avec des bounding boxes de taille raisonnable
|
||||
x, y, w, h = detection.bbox
|
||||
area = w * h
|
||||
if 100 < area < 100000: # Taille raisonnable pour un élément UI
|
||||
score *= 1.05
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_detection = detection
|
||||
|
||||
logger.info(f"Meilleure détection sélectionnée: {best_detection.label} "
|
||||
f"(confiance: {best_detection.confidence:.2f}, "
|
||||
f"modèle: {best_detection.model_source})")
|
||||
|
||||
return best_detection
|
||||
|
||||
def filter_detections(self, detections: List[Detection],
|
||||
min_confidence: float = 0.3,
|
||||
max_detections: int = 10) -> List[Detection]:
|
||||
"""
|
||||
Filtre les détections selon des critères de qualité
|
||||
|
||||
Args:
|
||||
detections: Liste de détections à filtrer
|
||||
min_confidence: Confiance minimale requise
|
||||
max_detections: Nombre maximum de détections à retourner
|
||||
|
||||
Returns:
|
||||
Liste filtrée et triée de détections
|
||||
"""
|
||||
# Filtrer par confiance minimale
|
||||
filtered = [d for d in detections if d.confidence >= min_confidence]
|
||||
|
||||
# Trier par confiance décroissante
|
||||
filtered.sort(key=lambda d: d.confidence, reverse=True)
|
||||
|
||||
# Limiter le nombre de détections
|
||||
filtered = filtered[:max_detections]
|
||||
|
||||
logger.info(f"Filtrage: {len(detections)} -> {len(filtered)} détections "
|
||||
f"(seuil: {min_confidence})")
|
||||
|
||||
return filtered
|
||||
|
||||
def merge_overlapping_detections(self, detections: List[Detection],
|
||||
iou_threshold: float = 0.5) -> List[Detection]:
|
||||
"""
|
||||
Fusionne les détections qui se chevauchent (même élément détecté plusieurs fois)
|
||||
|
||||
Args:
|
||||
detections: Liste de détections
|
||||
iou_threshold: Seuil d'IoU pour considérer deux détections comme identiques
|
||||
|
||||
Returns:
|
||||
Liste de détections fusionnées
|
||||
"""
|
||||
if len(detections) <= 1:
|
||||
return detections
|
||||
|
||||
def calculate_iou(box1: Tuple[int, int, int, int],
|
||||
box2: Tuple[int, int, int, int]) -> float:
|
||||
"""Calcule l'Intersection over Union entre deux bounding boxes"""
|
||||
x1, y1, w1, h1 = box1
|
||||
x2, y2, w2, h2 = box2
|
||||
|
||||
# Coordonnées de l'intersection
|
||||
xi1 = max(x1, x2)
|
||||
yi1 = max(y1, y2)
|
||||
xi2 = min(x1 + w1, x2 + w2)
|
||||
yi2 = min(y1 + h1, y2 + h2)
|
||||
|
||||
# Aire de l'intersection
|
||||
inter_area = max(0, xi2 - xi1) * max(0, yi2 - yi1)
|
||||
|
||||
# Aires des deux boxes
|
||||
box1_area = w1 * h1
|
||||
box2_area = w2 * h2
|
||||
|
||||
# Union
|
||||
union_area = box1_area + box2_area - inter_area
|
||||
|
||||
# IoU
|
||||
return inter_area / union_area if union_area > 0 else 0
|
||||
|
||||
# Trier par confiance décroissante
|
||||
sorted_detections = sorted(detections, key=lambda d: d.confidence, reverse=True)
|
||||
|
||||
merged = []
|
||||
used = set()
|
||||
|
||||
for i, det1 in enumerate(sorted_detections):
|
||||
if i in used:
|
||||
continue
|
||||
|
||||
# Trouver toutes les détections qui se chevauchent avec det1
|
||||
overlapping = [det1]
|
||||
for j, det2 in enumerate(sorted_detections[i+1:], start=i+1):
|
||||
if j in used:
|
||||
continue
|
||||
|
||||
iou = calculate_iou(det1.bbox, det2.bbox)
|
||||
if iou >= iou_threshold:
|
||||
overlapping.append(det2)
|
||||
used.add(j)
|
||||
|
||||
# Si plusieurs détections se chevauchent, garder celle avec la meilleure confiance
|
||||
# (det1 est déjà la meilleure car la liste est triée)
|
||||
merged.append(det1)
|
||||
used.add(i)
|
||||
|
||||
logger.info(f"Fusion: {len(detections)} -> {len(merged)} détections "
|
||||
f"(seuil IoU: {iou_threshold})")
|
||||
|
||||
return merged
|
||||
|
||||
def get_detection_statistics(self, detections: List[Detection]) -> Dict[str, Any]:
|
||||
"""
|
||||
Calcule des statistiques sur une liste de détections
|
||||
|
||||
Args:
|
||||
detections: Liste de détections
|
||||
|
||||
Returns:
|
||||
Dictionnaire de statistiques
|
||||
"""
|
||||
if not detections:
|
||||
return {
|
||||
"count": 0,
|
||||
"avg_confidence": 0.0,
|
||||
"max_confidence": 0.0,
|
||||
"min_confidence": 0.0,
|
||||
"models_used": []
|
||||
}
|
||||
|
||||
confidences = [d.confidence for d in detections]
|
||||
models = [d.model_source for d in detections]
|
||||
|
||||
stats = {
|
||||
"count": len(detections),
|
||||
"avg_confidence": float(np.mean(confidences)),
|
||||
"max_confidence": float(np.max(confidences)),
|
||||
"min_confidence": float(np.min(confidences)),
|
||||
"std_confidence": float(np.std(confidences)),
|
||||
"models_used": list(set(models)),
|
||||
"model_distribution": {model: models.count(model) for model in set(models)}
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
def unload_models(self):
|
||||
"""Décharge tous les modèles de la mémoire"""
|
||||
logger.info("Déchargement des modèles de vision...")
|
||||
self._models.clear()
|
||||
self._models_loaded = {k: False for k in self._models_loaded}
|
||||
|
||||
# Forcer le garbage collection
|
||||
import gc
|
||||
gc.collect()
|
||||
|
||||
# Si CUDA disponible, vider le cache
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
logger.info("Modèles déchargés")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""Tests basiques de VisionUtils"""
|
||||
import sys
|
||||
|
||||
print("Test de VisionUtils")
|
||||
print("=" * 50)
|
||||
|
||||
# Initialiser VisionUtils
|
||||
print("\n1. Initialisation de VisionUtils...")
|
||||
vision = VisionUtils()
|
||||
print(f" Modèle principal: {vision.primary_model}")
|
||||
print(f" Ordre de fallback: {vision.fallback_order}")
|
||||
|
||||
# Créer une image de test
|
||||
print("\n2. Création d'une image de test...")
|
||||
test_frame = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
|
||||
print(f" Taille de l'image: {test_frame.shape}")
|
||||
|
||||
# Test de détection (nécessite les modèles installés)
|
||||
print("\n3. Test de détection...")
|
||||
try:
|
||||
detections = vision.detect("button", test_frame)
|
||||
print(f" Détections trouvées: {len(detections)}")
|
||||
|
||||
if detections:
|
||||
print("\n4. Statistiques des détections:")
|
||||
stats = vision.get_detection_statistics(detections)
|
||||
for key, value in stats.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
print("\n5. Sélection de la meilleure détection:")
|
||||
best = vision.select_best_detection(detections)
|
||||
if best:
|
||||
print(f" Label: {best.label}")
|
||||
print(f" Confiance: {best.confidence:.2f}")
|
||||
print(f" BBox: {best.bbox}")
|
||||
print(f" Modèle: {best.model_source}")
|
||||
except Exception as e:
|
||||
print(f" Erreur lors de la détection: {e}")
|
||||
print(" (Normal si les modèles ne sont pas installés)")
|
||||
|
||||
# Test de filtrage
|
||||
print("\n6. Test de filtrage de détections...")
|
||||
mock_detections = [
|
||||
Detection(
|
||||
label="button1",
|
||||
confidence=0.95,
|
||||
bbox=(100, 100, 50, 30),
|
||||
embedding=np.random.rand(512),
|
||||
model_source="owl-v2"
|
||||
),
|
||||
Detection(
|
||||
label="button2",
|
||||
confidence=0.25,
|
||||
bbox=(200, 100, 50, 30),
|
||||
embedding=np.random.rand(512),
|
||||
model_source="owl-v2"
|
||||
),
|
||||
Detection(
|
||||
label="button3",
|
||||
confidence=0.75,
|
||||
bbox=(300, 100, 50, 30),
|
||||
embedding=np.random.rand(512),
|
||||
model_source="dino"
|
||||
),
|
||||
]
|
||||
|
||||
filtered = vision.filter_detections(mock_detections, min_confidence=0.5)
|
||||
print(f" Détections avant filtrage: {len(mock_detections)}")
|
||||
print(f" Détections après filtrage: {len(filtered)}")
|
||||
|
||||
# Test de fusion
|
||||
print("\n7. Test de fusion de détections chevauchantes...")
|
||||
overlapping_detections = [
|
||||
Detection(
|
||||
label="button",
|
||||
confidence=0.95,
|
||||
bbox=(100, 100, 50, 30),
|
||||
embedding=np.random.rand(512),
|
||||
model_source="owl-v2"
|
||||
),
|
||||
Detection(
|
||||
label="button",
|
||||
confidence=0.85,
|
||||
bbox=(105, 102, 48, 28), # Légèrement décalé
|
||||
embedding=np.random.rand(512),
|
||||
model_source="dino"
|
||||
),
|
||||
]
|
||||
|
||||
merged = vision.merge_overlapping_detections(overlapping_detections, iou_threshold=0.5)
|
||||
print(f" Détections avant fusion: {len(overlapping_detections)}")
|
||||
print(f" Détections après fusion: {len(merged)}")
|
||||
|
||||
print("\n✓ Tests basiques terminés!")
|
||||
Reference in New Issue
Block a user