feat: replay visuel VLM-first, worker séparé, package Léa, AZERTY, sécurité HTTPS

Pipeline replay visuel :
- VLM-first : l'agent appelle Ollama directement pour trouver les éléments
- Template matching en fallback (seuil strict 0.90)
- Stop immédiat si élément non trouvé (pas de clic blind)
- Replay depuis session brute (/replay-session) sans attendre le VLM
- Vérification post-action (screenshot hash avant/après)
- Gestion des popups (Enter/Escape/Tab+Enter)

Worker VLM séparé :
- run_worker.py : process distinct du serveur HTTP
- Communication par fichiers (_worker_queue.txt + _replay_active.lock)
- Le serveur HTTP ne fait plus jamais de VLM → toujours réactif
- Service systemd rpa-worker.service

Capture clavier :
- raw_keys (vk + press/release) pour replay exact indépendant du layout
- Fix AZERTY : ToUnicodeEx + AltGr detection
- Enter capturé comme \n, Tab comme \t
- Filtrage modificateurs seuls (Ctrl/Alt/Shift parasites)
- Fusion text_input consécutifs, dédup key_combo

Sécurité & Internet :
- HTTPS Let's Encrypt (lea.labs + vwb.labs.laurinebazin.design)
- Token API fixe dans .env.local
- HTTP Basic Auth sur VWB
- Security headers (HSTS, CSP, nosniff)
- CORS domaines publics, plus de wildcard

Infrastructure :
- DPI awareness (SetProcessDpiAwareness) Python + Rust
- Métadonnées système (dpi_scale, window_bounds, monitors, os_theme)
- Template matching multi-scale [0.5, 2.0]
- Résolution dynamique (plus de hardcode 1920x1080)
- VLM prefill fix (47x speedup, 3.5s au lieu de 180s)

Modules :
- core/auth/ : credential vault (Fernet AES), TOTP (RFC 6238), auth handler
- core/federation/ : LearningPack export/import anonymisé, FAISS global
- deploy/ : package Léa (config.txt, Lea.bat, install.bat, LISEZMOI.txt)

UX :
- Filtrage OS (VWB + Chat montrent que les workflows de l'OS courant)
- Bibliothèque persistante (cache local + SQLite)
- Clustering hybride (titre fenêtre + DBSCAN)
- EdgeConstraints + PostConditions peuplés
- GraphBuilder compound actions (toutes les frappes)

Agent Rust :
- Token Bearer auth (network.rs)
- sysinfo.rs (DPI, résolution, window bounds via Win32 API)
- config.txt lu automatiquement
- Support Chrome/Brave/Firefox (pas que Edge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-26 10:19:18 +01:00
parent fe5e0ba83d
commit d5deac3029
162 changed files with 25669 additions and 557 deletions

View File

@@ -26,7 +26,7 @@ class OllamaClient:
def __init__(self,
endpoint: str = "http://localhost:11434",
model: str = "qwen3-vl:8b",
timeout: int = 60):
timeout: int = 180):
"""
Initialiser le client Ollama
@@ -63,14 +63,21 @@ class OllamaClient:
system_prompt: Optional[str] = None,
temperature: float = 0.1,
max_tokens: int = 500,
force_json: bool = False) -> Dict[str, Any]:
force_json: bool = False,
assistant_prefill: Optional[str] = None,
num_ctx: Optional[int] = None,
extra_images_b64: Optional[List[str]] = None) -> Dict[str, Any]:
"""
Générer une réponse du VLM via l'API chat d'Ollama.
Note: On utilise /api/chat au lieu de /api/generate car qwen3-vl
avec /api/generate consomme tous les tokens en thinking interne
et retourne une réponse vide. L'API chat gère correctement
le mode /no_think et sépare thinking/réponse.
Pour les modèles thinking (qwen3-vl), on utilise la technique du
"assistant prefill" : un message assistant pré-rempli est ajouté
après le message user, forçant le modèle à continuer directement
sans phase de thinking. Cela résout le bug Ollama 0.18.x où
think=false est ignoré par le renderer qwen3-vl-thinking.
Sans prefill : le modèle pense 500+ tokens puis répond (~180s)
Avec prefill : le modèle répond directement (~1-5s)
Args:
prompt: Prompt textuel
@@ -80,6 +87,11 @@ class OllamaClient:
temperature: Température de génération
max_tokens: Nombre max de tokens
force_json: Forcer la sortie JSON (non recommandé pour qwen3-vl)
assistant_prefill: Début de réponse pré-rempli (auto-détecté si None)
num_ctx: Context window (défaut 2048, augmenter pour batch)
extra_images_b64: Images supplémentaires en base64 à envoyer avec le prompt.
Ajoutées après l'image principale. Utile pour le VLM multi-image
(ex: screenshot + crop de référence).
Returns:
Dict avec 'response', 'success', 'error'
@@ -93,17 +105,19 @@ class OllamaClient:
image_data = self._encode_image_from_pil(image)
# Nettoyer le prompt — retirer /no_think et /nothink du texte
# car le mode thinking est contrôlé via le paramètre think=false
# de l'API chat. Les préfixes /no_think dans le prompt causent
# paradoxalement PLUS de thinking interne chez qwen3-vl.
effective_prompt = prompt.replace("/no_think\n", "").replace("/no_think", "")
effective_prompt = effective_prompt.replace("/nothink ", "").replace("/nothink", "")
effective_prompt = effective_prompt.strip()
# Construire le message utilisateur
user_message = {"role": "user", "content": effective_prompt}
all_images = []
if image_data:
user_message["images"] = [image_data]
all_images.append(image_data)
if extra_images_b64:
all_images.extend(extra_images_b64)
if all_images:
user_message["images"] = all_images
# Construire les messages
messages = []
@@ -111,9 +125,37 @@ class OllamaClient:
messages.append({"role": "system", "content": system_prompt})
messages.append(user_message)
# Déterminer si le modèle supporte le thinking
# Déterminer si le modèle est un modèle thinking (qwen3)
is_thinking_model = "qwen3" in self.model.lower()
# WORKAROUND Ollama 0.18.x : think=false est ignoré par le
# renderer qwen3-vl-thinking. On utilise un assistant prefill
# pour forcer le modèle à skip le thinking et répondre directement.
# Le prefill est choisi en fonction du format attendu.
# IMPORTANT : avec image, sans prefill le thinking dépasse 180s.
prefill_used = None
if is_thinking_model:
if assistant_prefill is not None:
prefill_used = assistant_prefill
elif force_json:
prefill_used = "{"
elif all_images:
# Avec image(s), le thinking est catastrophique (>180s).
# Prefill générique pour forcer une réponse directe.
prefill_used = "Based on the image,"
if prefill_used is not None:
messages.append({
"role": "assistant",
"content": prefill_used
})
# num_ctx par défaut à 2048 (correspondant au default du modèle
# chargé en mémoire). Changer num_ctx force un rechargement du
# KV cache (~30s de pénalité), donc ne l'augmenter que pour les
# requêtes batch qui dépassent la limite (image + prompt long).
effective_num_ctx = num_ctx or 2048
payload = {
"model": self.model,
"messages": messages,
@@ -121,13 +163,13 @@ class OllamaClient:
"options": {
"temperature": temperature,
"num_predict": max_tokens,
"num_ctx": 2048,
"num_ctx": effective_num_ctx,
"top_k": 1
}
}
# Désactiver le thinking pour les modèles qui le supportent
# Cela réduit drastiquement la consommation de tokens et le temps
# Garder think=false au cas où une future version d'Ollama le
# corrige — le prefill reste le mécanisme principal
if is_thinking_model:
payload["think"] = False
@@ -144,6 +186,11 @@ class OllamaClient:
if response.status_code == 200:
result = response.json()
content = result.get("message", {}).get("content", "")
# Reconstituer la réponse complète en ajoutant le prefill
if prefill_used and content:
content = prefill_used + content
return {
"response": content,
"success": True,
@@ -181,8 +228,11 @@ For each element, provide:
- Semantic role (primary_action, cancel, submit, form_input, search_field, navigation, settings, close)
Format your response as JSON."""
result = self.generate(prompt, image_path=image_path, temperature=0.1)
result = self.generate(
prompt, image_path=image_path, temperature=0.1,
assistant_prefill="[",
)
if result["success"]:
try:
@@ -214,14 +264,21 @@ Format your response as JSON."""
Choose ONLY ONE from: {types_list}
Respond with just the type name, nothing else."""
if context:
prompt += f"\n\nContext: {context}"
result = self.generate(prompt, image=element_image, temperature=0.1)
result = self.generate(
prompt, image=element_image, temperature=0.1,
assistant_prefill="The type is:",
)
if result["success"]:
element_type = result["response"].strip().lower()
# Retirer le prefill du début pour extraire le type
raw = result["response"]
if raw.startswith("The type is:"):
raw = raw[len("The type is:"):]
element_type = raw.strip().lower()
# Valider que c'est un type connu
valid_types = types_list.split(", ")
if element_type in valid_types:
@@ -255,14 +312,21 @@ Respond with just the type name, nothing else."""
Choose ONLY ONE from: {roles_list}
Respond with just the role name, nothing else."""
if context:
prompt += f"\n\nContext: {context}"
result = self.generate(prompt, image=element_image, temperature=0.1)
result = self.generate(
prompt, image=element_image, temperature=0.1,
assistant_prefill="The role is:",
)
if result["success"]:
role = result["response"].strip().lower()
# Retirer le prefill du début pour extraire le rôle
raw = result["response"]
if raw.startswith("The role is:"):
raw = raw[len("The role is:"):]
role = raw.strip().lower()
# Valider que c'est un rôle connu
valid_roles = roles_list.split(", ")
if role in valid_roles:
@@ -286,12 +350,19 @@ Respond with just the role name, nothing else."""
Dict avec 'text' extrait
"""
prompt = "Extract all visible text from this image. Return only the text, nothing else."
result = self.generate(prompt, image=image, temperature=0.1)
result = self.generate(
prompt, image=image, temperature=0.1,
assistant_prefill="Text:",
)
if result["success"]:
return {"text": result["response"].strip(), "success": True}
# Retirer le prefill du début pour extraire le texte
raw = result["response"]
if raw.startswith("Text:"):
raw = raw[len("Text:"):]
return {"text": raw.strip(), "success": True}
return {"text": "", "success": False, "error": result["error"]}
# Taille minimum pour une classification fiable par le VLM
@@ -346,7 +417,8 @@ Your answer:"""
system_prompt=system_prompt,
temperature=0.1,
max_tokens=300,
force_json=False
force_json=False,
assistant_prefill="{"
)
if not result["success"]:

View File

@@ -220,7 +220,7 @@ class UIDetector:
# des centaines d'appels VLM inutiles (~2-3s chacun).
# On garde max 80 candidats — suffisant pour obtenir ~50 éléments
# après filtrage par confiance, tout en gardant un temps raisonnable.
max_candidates = 30 # 30 suffisent pour les éléments principaux (~6min/screenshot au lieu de 17)
max_candidates = 10 # 10 régions : compact, rapide (~5-10s avec prefill)
if len(regions) > max_candidates:
# Trier par confiance décroissante, puis par surface décroissante
regions.sort(key=lambda r: (r.confidence, r.w * r.h), reverse=True)
@@ -489,32 +489,18 @@ class UIDetector:
if not self.vlm_client or not regions:
return None
# Construire la description des régions pour le prompt
# Construire une description compacte des régions (économise les tokens)
regions_desc_lines = []
for i, r in enumerate(regions):
regions_desc_lines.append(
f" #{i}: position=({r.x},{r.y}), size={r.w}x{r.h}, source={r.source}"
)
regions_description = "\n".join(regions_desc_lines)
regions_desc_lines.append(f"#{i}:({r.x},{r.y},{r.w}x{r.h})")
regions_description = " ".join(regions_desc_lines)
prompt = f"""Analyze this screenshot. I have detected UI elements at these positions:
{regions_description}
prompt = f"""Classify UI elements at: {regions_description}
Types: button,text_input,checkbox,radio,dropdown,tab,link,icon,table_row,menu_item
Roles: primary_action,cancel,submit,form_input,search_field,navigation,settings,close,delete,edit,save
JSON array: [{{"id":0,"type":"...","role":"...","text":"..."}}]"""
For each element, classify it as a JSON array. Each entry must have:
- "id": the element number (matching # above)
- "type": one of button, text_input, checkbox, radio, dropdown, tab, link, icon, table_row, menu_item
- "role": one of primary_action, cancel, submit, form_input, search_field, navigation, settings, close, delete, edit, save
- "text": visible text on the element (empty string if none)
Return ONLY the JSON array, nothing else. Example:
[{{"id": 0, "type": "button", "role": "submit", "text": "OK"}}, {{"id": 1, "type": "text_input", "role": "form_input", "text": ""}}]
Your answer:"""
system_prompt = (
"You are a JSON-only UI classifier. No thinking. No explanation. "
"Output a raw JSON array only."
)
system_prompt = "JSON-only UI classifier. No explanation."
# Appel VLM unique avec le screenshot complet
for attempt in range(2):
@@ -523,8 +509,10 @@ Your answer:"""
image=pil_image,
system_prompt=system_prompt,
temperature=0.1,
max_tokens=2000, # Plus de tokens car réponse groupée
max_tokens=1500, # ~100 tokens/element * 10 elements + marge
force_json=False,
assistant_prefill="[", # Force JSON array direct, skip thinking
num_ctx=2048, # 2048 suffit pour 10 régions compactes + image
)
if not result["success"]:

View File

@@ -0,0 +1,622 @@
"""
UIDetector - Détection Sémantique d'Éléments UI avec VLM
Utilise un Vision-Language Model (VLM) pour détecter et classifier
les éléments UI avec leurs types et rôles sémantiques.
"""
from typing import List, Dict, Optional, Any, Tuple
from pathlib import Path
from dataclasses import dataclass
import numpy as np
from PIL import Image
import json
import re
from ..models.ui_element import UIElement, UIElementEmbeddings, VisualFeatures
from .ollama_client import OllamaClient, check_ollama_available
@dataclass
class DetectionConfig:
"""Configuration de la détection UI"""
vlm_model: str = "qwen3-vl:8b" # Modèle VLM à utiliser (qwen3-vl:8b recommandé)
vlm_endpoint: str = "http://localhost:11434" # Endpoint Ollama
confidence_threshold: float = 0.7 # Seuil de confiance minimum
max_elements: int = 50 # Nombre max d'éléments à détecter
detect_regions: bool = True # Détecter régions d'intérêt d'abord
use_embeddings: bool = True # Générer embeddings duaux
class UIDetector:
"""
Détecteur d'éléments UI sémantique
Utilise un VLM (Vision-Language Model) pour :
1. Détecter les régions d'intérêt dans un screenshot
2. Classifier le type de chaque élément UI
3. Déterminer le rôle sémantique
4. Extraire les features visuelles
5. Générer des embeddings duaux (image + texte)
"""
def __init__(self, config: Optional[DetectionConfig] = None):
"""
Initialiser le détecteur
Args:
config: Configuration (utilise config par défaut si None)
"""
self.config = config or DetectionConfig()
self.vlm_client = None
self._initialize_vlm()
def _initialize_vlm(self) -> None:
"""Initialiser le client VLM (Ollama)"""
try:
# Vérifier si Ollama est disponible
if check_ollama_available(self.config.vlm_endpoint):
self.vlm_client = OllamaClient(
endpoint=self.config.vlm_endpoint,
model=self.config.vlm_model
)
print(f"✓ VLM initialized: {self.config.vlm_model} at {self.config.vlm_endpoint}")
else:
print(f"⚠ Ollama not available at {self.config.vlm_endpoint}, using simulation mode")
self.vlm_client = None
except Exception as e:
print(f"⚠ Failed to initialize VLM: {e}, using simulation mode")
self.vlm_client = None
def detect(self,
screenshot_path: str,
window_context: Optional[Dict[str, Any]] = None) -> List[UIElement]:
"""
Détecter tous les éléments UI dans un screenshot
Args:
screenshot_path: Chemin vers le screenshot
window_context: Contexte de la fenêtre (titre, process, etc.)
Returns:
Liste d'UIElements détectés
"""
# Charger image
image = self._load_image(screenshot_path)
if image is None:
return []
# Détecter régions d'intérêt si activé
if self.config.detect_regions:
regions = self._detect_regions_of_interest(image, window_context)
else:
# Utiliser image complète
regions = [{"bbox": (0, 0, image.width, image.height), "confidence": 1.0}]
# Détecter éléments UI dans chaque région
ui_elements = []
for region in regions:
elements = self._detect_elements_in_region(
image,
region,
screenshot_path,
window_context
)
ui_elements.extend(elements)
# Filtrer par confiance
ui_elements = [
el for el in ui_elements
if el.confidence >= self.config.confidence_threshold
]
# Limiter nombre d'éléments
if len(ui_elements) > self.config.max_elements:
# Trier par confiance et garder les meilleurs
ui_elements.sort(key=lambda x: x.confidence, reverse=True)
ui_elements = ui_elements[:self.config.max_elements]
return ui_elements
def _load_image(self, screenshot_path: str) -> Optional[Image.Image]:
"""Charger une image depuis un fichier"""
try:
return Image.open(screenshot_path)
except Exception as e:
print(f"Error loading image {screenshot_path}: {e}")
return None
def _detect_regions_of_interest(self,
image: Image.Image,
window_context: Optional[Dict] = None) -> List[Dict]:
"""
Détecter les régions d'intérêt dans l'image
Utilise le VLM pour identifier les zones contenant des éléments UI.
Args:
image: Image PIL
window_context: Contexte de la fenêtre
Returns:
Liste de régions {bbox: (x, y, w, h), confidence: float}
"""
if self.vlm_client is None:
# Mode simulation : diviser l'image en grille
return self._simulate_region_detection(image)
# Utiliser VLM pour détecter régions
# Pour l'instant, on utilise l'image complète (plus simple et efficace)
width, height = image.size
return [{
"bbox": (0, 0, width, height),
"confidence": 1.0
}]
def _simulate_region_detection(self, image: Image.Image) -> List[Dict]:
"""Simulation de détection de régions (pour développement)"""
width, height = image.size
# Diviser en grille 3x3 pour simulation
regions = []
grid_size = 3
cell_w = width // grid_size
cell_h = height // grid_size
for i in range(grid_size):
for j in range(grid_size):
regions.append({
"bbox": (j * cell_w, i * cell_h, cell_w, cell_h),
"confidence": 0.8
})
return regions
def _detect_elements_in_region(self,
image: Image.Image,
region: Dict,
screenshot_path: str,
window_context: Optional[Dict] = None) -> List[UIElement]:
"""
Détecter éléments UI dans une région spécifique
Args:
image: Image complète
region: Région à analyser
screenshot_path: Chemin du screenshot
window_context: Contexte de la fenêtre
Returns:
Liste d'UIElements dans cette région
"""
bbox = region["bbox"]
x, y, w, h = bbox
# Extraire crop de la région
region_image = image.crop((x, y, x + w, y + h))
# Détecter éléments avec VLM
if self.vlm_client is None:
# Mode simulation
return self._simulate_element_detection(
region_image, bbox, screenshot_path, window_context
)
# Vraie détection avec VLM !
return self._detect_with_vlm(
region_image, bbox, screenshot_path, window_context
)
def _detect_with_vlm(self,
region_image: Image.Image,
region_bbox: Tuple[int, int, int, int],
screenshot_path: str,
window_context: Optional[Dict] = None) -> List[UIElement]:
"""
Détecter éléments UI avec le VLM (vraie détection)
Args:
region_image: Image de la région
region_bbox: Bbox de la région (x, y, w, h)
screenshot_path: Chemin du screenshot
window_context: Contexte de la fenêtre
Returns:
Liste d'UIElements détectés
"""
x_offset, y_offset, w, h = region_bbox
# Construire le prompt pour le VLM
context_str = ""
if window_context:
context_str = f"\nWindow context: {window_context.get('title', 'Unknown')}"
# Approche simplifiée : demander une description structurée
prompt = f"""List all interactive UI elements in this screenshot.{context_str}
For each element, provide:
- type (button, text_input, checkbox, link, etc.)
- label (visible text)
- approximate position (top/middle/bottom, left/center/right)
Format as JSON array:
[{{"type": "button", "label": "Submit", "position": "middle-center"}}]
Return ONLY the JSON array, no other text."""
# Appeler le VLM
# Note: Utiliser le chemin du screenshot complet plutôt que le crop
# car certains VLM gèrent mieux les fichiers que les images PIL
result = self.vlm_client.generate(
prompt=prompt,
image_path=screenshot_path, # Utiliser le chemin au lieu de l'image PIL
temperature=0.1,
max_tokens=1000
)
if not result["success"]:
print(f"❌ VLM detection failed: {result.get('error', 'Unknown error')}")
return []
if not result["response"] or len(result["response"].strip()) == 0:
print(f"⚠ VLM returned empty response")
return []
# Parser la réponse JSON
elements = self._parse_vlm_response(
result["response"],
region_bbox,
screenshot_path,
window_context
)
return elements
def _parse_vlm_response(self,
response: str,
region_bbox: Tuple[int, int, int, int],
screenshot_path: str,
window_context: Optional[Dict] = None) -> List[UIElement]:
"""
Parser la réponse JSON du VLM
Args:
response: Réponse texte du VLM
region_bbox: Bbox de la région
screenshot_path: Chemin du screenshot
window_context: Contexte de la fenêtre
Returns:
Liste d'UIElements
"""
x_offset, y_offset, region_w, region_h = region_bbox
try:
# Extraire le JSON de la réponse (peut contenir du texte avant/après)
json_match = re.search(r'\[.*\]', response, re.DOTALL)
if not json_match:
print(f"No JSON array found in VLM response")
print(f"VLM response was: {response[:500]}...")
return []
elements_data = json.loads(json_match.group(0))
if not isinstance(elements_data, list):
print(f"VLM response is not a JSON array")
return []
elements = []
for i, elem_data in enumerate(elements_data):
try:
# Gérer les positions (pourcentages ou textuelles)
if 'x' in elem_data and 'y' in elem_data:
# Format avec pourcentages
x_pct = float(elem_data.get('x', 0))
y_pct = float(elem_data.get('y', 0))
w_pct = float(elem_data.get('width', 10))
h_pct = float(elem_data.get('height', 5))
elem_x = x_offset + int(region_w * x_pct / 100)
elem_y = y_offset + int(region_h * y_pct / 100)
elem_w = int(region_w * w_pct / 100)
elem_h = int(region_h * h_pct / 100)
else:
# Format avec position textuelle (top/middle/bottom, left/center/right)
position = elem_data.get('position', 'middle-center').lower()
# Parser la position
if 'top' in position:
elem_y = y_offset + region_h // 4
elif 'bottom' in position:
elem_y = y_offset + 3 * region_h // 4
else: # middle
elem_y = y_offset + region_h // 2
if 'left' in position:
elem_x = x_offset + region_w // 4
elif 'right' in position:
elem_x = x_offset + 3 * region_w // 4
else: # center
elem_x = x_offset + region_w // 2
# Taille par défaut basée sur le type
elem_type = elem_data.get('type', 'button')
if elem_type == 'button':
elem_w, elem_h = 100, 40
elif elem_type == 'text_input':
elem_w, elem_h = 200, 35
elif elem_type == 'checkbox':
elem_w, elem_h = 25, 25
else:
elem_w, elem_h = 80, 30
# Créer l'UIElement
element = UIElement(
element_id=f"vlm_{elem_x}_{elem_y}",
type=elem_data.get('type', 'unknown'),
role=elem_data.get('role', 'unknown'),
bbox=(elem_x, elem_y, elem_w, elem_h),
center=(elem_x + elem_w // 2, elem_y + elem_h // 2),
label=elem_data.get('label', ''),
label_confidence=0.85, # Confiance par défaut pour VLM
embeddings=UIElementEmbeddings(),
visual_features=VisualFeatures(
dominant_color="rgb(128, 128, 128)",
has_icon=elem_data.get('type') == 'icon',
shape="rectangle",
size_category="medium"
),
confidence=0.85, # Confiance par défaut pour VLM
metadata={
"detected_by": "vlm",
"model": self.config.vlm_model,
"screenshot_path": screenshot_path
}
)
elements.append(element)
except (KeyError, ValueError, TypeError) as e:
print(f"Error parsing element {i}: {e}")
continue
return elements
except json.JSONDecodeError as e:
print(f"Failed to parse VLM JSON response: {e}")
print(f"Response was: {response[:200]}...")
return []
def _simulate_element_detection(self,
region_image: Image.Image,
region_bbox: Tuple[int, int, int, int],
screenshot_path: str,
window_context: Optional[Dict] = None) -> List[UIElement]:
"""Simulation de détection d'éléments (pour développement)"""
# Pour simulation, créer quelques éléments fictifs
elements = []
x_offset, y_offset, w, h = region_bbox
# Simuler 2-3 éléments par région
num_elements = np.random.randint(2, 4)
for i in range(num_elements):
# Position aléatoire dans la région
elem_w = np.random.randint(50, 150)
elem_h = np.random.randint(20, 60)
elem_x = x_offset + np.random.randint(0, max(1, w - elem_w))
elem_y = y_offset + np.random.randint(0, max(1, h - elem_h))
# Type et rôle aléatoires
types = ["button", "text_input", "checkbox", "link", "icon"]
roles = ["primary_action", "cancel", "submit", "form_input", "navigation"]
element = UIElement(
element_id=f"elem_{elem_x}_{elem_y}",
type=np.random.choice(types),
role=np.random.choice(roles),
bbox=(elem_x, elem_y, elem_w, elem_h),
center=(elem_x + elem_w // 2, elem_y + elem_h // 2),
label=f"Element {i}",
label_confidence=np.random.uniform(0.7, 0.95),
embeddings=UIElementEmbeddings(), # Embeddings vides
visual_features=VisualFeatures(
dominant_color="rgb(128, 128, 128)",
has_icon=np.random.choice([True, False]),
shape="rectangle",
size_category="medium"
),
confidence=np.random.uniform(0.7, 0.95),
metadata={"simulated": True, "screenshot_path": screenshot_path}
)
elements.append(element)
return elements
def classify_type(self,
element_image: Image.Image,
context: Optional[Dict] = None) -> Tuple[str, float]:
"""
Classifier le type d'un élément UI
Args:
element_image: Image de l'élément
context: Contexte additionnel
Returns:
(type, confidence)
"""
if self.vlm_client is None:
# Simulation
types = ["button", "text_input", "checkbox", "radio", "dropdown",
"tab", "link", "icon", "table_row", "menu_item"]
return np.random.choice(types), np.random.uniform(0.7, 0.95)
# Vraie classification avec VLM
result = self.vlm_client.classify_element_type(element_image, context)
if result["success"]:
return result["type"], result["confidence"]
return "unknown", 0.0
def classify_role(self,
element_image: Image.Image,
element_type: str,
context: Optional[Dict] = None) -> Tuple[str, float]:
"""
Classifier le rôle sémantique d'un élément
Args:
element_image: Image de l'élément
element_type: Type de l'élément
context: Contexte additionnel
Returns:
(role, confidence)
"""
if self.vlm_client is None:
# Simulation
roles = ["primary_action", "cancel", "submit", "form_input",
"search_field", "navigation", "settings", "close"]
return np.random.choice(roles), np.random.uniform(0.7, 0.95)
# Vraie classification avec VLM
result = self.vlm_client.classify_element_role(
element_image,
element_type,
context
)
if result["success"]:
return result["role"], result["confidence"]
return "unknown", 0.0
def extract_visual_features(self,
element_image: Image.Image) -> VisualFeatures:
"""
Extraire les features visuelles d'un élément
Args:
element_image: Image de l'élément
Returns:
VisualFeatures
"""
# Calculer couleur dominante
img_array = np.array(element_image)
if len(img_array.shape) == 3:
# Moyenne des couleurs
dominant_color = tuple(img_array.mean(axis=(0, 1)).astype(int).tolist())
else:
dominant_color = (128, 128, 128)
# Déterminer forme (simplifié)
width, height = element_image.size
aspect_ratio = width / height if height > 0 else 1.0
if aspect_ratio > 3:
shape = "horizontal_bar"
elif aspect_ratio < 0.33:
shape = "vertical_bar"
elif 0.8 <= aspect_ratio <= 1.2:
shape = "square"
else:
shape = "rectangle"
# Catégorie de taille
area = width * height
if area < 1000:
size_category = "small"
elif area < 10000:
size_category = "medium"
else:
size_category = "large"
# Détection d'icône (simplifié)
has_icon = width < 100 and height < 100 and 0.8 <= aspect_ratio <= 1.2
return VisualFeatures(
dominant_color=dominant_color,
has_icon=has_icon,
shape=shape,
size_category=size_category
)
def generate_embeddings(self,
element_image: Image.Image,
element_label: str,
embedder: Optional[Any] = None) -> Optional[UIElementEmbeddings]:
"""
Générer embeddings duaux (image + texte) pour un élément
Args:
element_image: Image de l'élément
element_label: Label textuel de l'élément
embedder: Embedder à utiliser (optionnel)
Returns:
UIElementEmbeddings ou None
"""
if not self.config.use_embeddings or embedder is None:
return None
try:
# Générer embedding image
image_embedding_id = None
if hasattr(embedder, 'embed_image'):
# Sauvegarder temporairement l'image
# TODO: Implémenter sauvegarde et embedding
pass
# Générer embedding texte
text_embedding_id = None
if element_label and hasattr(embedder, 'embed_text'):
# TODO: Implémenter embedding texte
pass
if image_embedding_id or text_embedding_id:
return UIElementEmbeddings(
image_embedding_id=image_embedding_id,
text_embedding_id=text_embedding_id,
provider="openclip_ViT-B-32",
dimensions=512
)
except Exception as e:
print(f"Warning: Failed to generate embeddings: {e}")
return None
def set_vlm_client(self, client: Any) -> None:
"""Définir le client VLM"""
self.vlm_client = client
def get_config(self) -> DetectionConfig:
"""Récupérer la configuration"""
return self.config
# ============================================================================
# Fonctions utilitaires
# ============================================================================
def create_detector(vlm_model: str = "qwen3-vl:8b",
confidence_threshold: float = 0.7) -> UIDetector:
"""
Créer un UIDetector avec configuration personnalisée
Args:
vlm_model: Modèle VLM à utiliser
confidence_threshold: Seuil de confiance
Returns:
UIDetector configuré
"""
config = DetectionConfig(
vlm_model=vlm_model,
confidence_threshold=confidence_threshold
)
return UIDetector(config)