feat(execution): cascade post-raccourci pilotée par DialogHandler/OCR

Le pHash global 8x8 sur écran 1920x1080 ne détecte pas l'ouverture d'un
dialog modal dans une VM QEMU (un dialog 800x500 couvre ~3 pixels pHash,
distance Hamming typique = 1-2, sous le seuil de 3). Découvert sur Win11/
Notepad : Ctrl+Shift+S ouvrait bien le dialog mais Léa abortait à tort.

_handle_post_shortcut() poll désormais DialogHandler.handle_if_dialog()
toutes les 500ms (EasyOCR + KNOWN_DIALOGS). 8s pour le premier dialog,
3s de stabilité entre dialogs successifs, 60s budget total.

KNOWN_DIALOGS réordonné : popups modaux (confirmer/remplacer/écraser)
prioritaires sur fenêtres parents (enregistrer sous/save as) car l'OCR
full-screen capte les deux simultanément.

DialogHandler bascule sur UITarsGrounder subprocess one-shot (au lieu
du serveur HTTP localhost:8200 qui n'existait plus). InfiGUI worker,
think_arbiter et ui_tars_grounder alignés sur le même contrat.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-26 20:19:39 +02:00
parent 3d6868f029
commit 487bcb8618
6 changed files with 474 additions and 243 deletions

View File

@@ -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

View File

@@ -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 <think> </think> 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("</think>")[-1] if "</think>" 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__":

View File

@@ -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 "<crop>"
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

View File

@@ -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 "<crop only>"
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