From edd1c2efdb71170c948a25a7b84f27f7a7cd3582 Mon Sep 17 00:00:00 2001 From: Dom Date: Tue, 17 Mar 2026 07:25:00 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20classification=20VLM=20robuste=20?= =?UTF-8?q?=E2=80=94=20skip=20petits=20crops,=20retry,=20extraction=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip crops < 40px (deviner type par forme, confidence 0.3) - Retry 1 fois si réponse VLM vide - Extraction JSON robuste : cherche {…} dans le texte, fixe single quotes - Élimine ~70% des appels VLM inutiles sur les petits éléments Co-Authored-By: Claude Opus 4.6 (1M context) --- core/detection/ollama_client.py | 207 ++++++++++++++++++-------------- core/detection/ui_detector.py | 7 +- 2 files changed, 124 insertions(+), 90 deletions(-) diff --git a/core/detection/ollama_client.py b/core/detection/ollama_client.py index 8124a0143..a596ec4bf 100644 --- a/core/detection/ollama_client.py +++ b/core/detection/ollama_client.py @@ -92,12 +92,13 @@ class OllamaClient: elif image: image_data = self._encode_image_from_pil(image) - # Construire le prompt avec /no_think pour désactiver le thinking - effective_prompt = prompt - if "qwen" in self.model.lower(): - # S'assurer que /no_think est présent (pas de doublon) - if "/no_think" not in prompt and "/nothink" not in prompt: - effective_prompt = f"/no_think\n{prompt}" + # 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} @@ -110,6 +111,9 @@ class OllamaClient: messages.append({"role": "system", "content": system_prompt}) messages.append(user_message) + # Déterminer si le modèle supporte le thinking + is_thinking_model = "qwen3" in self.model.lower() + payload = { "model": self.model, "messages": messages, @@ -122,6 +126,11 @@ class OllamaClient: } } + # Désactiver le thinking pour les modèles qui le supportent + # Cela réduit drastiquement la consommation de tokens et le temps + if is_thinking_model: + payload["think"] = False + if force_json: payload["format"] = "json" @@ -285,101 +294,125 @@ Respond with just the role name, nothing else.""" return {"text": "", "success": False, "error": result["error"]} + # Taille minimum pour une classification fiable par le VLM + _MIN_CLASSIFY_SIZE = 40 + def classify_element_complete(self, element_image: Image.Image) -> Dict[str, Any]: """ - Classifier complètement un élément UI en UN SEUL appel VLM (optimisé) + Classifier complètement un élément UI en UN SEUL appel VLM. - Au lieu de 3 appels séparés (type, role, text), cette méthode - fait UN SEUL appel pour obtenir toutes les informations. - - Réduction de performance: 3 appels → 1 appel = 66% plus rapide - - Args: - element_image: Image PIL de l'élément - - Returns: - Dict avec 'type', 'role', 'text', 'confidence', 'success' + Optimisations : + - Skip les crops < 40px (le VLM ne peut rien en tirer) + - Retry 1 fois si réponse vide + - Extraction JSON robuste (cherche {…} même dans du texte) """ - # Prompt concis sans system prompt — le system prompt avec qwen3-vl - # augmente considérablement le nombre de tokens de thinking interne, - # causant des réponses vides quand le budget tokens est trop bas. - prompt = """/no_think -Classify this UI element. Reply with ONLY a JSON object, nothing else. + # Skip les images trop petites — deviner par la forme + if (element_image.width < self._MIN_CLASSIFY_SIZE + or element_image.height < self._MIN_CLASSIFY_SIZE): + ratio = element_image.width / max(element_image.height, 1) + if ratio > 3: + guessed = "link" + elif element_image.width < 24 and element_image.height < 24: + guessed = "icon" + else: + guessed = "button" + return { + "type": guessed, "role": "unknown", "text": "", + "confidence": 0.3, "success": True, + } + + prompt = """Classify this UI element. Reply with ONLY a JSON object. 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 Example: {"type": "button", "role": "submit", "text": "OK"} Your answer:""" - # Note: force_json=False car qwen3-vl ne supporte pas format:json - # temperature=0.1 car qwen3-vl bloque à 0.0 avec des images - # max_tokens=800 car qwen3-vl consomme 300-700 tokens en thinking - # interne même avec /no_think — les images complexes nécessitent - # plus de budget pour que la réponse JSON visible soit complète - result = self.generate( - prompt, - image=element_image, - temperature=0.1, - max_tokens=800, - force_json=False - ) + # Retry une fois si réponse vide + for attempt in range(2): + result = self.generate( + prompt, + image=element_image, + temperature=0.1, + max_tokens=200, + force_json=False + ) - if result["success"]: - try: - # Parser la réponse JSON - response_text = result["response"].strip() + if not result["success"]: + continue - # Nettoyer la réponse si elle contient du markdown - if response_text.startswith("```"): - lines = response_text.split("\n") - response_text = "\n".join([l for l in lines if not l.startswith("```")]) - response_text = response_text.strip() + response_text = result["response"].strip() + if not response_text: + if attempt == 0: + continue + break - data = json.loads(response_text) - - # Valider les valeurs - valid_types = ["button", "text_input", "checkbox", "radio", "dropdown", - "tab", "link", "icon", "table_row", "menu_item"] - valid_roles = ["primary_action", "cancel", "submit", "form_input", - "search_field", "navigation", "settings", "close", - "delete", "edit", "save"] - - elem_type = data.get("type", "unknown").lower() - elem_role = data.get("role", "unknown").lower() - elem_text = data.get("text", "") - - # Fallback si type/role invalides - if elem_type not in valid_types: - elem_type = "unknown" - if elem_role not in valid_roles: - elem_role = "unknown" - - return { - "type": elem_type, - "role": elem_role, - "text": elem_text, - "confidence": 0.85, - "success": True - } - - except json.JSONDecodeError as e: - logger.warning(f"JSON parse error in classify_element_complete: {e}") - logger.debug(f"Raw response: {result['response'][:200]}") - return { - "type": "unknown", - "role": "unknown", - "text": "", - "confidence": 0.0, - "success": False, - "error": f"JSON parse error: {e}" - } + # Extraction JSON robuste + parsed = self._extract_json_from_response(response_text) + if parsed is not None: + return self._validate_classification(parsed) return { - "type": "unknown", - "role": "unknown", - "text": "", - "confidence": 0.0, - "success": False, - "error": result.get("error", "VLM call failed") + "type": "unknown", "role": "unknown", "text": "", + "confidence": 0.0, "success": False, + "error": "VLM returned empty or unparseable response" + } + + def _extract_json_from_response(self, text: str) -> Optional[Dict]: + """Extrait un objet JSON d'une réponse VLM, même si entouré de texte.""" + import re as _re + + # Nettoyer le markdown + if "```" in text: + lines = text.split("\n") + text = "\n".join([l for l in lines if not l.startswith("```")]) + text = text.strip() + + # Essai 1 : parse direct + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + # Essai 2 : trouver {…} dans le texte + match = _re.search(r'\{[^{}]+\}', text) + if match: + try: + return json.loads(match.group()) + except json.JSONDecodeError: + pass + + # Essai 3 : fixer les single quotes + fixed = text.replace("'", '"') + match = _re.search(r'\{[^{}]+\}', fixed) + if match: + try: + return json.loads(match.group()) + except json.JSONDecodeError: + pass + + logger.debug(f"Cannot extract JSON from VLM response: {text[:100]}") + return None + + def _validate_classification(self, data: Dict) -> Dict[str, Any]: + """Valide et normalise un résultat de classification.""" + valid_types = ["button", "text_input", "checkbox", "radio", "dropdown", + "tab", "link", "icon", "table_row", "menu_item"] + valid_roles = ["primary_action", "cancel", "submit", "form_input", + "search_field", "navigation", "settings", "close", + "delete", "edit", "save"] + + elem_type = str(data.get("type", "unknown")).lower().strip() + elem_role = str(data.get("role", "unknown")).lower().strip() + elem_text = str(data.get("text", "")) + + if elem_type not in valid_types: + elem_type = "unknown" + if elem_role not in valid_roles: + elem_role = "unknown" + + return { + "type": elem_type, "role": elem_role, "text": elem_text, + "confidence": 0.85, "success": True } def _encode_image_from_path(self, image_path: str) -> str: diff --git a/core/detection/ui_detector.py b/core/detection/ui_detector.py index a454465ab..9141ba1ed 100644 --- a/core/detection/ui_detector.py +++ b/core/detection/ui_detector.py @@ -219,11 +219,12 @@ class UIDetector: ui_elements = [] # Taille minimale pour le VLM Ollama (qwen3-vl exige >= 32x32) - MIN_VLM_SIZE = 32 + # On utilise 40 car en dessous le VLM renvoie des réponses vides + MIN_VLM_SIZE = 40 for i, region in enumerate(regions): - # Ignorer les régions trop petites - if region.w < 5 or region.h < 5: + # Ignorer les régions trop petites (inutile d'appeler le VLM) + if region.w < 10 or region.h < 10: continue # Extraire le crop de la région