diff --git a/core/execution/observe_reason_act.py b/core/execution/observe_reason_act.py index d37aee34a..567f2b4e4 100644 --- a/core/execution/observe_reason_act.py +++ b/core/execution/observe_reason_act.py @@ -221,22 +221,31 @@ class ORALoop: if action_type in ('click_anchor', 'click', 'double_click_anchor', 'right_click_anchor'): target_text = anchor.get('target_text', '') or anchor.get('description', '') - # Si target_text est vide ou est un nom d'action → décrire le crop - if not target_text or target_text in _action_type_names: - screenshot_b64 = anchor.get('screenshot', '') - if screenshot_b64: - try: - from core.execution.input_handler import _describe_anchor_image - desc = _describe_anchor_image(screenshot_b64) - if desc and len(desc) > 2: - target_text = desc - print(f"🏷️ [ORA/reason] Ancre décrite par VLM: '{target_text}'") - except Exception: - pass + # Détecter les target_text absurdes : vide, nom d'action, ou bruit OCR + def _is_garbage(t): + if not t or t in _action_type_names: + return True + # Bruit OCR : que des caractères spéciaux/chiffres/espaces + cleaned = t.replace('-', '').replace(' ', '').replace('.', '').replace('_', '') + if len(cleaned) < 3: + return True + # Que des chiffres + if cleaned.isdigit(): + return True + return False + + # Note: plus d'appel à _describe_anchor_image() (qwen2.5vl) ici. + # Le crop d'ancre (screenshot_b64) servira directement au template matching + # cv2 dans _act_click, puis fallback InfiGUI fusionné si nécessaire. + # Cela évite le conflit VRAM (qwen2.5vl 9.4GB + InfiGUI 2.4GB > 11.5GB GPU). # Dernier fallback : label si pas un nom d'action - if not target_text or target_text in _action_type_names: + if _is_garbage(target_text): target_text = label if label not in _action_type_names else '' + if target_text: + print(f"🏷️ [ORA/reason] Label garbage, fallback texte: '{target_text}'") + else: + print(f"🏷️ [ORA/reason] Pas de label texte — grounding via crop visuel uniquement") action = 'click' value = 'double' if action_type == 'double_click_anchor' else ( @@ -1245,6 +1254,7 @@ Règles: ) print(f"🚀 [ORA] Démarrage workflow: {total} étapes, verify={self.verify_level}, retries={self.max_retries}") + print(f"🔧 [ORA] CODE VERSION: post-shortcut-dialog-handler ACTIF (26 avril 17h30)") for i, step in enumerate(steps): if not self._should_continue(): @@ -1326,6 +1336,47 @@ Règles: ) ) + # --- 3b. Post-raccourci : attendre changement écran + gérer dialogue --- + # Après un keyboard_shortcut (pas scroll), on polle le pHash pour détecter + # si un dialogue est apparu (ex: "Enregistrer sous" après Ctrl+Shift+S). + # Si oui → InfiGUI localise et clique le bouton visuellement. + if act_success and decision.action == 'hotkey' and not decision.value.startswith('scroll_'): + print(f"🔍 [ORA/post-shortcut] ENTRÉ dans le bloc post-shortcut (action={decision.action}, value={decision.value})") + dialog_handled = self._handle_post_shortcut(pre) + if dialog_handled: + time.sleep(0.5) + post = self.observe() + self._last_post_phash = post.phash + if on_progress: + on_progress(i + 1, total, VerificationResult( + success=True, change_level='major', + matches_expected=True, + detail="Dialogue géré visuellement après raccourci" + )) + continue + else: + # Invariant : aucune étape suivante ne doit s'exécuter tant que + # la cascade déclenchée par le raccourci n'est pas pleinement résolue. + # Cas typique : Ctrl+S → "Enregistrer sous" non géré → on ABORT plutôt + # que de cliquer sur des coordonnées potentiellement obsolètes. + msg = ( + f"Étape {i+1}: raccourci '{decision.value}' — cascade post-raccourci " + f"non résolue (dialogue absent ou bloqué). Workflow stoppé pour éviter " + f"un clic dans un contexte incohérent." + ) + print(f"❌ [ORA/post-shortcut] {msg}") + logger.warning(f"🆘 [ORA] {msg}") + if on_progress: + on_progress(i + 1, total, VerificationResult( + success=False, change_level='none', + matches_expected=False, + detail="Cascade post-raccourci non résolue" + )) + return LoopResult( + success=False, steps_completed=i, total_steps=total, + reason=msg, + ) + # Petit délai pour laisser l'écran se stabiliser time.sleep(0.3) @@ -1412,6 +1463,107 @@ Règles: # Méthodes privées — actions # ═══════════════════════════════════════════════════════════ + def _handle_post_shortcut(self, pre_obs: 'Observation') -> bool: + """Après un raccourci clavier, résoudre la cascade de dialogues réflexes. + + Pilotage par DialogHandler (OCR direct), PAS par pHash. Raison : + un dialog modal qui s'ouvre dans une VM ne change quasiment pas le + pHash global de l'écran hôte (signature 8x8 sur 1920x1080 — un dialog + de 800x500 couvre ~3 pixels pHash, distance Hamming souvent < 3). + On poll donc directement DialogHandler.handle_if_dialog(). + + Returns: + True si au moins un dialog connu a été détecté + géré et qu'aucun + autre dialog n'apparaît dans la fenêtre de stabilité finale. + False si aucun dialog connu n'apparaît dans la fenêtre d'attente + initiale (le workflow doit ABORT — état incohérent). + """ + from core.grounding.dialog_handler import DialogHandler + + # Fenêtre d'attente du PREMIER dialog après le raccourci. Win11/QEMU : + # Ctrl+Shift+S → "Enregistrer sous" apparaît en <2s typiquement. + first_dialog_timeout = 8.0 + # Budget total pour résoudre toute la cascade (InfiGUI ~15s/dialog). + total_timeout = 60.0 + # Fenêtre de stabilité après le dernier dialog géré : si rien d'autre + # n'apparaît pendant cette durée, la cascade est considérée terminée. + # Doit couvrir l'apparition du popup modal suivant (post_click_wait + marge). + stable_window = 3.0 + # Délai post-clic avant de tester le dialog suivant. + post_click_wait = 1.5 + # Cadence de polling OCR (EasyOCR full-screen ~500ms/poll). + poll_interval = 0.5 + # Garde-fou anti-boucle infinie. + max_dialog_iterations = 5 + + t_start = time.time() + dh = DialogHandler() + dialogs_handled = 0 + + def _elapsed() -> float: + return time.time() - t_start + + def _poll_dialog(deadline: float) -> Optional[Dict[str, Any]]: + """Poll DialogHandler jusqu'à détection d'un dialog connu OU deadline. + + Retourne le dict result si un dialog connu a été géré (cliqué), + None si la deadline est atteinte sans match. Si DialogHandler + détecte ET clique avec succès, le clic InfiGUI peut excéder la + deadline mais on retourne quand même le résultat (action déjà + engagée — on ne va pas l'annuler). + """ + while time.time() < deadline: + obs = self.observe() + try: + result = dh.handle_if_dialog(obs.screenshot) + except Exception as e: + print(f"⚠️ [ORA/post-shortcut] Erreur dialog handler: {e}") + return None + if result.get('handled'): + return result + sleep_left = deadline - time.time() + if sleep_left > 0: + time.sleep(min(poll_interval, sleep_left)) + return None + + # --- Étape 1 : attendre le PREMIER dialog --- + first_deadline = t_start + min(total_timeout, first_dialog_timeout) + result = _poll_dialog(first_deadline) + if result is None: + print(f"⏳ [ORA/post-shortcut] Aucun dialog connu détecté après " + f"{_elapsed():.1f}s (fenêtre={first_dialog_timeout}s) — " + f"raccourci sans effet attendu") + return False + + dialogs_handled = 1 + print(f"✅ [ORA/post-shortcut] Dialog #1 géré: {result.get('action')} " + f"({_elapsed():.1f}s)") + time.sleep(post_click_wait) + + # --- Étape 2 : cascade — chaque dialog suivant doit apparaître dans stable_window --- + for iteration in range(1, max_dialog_iterations): + if _elapsed() >= total_timeout: + print(f"⏳ [ORA/post-shortcut] Timeout cascade ({total_timeout:.0f}s, " + f"{dialogs_handled} dialog(s) géré(s))") + return True # au moins un dialog traité → considéré OK + + next_deadline = min(time.time() + stable_window, t_start + total_timeout) + result = _poll_dialog(next_deadline) + if result is None: + # Pas de nouveau dialog dans stable_window → cascade terminée + print(f"✅ [ORA/post-shortcut] Cascade résolue " + f"({dialogs_handled} dialog(s), {_elapsed():.1f}s)") + return True + + dialogs_handled += 1 + print(f"✅ [ORA/post-shortcut] Dialog #{dialogs_handled} géré: " + f"{result.get('action')} ({_elapsed():.1f}s)") + time.sleep(post_click_wait) + + print(f"⚠️ [ORA/post-shortcut] Trop d'itérations cascade " + f"({max_dialog_iterations}) — cascade malformée, on s'arrête là") + return dialogs_handled > 0 + def _act_click(self, decision: Decision, step_params: dict) -> bool: """Exécute un clic (simple, double, droit, hover, focus). @@ -1425,16 +1577,62 @@ Règles: anchor = step_params.get('visual_anchor', {}) screenshot_b64 = anchor.get('screenshot') bbox = anchor.get('bounding_box', {}) - target_text = anchor.get('target_text', '') or decision.target + # Utiliser le target nettoyé par reason_workflow_step (pas relire le garbage de l'ancre) + target_text = decision.target target_desc = anchor.get('description', '') + print(f"🎯 [ORA/_act_click] target='{target_text}', desc='{target_desc[:40]}', bbox={bbox.get('x','?')},{bbox.get('y','?')}") + x, y = None, None method_used = '' + # Score et position du template-first (réutilisés en fallback intermédiaire) + template_score = 0.0 + template_xy: Optional[tuple] = None - # --- Pipeline FAST→SMART→THINK --- + # --- AVANT-POSTE : template matching cv2 sur le crop d'ancre --- + # Si l'UI n'a pas changé (cas dominant en replay), un match pixel-perfect + # nous donne le clic en ~50ms sans toucher au GPU. On ne déclenche le + # pipeline VLM que si le score est insuffisant. + if screenshot_b64 and CV2_AVAILABLE and PIL_AVAILABLE and MSS_AVAILABLE: + try: + import io as _io + with mss_lib.mss() as sct: + mon = sct.monitors[0] + grab = sct.grab(mon) + screen_img = Image.frombytes('RGB', grab.size, grab.bgra, 'raw', 'BGRX') + + raw_b64 = screenshot_b64.split(',')[1] if ',' in screenshot_b64 else screenshot_b64 + anchor_data = base64.b64decode(raw_b64) + anchor_img = Image.open(_io.BytesIO(anchor_data)) + + screen_cv = cv2.cvtColor(np.array(screen_img), cv2.COLOR_RGB2BGR) + anchor_cv = cv2.cvtColor(np.array(anchor_img), cv2.COLOR_RGB2BGR) + + if anchor_cv.shape[0] < screen_cv.shape[0] and anchor_cv.shape[1] < screen_cv.shape[1]: + t0 = time.time() + result_tm = cv2.matchTemplate(screen_cv, anchor_cv, cv2.TM_CCOEFF_NORMED) + _, max_val, _, max_loc = cv2.minMaxLoc(result_tm) + elapsed_ms = (time.time() - t0) * 1000 + template_score = float(max_val) + template_xy = ( + max_loc[0] + anchor_cv.shape[1] // 2, + max_loc[1] + anchor_cv.shape[0] // 2, + ) + print(f"⚡ [ORA/template-first] score={template_score:.3f} pos={max_loc} ({elapsed_ms:.0f}ms)") + # Seuil élevé pour le mode "direct" : on veut être quasi-certain + # que c'est le même élément, pixel-perfect, avant de zapper le VLM. + if template_score >= 0.95: + x, y = template_xy + method_used = 'template_direct' + print(f"✅ [ORA/template-first] Match direct → ({x}, {y}), skip pipeline") + except Exception as e: + print(f"⚠️ [ORA/template-first] Erreur: {e}") + + # --- Pipeline FAST→SMART→THINK (escalade si template-first n'a pas tranché) --- _use_fast = os.environ.get('RPA_USE_FAST_PIPELINE', '1') == '1' - if _use_fast and (target_text or target_desc): + if x is None and _use_fast and (target_text or target_desc or screenshot_b64): + print(f"🎯 [ORA/_act_click] RPA_USE_FAST_PIPELINE={_use_fast}, has_target={bool(target_text or target_desc)}, template_score={template_score:.3f}") try: from core.grounding.fast_pipeline import FastSmartThinkPipeline from core.grounding.target import GroundingTarget @@ -1471,34 +1669,13 @@ Règles: except Exception as e: print(f"⚠️ [ORA/pipeline] Erreur: {e}") - # --- Fallback : ancien pipeline (template → OCR → static) --- - if x is None and screenshot_b64 and CV2_AVAILABLE and PIL_AVAILABLE and MSS_AVAILABLE: - try: - import io as _io - with mss_lib.mss() as sct: - mon = sct.monitors[0] - grab = sct.grab(mon) - screen_img = Image.frombytes('RGB', grab.size, grab.bgra, 'raw', 'BGRX') - - raw_b64 = screenshot_b64.split(',')[1] if ',' in screenshot_b64 else screenshot_b64 - anchor_data = base64.b64decode(raw_b64) - anchor_img = Image.open(_io.BytesIO(anchor_data)) - - screen_cv = cv2.cvtColor(np.array(screen_img), cv2.COLOR_RGB2BGR) - anchor_cv = cv2.cvtColor(np.array(anchor_img), cv2.COLOR_RGB2BGR) - - if anchor_cv.shape[0] < screen_cv.shape[0] and anchor_cv.shape[1] < screen_cv.shape[1]: - t0 = time.time() - result_tm = cv2.matchTemplate(screen_cv, anchor_cv, cv2.TM_CCOEFF_NORMED) - _, max_val, _, max_loc = cv2.minMaxLoc(result_tm) - elapsed_ms = (time.time() - t0) * 1000 - print(f"⚡ [ORA/template] score={max_val:.3f} pos={max_loc} ({elapsed_ms:.0f}ms)") - if max_val > 0.75: - x = max_loc[0] + anchor_cv.shape[1] // 2 - y = max_loc[1] + anchor_cv.shape[0] // 2 - method_used = 'template' - except Exception as e: - print(f"⚠️ [ORA/template] Erreur: {e}") + # --- Fallback : on réutilise le score template-first si pertinent --- + # Si le pipeline VLM a échoué mais que le template-first avait un score + # intermédiaire (0.75-0.95), on accepte ce match comme secours. + if x is None and template_xy is not None and template_score >= 0.75: + x, y = template_xy + method_used = 'template_fallback' + print(f"⚡ [ORA/template-fallback] Réutilisation score={template_score:.3f} → ({x}, {y})") if x is None and target_text: try: diff --git a/core/grounding/dialog_handler.py b/core/grounding/dialog_handler.py index 68dec53de..2f28d76e4 100644 --- a/core/grounding/dialog_handler.py +++ b/core/grounding/dialog_handler.py @@ -25,31 +25,42 @@ import time from typing import Any, Dict, Optional -# Titres connus → quelle action demander à InfiGUI +# Titres connus → quelle action demander à InfiGUI. +# +# IMPORTANT — ordre du dict = priorité de matching. +# L'OCR est full-screen et capte souvent le texte du dialog parent ET du popup +# modal qui apparaît par-dessus (ex: "Enregistrer sous" reste visible derrière +# "Confirmer l'enregistrement"). Les popups modaux DOIVENT matcher avant les +# fenêtres principales, sinon Léa clique sur le bouton du parent qui n'a pas +# le focus. KNOWN_DIALOGS = { - "enregistrer sous": {"target": "Enregistrer", "description": "Clique sur le bouton Enregistrer dans le dialogue Enregistrer sous"}, - "save as": {"target": "Save", "description": "Click the Save button in the Save As dialog"}, - "confirmer": {"target": "Oui", "description": "Clique sur le bouton Oui dans le dialogue de confirmation"}, + # ── Popups modaux de confirmation (priorité HAUTE) ────────────────── + "voulez-vous le remplacer": {"target": "Oui", "description": "Clique sur Oui pour confirmer le remplacement du fichier"}, + "do you want to replace": {"target": "Yes", "description": "Click Yes to confirm file replacement"}, + "existe déjà": {"target": "Oui", "description": "Clique sur Oui, le fichier existe déjà et doit être remplacé"}, + "already exists": {"target": "Yes", "description": "Click Yes, the file already exists"}, "remplacer": {"target": "Oui", "description": "Clique sur le bouton Oui pour confirmer le remplacement du fichier"}, "replace": {"target": "Yes", "description": "Click Yes to confirm file replacement"}, - "voulez-vous enregistrer": {"target": "Enregistrer", "description": "Clique sur Enregistrer pour sauvegarder les modifications"}, - "do you want to save": {"target": "Save", "description": "Click Save to save changes"}, - "overwrite": {"target": "Yes", "description": "Click Yes to overwrite"}, "écraser": {"target": "Oui", "description": "Clique sur Oui pour écraser le fichier"}, - "already exists": {"target": "Yes", "description": "Click Yes, the file already exists"}, - "existe déjà": {"target": "Oui", "description": "Clique sur Oui, le fichier existe déjà"}, + "overwrite": {"target": "Yes", "description": "Click Yes to overwrite"}, + "confirmer l'enregistrement": {"target": "Oui", "description": "Clique sur Oui dans le popup de confirmation d'enregistrement"}, + "confirmer": {"target": "Oui", "description": "Clique sur le bouton Oui dans le dialogue de confirmation"}, + # ── Avertissements/erreurs (priorité haute, 1 seul bouton OK) ─────── "erreur": {"target": "OK", "description": "Clique sur OK pour fermer le message d'erreur"}, "error": {"target": "OK", "description": "Click OK to close the error message"}, "avertissement": {"target": "OK", "description": "Clique sur OK pour fermer l'avertissement"}, "warning": {"target": "OK", "description": "Click OK to close the warning"}, + # ── Dialogs principaux de sauvegarde (priorité BASSE — fenêtres parents) ─ + "voulez-vous enregistrer": {"target": "Enregistrer", "description": "Clique sur Enregistrer pour sauvegarder les modifications"}, + "do you want to save": {"target": "Save", "description": "Click Save to save changes"}, + "enregistrer sous": {"target": "Enregistrer", "description": "Clique sur le bouton Enregistrer dans le dialogue Enregistrer sous"}, + "save as": {"target": "Save", "description": "Click the Save button in the Save As dialog"}, } class DialogHandler: """Gestion intelligente des dialogues via titre + InfiGUI.""" - GROUNDING_URL = "http://localhost:8200" - def __init__(self): self._easyocr_reader = None @@ -169,29 +180,21 @@ class DialogHandler: def _click_via_infigui( self, target: str, description: str, screenshot_pil ) -> Optional[Dict]: - """Demande à InfiGUI de localiser et cliquer sur le bouton.""" + """Demande à InfiGUI (subprocess one-shot) de localiser et cliquer sur le bouton.""" try: - import requests - import base64 - import io + from core.grounding.ui_tars_grounder import UITarsGrounder - buf = io.BytesIO() - screenshot_pil.save(buf, format='JPEG', quality=85) - b64 = base64.b64encode(buf.getvalue()).decode() + grounder = UITarsGrounder.get_instance() + result = grounder.ground( + target_text=target, + target_description=description, + screen_pil=screenshot_pil, + ) - resp = requests.post(f"{self.GROUNDING_URL}/ground", json={ - 'target_text': target, - 'target_description': description, - 'image_b64': b64, - }, timeout=15) - - if resp.status_code == 200: - data = resp.json() - if data.get('x') is not None: - # Cliquer - import pyautogui - pyautogui.click(data['x'], data['y']) - return data + if result and result.x is not None: + import pyautogui + pyautogui.click(result.x, result.y) + return {'x': result.x, 'y': result.y} return None diff --git a/core/grounding/infigui_worker.py b/core/grounding/infigui_worker.py index a6fe1f629..f69a9c4a1 100644 --- a/core/grounding/infigui_worker.py +++ b/core/grounding/infigui_worker.py @@ -61,7 +61,14 @@ def load_model(): def infer(model, processor, req): - """Fait une inférence.""" + """Fait une inférence. + + Modes : + - texte seul (target/description) : grounding classique + - fusionné (anchor_image_path présent) : on passe en plus le crop d'ancre + comme image de référence et le modèle doit retrouver cet élément sur + le screenshot. Évite la double passe describe→ground. + """ from PIL import Image from qwen_vl_utils import process_vision_info @@ -69,10 +76,7 @@ def infer(model, processor, req): description = req.get("description", "") label = f"{target} — {description}" if description else target - if not label.strip(): - return {"x": None, "y": None, "error": "target requis"} - - # Image + # Image principale (screenshot complet) image_path = req.get("image_path", "") if image_path and os.path.exists(image_path): img = Image.open(image_path).convert("RGB") @@ -82,6 +86,15 @@ def infer(model, processor, req): grab = sct.grab(sct.monitors[0]) img = Image.frombytes("RGB", grab.size, grab.bgra, "raw", "BGRX") + # Image d'ancre (optionnelle) — mode fusionné describe+ground + anchor_image_path = req.get("anchor_image_path", "") + anchor_img = None + if anchor_image_path and os.path.exists(anchor_image_path): + anchor_img = Image.open(anchor_image_path).convert("RGB") + + if not label.strip() and anchor_img is None: + return {"x": None, "y": None, "error": "target ou anchor_image requis"} + W, H = img.size factor = 28 rH = max(factor, round(H / factor) * factor) @@ -92,20 +105,41 @@ def infer(model, processor, req): "and then provide the final answer.\n" "The reasoning process MUST BE enclosed within tags." ) - user_text = ( - f'The screen\'s resolution is {rW}x{rH}.\n' - f'Locate the UI element(s) for "{label}", ' - f'output the coordinates using JSON format: ' - f'[{{"point_2d": [x, y]}}, ...]' - ) - messages = [ - {"role": "system", "content": system}, - {"role": "user", "content": [ - {"type": "image", "image": img}, - {"type": "text", "text": user_text}, - ]}, - ] + # Construction du prompt selon le mode + if anchor_img is not None: + # Mode fusionné : Image1 = crop d'ancre, Image2 = screenshot + hint = f' Hint: this element looks like "{label}".' if label.strip() else "" + user_text = ( + f"The first image is a small crop of a UI element captured previously. " + f"The second image is the current screen ({rW}x{rH}).{hint}\n" + f"Locate on the second image the UI element that visually matches the first image. " + f"Output the coordinates using JSON format: " + f'[{{"point_2d": [x, y]}}, ...]' + ) + messages = [ + {"role": "system", "content": system}, + {"role": "user", "content": [ + {"type": "image", "image": anchor_img}, + {"type": "image", "image": img}, + {"type": "text", "text": user_text}, + ]}, + ] + else: + # Mode classique : texte seul + user_text = ( + f'The screen\'s resolution is {rW}x{rH}.\n' + f'Locate the UI element(s) for "{label}", ' + f'output the coordinates using JSON format: ' + f'[{{"point_2d": [x, y]}}, ...]' + ) + messages = [ + {"role": "system", "content": system}, + {"role": "user", "content": [ + {"type": "image", "image": img}, + {"type": "text", "text": user_text}, + ]}, + ] text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) image_inputs, video_inputs = process_vision_info(messages) @@ -124,7 +158,8 @@ def infer(model, processor, req): trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False, )[0].strip() - print(f"[infigui-worker] '{label[:40]}' ({infer_ms:.0f}ms)") + mode_str = "fused" if anchor_img is not None else "text" + print(f"[infigui-worker] [{mode_str}] '{label[:40]}' ({infer_ms:.0f}ms)") # Parser JSON point_2d json_part = raw.split("")[-1] if "" in raw else raw @@ -153,34 +188,22 @@ def infer(model, processor, req): def main(): + """Mode one-shot : lit une requête sur stdin, infère, écrit le résultat sur stdout.""" + # Lire la requête + input_data = sys.stdin.read().strip() + if not input_data: + print(json.dumps({"x": None, "y": None, "error": "pas de requête"})) + return + + try: + req = json.loads(input_data) + except json.JSONDecodeError: + print(json.dumps({"x": None, "y": None, "error": "JSON invalide"})) + return + model, processor = load_model() - - # Nettoyer les fichiers résiduels - for f in [REQUEST_FILE, RESPONSE_FILE]: - if os.path.exists(f): - os.unlink(f) - - print(f"[infigui-worker] En attente de requêtes ({REQUEST_FILE})") - - # Boucle : surveiller le fichier de requête - while True: - if os.path.exists(REQUEST_FILE): - try: - with open(REQUEST_FILE, "r") as f: - req = json.load(f) - os.unlink(REQUEST_FILE) - - result = infer(model, processor, req) - - with open(RESPONSE_FILE, "w") as f: - json.dump(result, f) - - except Exception as e: - print(f"[infigui-worker] ERREUR: {e}") - with open(RESPONSE_FILE, "w") as f: - json.dump({"x": None, "y": None, "error": str(e)}, f) - - time.sleep(0.05) # 50ms polling + result = infer(model, processor, req) + print(json.dumps(result)) if __name__ == "__main__": diff --git a/core/grounding/think_arbiter.py b/core/grounding/think_arbiter.py index ee47fab95..0862ce8c6 100644 --- a/core/grounding/think_arbiter.py +++ b/core/grounding/think_arbiter.py @@ -1,50 +1,41 @@ """ -core/grounding/think_arbiter.py — Layer THINK : VLM arbitre (UI-TARS) +core/grounding/think_arbiter.py — Layer THINK : VLM arbitre (InfiGUI via subprocess) -Appelé UNIQUEMENT quand le SmartMatcher n'a pas assez confiance : -- Score < 0.60 : aucun candidat clair → UI-TARS cherche dans tout l'écran -- Score 0.60-0.90 : candidats ambigus → UI-TARS confirme/infirme - -Le VLM tourne dans un process séparé (serveur FastAPI port 8200). -Ce module est un CLIENT HTTP — il ne charge aucun modèle en VRAM. +Appelé UNIQUEMENT quand le SmartMatcher n'a pas assez confiance. +Utilise le subprocess worker InfiGUI (pas de serveur HTTP). Utilisation : from core.grounding.think_arbiter import ThinkArbiter arbiter = ThinkArbiter() - if arbiter.available: - result = arbiter.arbitrate(target, candidates, screenshot) + result = arbiter.arbitrate(target, candidates, screenshot) """ from __future__ import annotations -import base64 -import io import time from typing import Any, Dict, List, Optional -from core.grounding.fast_types import DetectedUIElement, LocateResult, MatchCandidate +from core.grounding.fast_types import LocateResult, MatchCandidate from core.grounding.target import GroundingTarget class ThinkArbiter: - """Arbitre VLM pour les cas ambigus — appelle le serveur UI-TARS.""" + """Arbitre VLM — appelle InfiGUI via subprocess worker.""" - DEFAULT_URL = "http://localhost:8200" + def __init__(self): + self._grounder = None - def __init__(self, server_url: str = DEFAULT_URL, timeout: int = 30): - self.server_url = server_url - self.timeout = timeout + def _get_grounder(self): + if self._grounder is None: + from core.grounding.ui_tars_grounder import UITarsGrounder + self._grounder = UITarsGrounder.get_instance() + return self._grounder @property def available(self) -> bool: - """Vérifie si le serveur de grounding est accessible.""" - try: - import requests - resp = requests.get(f"{self.server_url}/health", timeout=3) - return resp.status_code == 200 and resp.json().get("model_loaded", False) - except Exception: - return False + """Toujours disponible — le worker se lance à la demande.""" + return True def arbitrate( self, @@ -54,62 +45,57 @@ class ThinkArbiter: ) -> Optional[LocateResult]: """Demande au VLM de trancher. - Args: - target: Ce qu'on cherche. - candidates: Candidats SMART (peut être vide). - screenshot_pil: Screenshot PIL. Si None, le serveur capture lui-même. - - Returns: - LocateResult ou None si le VLM ne trouve pas non plus. + Si target.template_b64 est fourni, on bascule en mode fusionné : + le crop est passé comme image de référence à InfiGUI, ce qui évite + une description Ollama qwen2.5vl coûteuse en VRAM. """ t0 = time.time() + # Décodage du crop d'ancre si disponible (mode fusionné) + anchor_pil = None + if target.template_b64: + try: + import base64 + import io + from PIL import Image + + raw_b64 = target.template_b64 + if ',' in raw_b64: + raw_b64 = raw_b64.split(',', 1)[1] + anchor_pil = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGB") + except Exception as ex: + print(f"⚠️ [THINK] Décodage anchor échoué: {ex}") + anchor_pil = None + try: - import requests - - # Construire le payload - payload: Dict[str, Any] = { - "target_text": target.text or "", - "target_description": target.description or "", - } - - # Envoyer l'image si disponible - if screenshot_pil is not None: - buf = io.BytesIO() - screenshot_pil.save(buf, format="JPEG", quality=85) - payload["image_b64"] = base64.b64encode(buf.getvalue()).decode("utf-8") - - # Appel au serveur - resp = requests.post( - f"{self.server_url}/ground", - json=payload, - timeout=self.timeout, + grounder = self._get_grounder() + result = grounder.ground( + target_text=target.text or "", + target_description=target.description or "", + screen_pil=screenshot_pil, + anchor_pil=anchor_pil, ) dt = (time.time() - t0) * 1000 - if resp.status_code != 200: - print(f"🤔 [THINK] Serveur HTTP {resp.status_code}") + if result is None: + label = target.text or "" + print(f"🤔 [THINK] VLM n'a pas trouvé '{label}' ({dt:.0f}ms)") return None - data = resp.json() - - if data.get("x") is None: - print(f"🤔 [THINK] VLM n'a pas trouvé '{target.text}' ({dt:.0f}ms)") - return None - - result = LocateResult( - x=data["x"], - y=data["y"], - confidence=data.get("confidence", 0.85), - method="think_vlm", + method = "think_vlm_fused" if anchor_pil is not None else "think_vlm" + locate = LocateResult( + x=result.x, + y=result.y, + confidence=result.confidence, + method=method, time_ms=dt, tier="think", candidates_count=len(candidates), ) - print(f"🤔 [THINK] VLM → ({result.x}, {result.y}) conf={result.confidence:.2f} ({dt:.0f}ms)") - return result + print(f"🤔 [THINK/{method}] ({result.x}, {result.y}) conf={result.confidence:.2f} ({dt:.0f}ms)") + return locate except Exception as ex: dt = (time.time() - t0) * 1000 diff --git a/core/grounding/ui_tars_grounder.py b/core/grounding/ui_tars_grounder.py index d5bc24eb8..439762e2c 100644 --- a/core/grounding/ui_tars_grounder.py +++ b/core/grounding/ui_tars_grounder.py @@ -1,21 +1,18 @@ """ -core/grounding/ui_tars_grounder.py — Grounding via worker InfiGUI indépendant +core/grounding/ui_tars_grounder.py — Grounding via script one-shot InfiGUI -Communication par fichiers : - - Écrit la requête dans /tmp/infigui_request.json - - Le worker lit, infère, écrit la réponse dans /tmp/infigui_response.json - - Le grounder lit la réponse - -Le worker est un process indépendant lancé par start_grounding_worker.sh, -PAS un subprocess de Flask. +Chaque appel lance un subprocess Python qui charge le modèle, infère, et quitte. +Lent (~15s) mais fiable — pas de crash CUDA en process persistant. """ from __future__ import annotations import json import os -import time +import subprocess +import sys import threading +import time from typing import Optional from core.grounding.target import GroundingResult @@ -23,16 +20,15 @@ from core.grounding.target import GroundingResult _instance: Optional[UITarsGrounder] = None _instance_lock = threading.Lock() -REQUEST_FILE = "/tmp/infigui_request.json" -RESPONSE_FILE = "/tmp/infigui_response.json" -READY_FILE = "/tmp/infigui_ready" - class UITarsGrounder: - """Grounding via worker InfiGUI indépendant — communication par fichiers.""" + """Grounding via script one-shot InfiGUI.""" def __init__(self): self._lock = threading.Lock() + self._project_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..") + ) @classmethod def get_instance(cls) -> UITarsGrounder: @@ -45,68 +41,111 @@ class UITarsGrounder: @property def available(self) -> bool: - return os.path.exists(READY_FILE) + return True # Toujours disponible — le script se lance à la demande def ground( self, target_text: str = "", target_description: str = "", screen_pil=None, + anchor_pil=None, ) -> Optional[GroundingResult]: - """Localise un élément UI via le worker InfiGUI.""" - if not self.available: - print("[InfiGUI] Worker non démarré (pas de /tmp/infigui_ready)") - return None + """Localise un élément UI via un script one-shot InfiGUI. + Args: + target_text: nom textuel de la cible (peut être vide si anchor_pil fourni). + target_description: description sémantique libre. + screen_pil: screenshot complet (PIL.Image). + anchor_pil: crop visuel de l'ancre capturée précédemment (PIL.Image). + Si fourni, le worker passe en mode fusionné : Image1=crop, Image2=screen, + "trouve sur l'image 2 l'élément visuel de l'image 1". + """ t0 = time.time() try: with self._lock: - # Sauver l'image si fournie - image_path = "" + # Sauver l'image principale + image_path = "/tmp/infigui_screen.png" if screen_pil is not None: - image_path = "/tmp/infigui_screen.png" screen_pil.save(image_path) - # Écrire la requête - req = { + # Sauver l'image d'ancre (mode fusionné) + anchor_image_path = "" + if anchor_pil is not None: + anchor_image_path = "/tmp/infigui_anchor.png" + anchor_pil.save(anchor_image_path) + + # Construire la requête JSON + req = json.dumps({ "target": target_text, "description": target_description, "image_path": image_path, - "timestamp": time.time(), - } + "anchor_image_path": anchor_image_path, + }) - # Supprimer l'ancienne réponse - if os.path.exists(RESPONSE_FILE): - os.unlink(RESPONSE_FILE) + mode_str = "fused" if anchor_pil is not None else "text" + label_short = target_text[:30] if target_text else "" + print(f"🎯 [InfiGUI] Lancement one-shot [{mode_str}]: '{label_short}'") - # Écrire la requête - with open(REQUEST_FILE, "w") as f: - json.dump(req, f) + # Lancer le script one-shot + # IMPORTANT: depuis un service systemd où le parent a déjà chargé CUDA, + # le subprocess hérite d'un état GPU cassé (No CUDA GPUs available). + # Solutions : start_new_session=True (nouveau cgroup) + forcer + # CUDA_VISIBLE_DEVICES=0 explicitement pour bypass l'héritage parent. + _child_env = {**os.environ} + _child_env["PYTHONDONTWRITEBYTECODE"] = "1" + _child_env["CUDA_VISIBLE_DEVICES"] = "0" + _child_env["NVIDIA_VISIBLE_DEVICES"] = "all" + # Supprimer les variables Python qui pourraient pointer sur l'état parent + _child_env.pop("PYTORCH_NVML_BASED_CUDA_CHECK", None) - # Attendre la réponse (max 30s) - for _ in range(300): - if os.path.exists(RESPONSE_FILE): - time.sleep(0.05) # Laisser le fichier se fermer - try: - with open(RESPONSE_FILE, "r") as f: - data = json.load(f) - os.unlink(RESPONSE_FILE) - break - except (json.JSONDecodeError, IOError): - continue - time.sleep(0.1) - else: - print(f"⚠️ [InfiGUI] Timeout 30s — worker ne répond pas") + result = subprocess.run( + [sys.executable, "-m", "core.grounding.infigui_worker"], + input=req + "\n", + capture_output=True, + text=True, + timeout=60, + cwd=self._project_root, + env=_child_env, + start_new_session=True, # nouveau session group, isole du parent + close_fds=True, + ) + + if result.returncode != 0: + stderr_lines = (result.stderr or '').strip().split('\n') + # Afficher les dernières lignes significatives du stderr + last_err = [l for l in stderr_lines[-5:] if l.strip()] + print(f"⚠️ [InfiGUI] Script échoué (code {result.returncode})") + for l in last_err: + print(f" ❌ {l}") + return None + + # Parser la sortie — chercher la ligne JSON de résultat + data = None + for line in result.stdout.strip().split("\n"): + line = line.strip() + if not line: + continue + try: + parsed = json.loads(line) + if "x" in parsed: + data = parsed + except json.JSONDecodeError: + continue + + if data is None: + print(f"⚠️ [InfiGUI] Pas de réponse JSON dans la sortie") return None dt = (time.time() - t0) * 1000 if data.get("x") is not None: - print(f"🎯 [InfiGUI] ({data['x']}, {data['y']}) conf={data.get('confidence', 0):.2f} ({dt:.0f}ms)") + method_name = "infigui_fused" if anchor_pil is not None else "infigui" + print(f"🎯 [InfiGUI/{method_name}] ({data['x']}, {data['y']}) " + f"conf={data.get('confidence', 0):.2f} ({dt:.0f}ms)") return GroundingResult( x=data["x"], y=data["y"], - method="infigui", + method=method_name, confidence=data.get("confidence", 0.90), time_ms=dt, ) @@ -114,6 +153,9 @@ class UITarsGrounder: print(f"⚠️ [InfiGUI] Pas trouvé ({dt:.0f}ms)") return None + except subprocess.TimeoutExpired: + print(f"⚠️ [InfiGUI] Timeout 60s") + return None except Exception as e: print(f"⚠️ [InfiGUI] Erreur: {e}") return None diff --git a/visual_workflow_builder/backend/api_v3/execute.py b/visual_workflow_builder/backend/api_v3/execute.py index 57167a877..ac908fd30 100644 --- a/visual_workflow_builder/backend/api_v3/execute.py +++ b/visual_workflow_builder/backend/api_v3/execute.py @@ -896,15 +896,15 @@ def execute_action(action_type: str, params: dict) -> dict: _fc_target_text = params.get('_step_label', '') _action_types = {'click_anchor', 'double_click_anchor', 'right_click_anchor', 'hover_anchor', 'focus_anchor', 'scroll_to_anchor'} - if _fc_target_text in _action_types and screenshot_base64: - try: - from core.execution.input_handler import _describe_anchor_image - _desc = _describe_anchor_image(screenshot_base64) - if _desc: - print(f"🏷️ [Vision] Ancre décrite: '{_desc}'") - _fc_target_text = _desc - except Exception: - pass + # Note: plus d'appel à _describe_anchor_image() (qwen2.5vl) ici. + # Le crop d'ancre (screenshot_base64) est utilisé directement par + # le template matching pixel-perfect en avant-poste, puis InfiGUI + # en mode fusionné si nécessaire (option 2.c+2.a). Économise ~9.4 GB + # de VRAM Ollama qui rentrait en conflit avec InfiGUI. + if _fc_target_text in _action_types: + # Marquer le label comme garbage pour que le pipeline + # bascule sur le mode fusionné via template_b64. + _fc_target_text = '' _fc_target_desc = params.get('visual_anchor', {}).get('description', '') x, y, confidence, method_used = None, None, 0, ''