## Nouvelles fonctionnalités ### 1. SeeClick Adapter (visual grounding fallback) - Nouvel adapter pour le modèle SeeClick (HuggingFace) - Intégré dans la chaîne de fallback: CLIP → Template → SeeClick → Static - Localise les éléments GUI à partir de descriptions textuelles ### 2. Self-Healing Interactif - Dialogue qui propose des alternatives quand l'ancre n'est pas trouvée - L'utilisateur peut choisir: candidat alternatif, coords statiques, ou sauter - Nouveaux endpoints: /healing/status, /healing/choose, /healing/candidates - État "waiting_for_choice" pour mettre l'exécution en pause ### 3. Dashboard Confiance (temps réel) - Affiche les scores de confiance pendant l'exécution - Montre: méthode utilisée, distance, taux de succès - Interface pliable en bas à droite - Visible uniquement en mode intelligent/debug ## Fichiers ajoutés - core/detection/seeclick_adapter.py - frontend_v4/src/components/SelfHealingDialog.tsx - frontend_v4/src/components/ConfidenceDashboard.tsx ## Fichiers modifiés - core/detection/__init__.py - backend/services/intelligent_executor.py - backend/api_v3/execute.py - frontend_v4/src/App.tsx - frontend_v4/src/services/api.ts - docs/VISION_RPA_INTELLIGENT.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
334 lines
11 KiB
Python
334 lines
11 KiB
Python
"""
|
||
SeeClick Adapter pour RPA Vision V3
|
||
|
||
Intègre le modèle SeeClick pour le visual grounding sur GUI.
|
||
SeeClick peut localiser un élément GUI à partir d'une description textuelle
|
||
et retourner des coordonnées (x, y) de clic.
|
||
|
||
Usage:
|
||
adapter = SeeClickAdapter()
|
||
x, y = adapter.ground(screenshot_pil, "bouton Valider")
|
||
# Retourne les coordonnées de clic normalisées ou en pixels
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
from typing import Optional, Tuple, Dict, Any
|
||
from dataclasses import dataclass
|
||
from PIL import Image
|
||
import re
|
||
|
||
# Configuration
|
||
SEECLICK_MODEL_ID = "cckevinn/SeeClick"
|
||
SEECLICK_LOCAL_PATH = "/home/dom/ai/models/seeclick" # Cache local si téléchargé
|
||
|
||
|
||
@dataclass
|
||
class GroundingResult:
|
||
"""Résultat de grounding SeeClick"""
|
||
found: bool
|
||
x: Optional[float] # Coordonnée X normalisée (0-1) ou pixels
|
||
y: Optional[float] # Coordonnée Y normalisée (0-1) ou pixels
|
||
x_pixel: Optional[int] # Coordonnée X en pixels
|
||
y_pixel: Optional[int] # Coordonnée Y en pixels
|
||
confidence: float
|
||
raw_output: str # Sortie brute du modèle
|
||
|
||
|
||
class SeeClickAdapter:
|
||
"""
|
||
Adapter pour utiliser SeeClick comme grounding fallback.
|
||
|
||
SeeClick est un modèle vision-langage basé sur Qwen-VL qui peut
|
||
localiser des éléments GUI à partir de descriptions textuelles.
|
||
|
||
Il retourne des coordonnées (x, y) normalisées entre 0 et 1.
|
||
"""
|
||
|
||
_instance = None
|
||
_initialized = False
|
||
|
||
def __new__(cls):
|
||
"""Singleton pour éviter de charger le modèle plusieurs fois"""
|
||
if cls._instance is None:
|
||
cls._instance = super().__new__(cls)
|
||
return cls._instance
|
||
|
||
def __init__(self):
|
||
"""Initialise SeeClick (lazy loading)"""
|
||
if SeeClickAdapter._initialized:
|
||
return
|
||
|
||
self.model = None
|
||
self.tokenizer = None
|
||
self.available = False
|
||
self._device = None
|
||
self._check_availability()
|
||
|
||
def _check_availability(self):
|
||
"""Vérifie si SeeClick peut être chargé"""
|
||
try:
|
||
# Vérifier que transformers est disponible
|
||
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||
|
||
# Vérifier si le modèle est en cache local ou accessible
|
||
if os.path.exists(SEECLICK_LOCAL_PATH):
|
||
print(f"✅ [SeeClick] Modèle local trouvé: {SEECLICK_LOCAL_PATH}")
|
||
self.available = True
|
||
else:
|
||
# On suppose qu'on peut télécharger depuis HuggingFace
|
||
print(f"ℹ️ [SeeClick] Modèle sera téléchargé depuis HuggingFace: {SEECLICK_MODEL_ID}")
|
||
self.available = True
|
||
|
||
print("✅ [SeeClick] Adapter disponible (chargement différé)")
|
||
|
||
except ImportError as e:
|
||
print(f"❌ [SeeClick] Dépendances manquantes: {e}")
|
||
self.available = False
|
||
except Exception as e:
|
||
print(f"❌ [SeeClick] Erreur vérification: {e}")
|
||
self.available = False
|
||
|
||
def _load_model(self):
|
||
"""Charge le modèle SeeClick (lazy loading)"""
|
||
if self.model is not None:
|
||
return True
|
||
|
||
if not self.available:
|
||
return False
|
||
|
||
try:
|
||
import torch
|
||
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||
|
||
# Déterminer le device
|
||
self._device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
||
print(f"🔄 [SeeClick] Chargement du modèle sur {self._device}...")
|
||
|
||
# Charger depuis le cache local ou HuggingFace
|
||
model_path = SEECLICK_LOCAL_PATH if os.path.exists(SEECLICK_LOCAL_PATH) else SEECLICK_MODEL_ID
|
||
|
||
self.tokenizer = AutoTokenizer.from_pretrained(
|
||
model_path,
|
||
trust_remote_code=True
|
||
)
|
||
|
||
self.model = AutoModelForCausalLM.from_pretrained(
|
||
model_path,
|
||
device_map=self._device,
|
||
trust_remote_code=True,
|
||
torch_dtype=torch.float16 if self._device == 'cuda' else torch.float32
|
||
)
|
||
|
||
SeeClickAdapter._initialized = True
|
||
print(f"✅ [SeeClick] Modèle chargé avec succès sur {self._device}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ [SeeClick] Erreur chargement modèle: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
self.available = False
|
||
return False
|
||
|
||
def ground(
|
||
self,
|
||
image: Image.Image,
|
||
description: str,
|
||
return_pixels: bool = True
|
||
) -> GroundingResult:
|
||
"""
|
||
Localise un élément GUI basé sur une description textuelle.
|
||
|
||
Args:
|
||
image: Image PIL du screenshot
|
||
description: Description de l'élément à trouver
|
||
Ex: "bouton Valider", "icône OnlyOffice", "champ de recherche"
|
||
return_pixels: Si True, retourne les coordonnées en pixels
|
||
|
||
Returns:
|
||
GroundingResult avec les coordonnées trouvées
|
||
"""
|
||
if not self._load_model():
|
||
return GroundingResult(
|
||
found=False,
|
||
x=None, y=None,
|
||
x_pixel=None, y_pixel=None,
|
||
confidence=0.0,
|
||
raw_output="Model not available"
|
||
)
|
||
|
||
try:
|
||
W, H = image.size
|
||
|
||
# Formatter le prompt pour SeeClick
|
||
# Format: "In this UI screenshot, what is the position of the element..."
|
||
prompt = f"In this UI screenshot, what is the position of the element corresponding to \"{description}\"?"
|
||
|
||
# Préparer l'input (dépend de l'implémentation Qwen-VL)
|
||
# SeeClick utilise le format chat de Qwen-VL
|
||
messages = [
|
||
{
|
||
"role": "user",
|
||
"content": [
|
||
{"type": "image", "image": image},
|
||
{"type": "text", "text": prompt}
|
||
]
|
||
}
|
||
]
|
||
|
||
# Tokeniser et générer
|
||
text = self.tokenizer.apply_chat_template(
|
||
messages,
|
||
tokenize=False,
|
||
add_generation_prompt=True
|
||
)
|
||
|
||
inputs = self.tokenizer(text, return_tensors="pt")
|
||
if self._device == 'cuda':
|
||
inputs = {k: v.cuda() for k, v in inputs.items()}
|
||
|
||
outputs = self.model.generate(
|
||
**inputs,
|
||
max_new_tokens=100,
|
||
do_sample=False
|
||
)
|
||
|
||
# Décoder la sortie
|
||
response = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
|
||
raw_output = response.split("assistant")[-1].strip() if "assistant" in response else response
|
||
|
||
print(f"🔍 [SeeClick] Sortie brute: {raw_output}")
|
||
|
||
# Parser les coordonnées (format: (0.xxx, 0.yyy) ou [0.xxx, 0.yyy])
|
||
coords = self._parse_coordinates(raw_output)
|
||
|
||
if coords:
|
||
x_norm, y_norm = coords
|
||
x_pixel = int(x_norm * W)
|
||
y_pixel = int(y_norm * H)
|
||
|
||
print(f"✅ [SeeClick] Trouvé: ({x_pixel}, {y_pixel}) = ({x_norm:.3f}, {y_norm:.3f}) normalisé")
|
||
|
||
return GroundingResult(
|
||
found=True,
|
||
x=x_pixel if return_pixels else x_norm,
|
||
y=y_pixel if return_pixels else y_norm,
|
||
x_pixel=x_pixel,
|
||
y_pixel=y_pixel,
|
||
confidence=0.8, # SeeClick ne donne pas de score explicite
|
||
raw_output=raw_output
|
||
)
|
||
else:
|
||
print(f"⚠️ [SeeClick] Pas de coordonnées trouvées dans: {raw_output}")
|
||
return GroundingResult(
|
||
found=False,
|
||
x=None, y=None,
|
||
x_pixel=None, y_pixel=None,
|
||
confidence=0.0,
|
||
raw_output=raw_output
|
||
)
|
||
|
||
except Exception as e:
|
||
print(f"❌ [SeeClick] Erreur grounding: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return GroundingResult(
|
||
found=False,
|
||
x=None, y=None,
|
||
x_pixel=None, y_pixel=None,
|
||
confidence=0.0,
|
||
raw_output=str(e)
|
||
)
|
||
|
||
def _parse_coordinates(self, text: str) -> Optional[Tuple[float, float]]:
|
||
"""
|
||
Parse les coordonnées depuis la sortie du modèle.
|
||
|
||
Formats supportés:
|
||
- (0.123, 0.456)
|
||
- [0.123, 0.456]
|
||
- click(0.123, 0.456)
|
||
- x: 0.123, y: 0.456
|
||
"""
|
||
# Pattern pour (x, y) ou [x, y]
|
||
pattern1 = r'[\(\[]?\s*(\d+\.?\d*)\s*[,;]\s*(\d+\.?\d*)\s*[\)\]]?'
|
||
|
||
# Pattern pour click(x, y)
|
||
pattern2 = r'click\s*[\(\[]?\s*(\d+\.?\d*)\s*[,;]\s*(\d+\.?\d*)'
|
||
|
||
# Pattern pour x: 0.xxx, y: 0.yyy
|
||
pattern3 = r'x\s*[:=]\s*(\d+\.?\d*).*?y\s*[:=]\s*(\d+\.?\d*)'
|
||
|
||
for pattern in [pattern2, pattern1, pattern3]:
|
||
matches = re.findall(pattern, text, re.IGNORECASE)
|
||
if matches:
|
||
x_str, y_str = matches[0]
|
||
try:
|
||
x = float(x_str)
|
||
y = float(y_str)
|
||
|
||
# Si les valeurs sont > 1, elles sont probablement en pixels
|
||
# Dans ce cas, on ne peut pas les normaliser sans connaître la taille
|
||
if x > 1 or y > 1:
|
||
# Essayer de normaliser si > 1 (supposer pixels)
|
||
# Mais on ne sait pas la taille de l'image ici...
|
||
# On retourne tel quel et on laisse l'appelant gérer
|
||
pass
|
||
|
||
return (x, y)
|
||
except ValueError:
|
||
continue
|
||
|
||
return None
|
||
|
||
def ground_with_anchor(
|
||
self,
|
||
image: Image.Image,
|
||
anchor_image: Image.Image,
|
||
anchor_description: Optional[str] = None
|
||
) -> GroundingResult:
|
||
"""
|
||
Localise un élément similaire à une ancre.
|
||
|
||
Si une description est fournie, l'utilise.
|
||
Sinon, essaie de décrire l'ancre automatiquement.
|
||
|
||
Args:
|
||
image: Screenshot actuel
|
||
anchor_image: Image de l'ancre à trouver
|
||
anchor_description: Description optionnelle de l'ancre
|
||
|
||
Returns:
|
||
GroundingResult avec les coordonnées trouvées
|
||
"""
|
||
if anchor_description:
|
||
description = anchor_description
|
||
else:
|
||
# Générer une description générique
|
||
# On pourrait utiliser un captioning model ici
|
||
description = "the element that looks like the given target"
|
||
|
||
return self.ground(image, description)
|
||
|
||
|
||
# Instance globale (singleton)
|
||
_seeclick_instance: Optional[SeeClickAdapter] = None
|
||
|
||
|
||
def get_seeclick() -> SeeClickAdapter:
|
||
"""Retourne l'instance singleton de SeeClick"""
|
||
global _seeclick_instance
|
||
if _seeclick_instance is None:
|
||
_seeclick_instance = SeeClickAdapter()
|
||
return _seeclick_instance
|
||
|
||
|
||
def ground_element(
|
||
image: Image.Image,
|
||
description: str,
|
||
return_pixels: bool = True
|
||
) -> GroundingResult:
|
||
"""Fonction utilitaire pour localiser un élément"""
|
||
return get_seeclick().ground(image, description, return_pixels)
|