fix(grounding): OCR collecte TOUS les matchs + choisit le plus proche de l'ancre
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 14s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped

Avant : OCR retournait le premier match → cliquait sur la barre de titre
("CR_patient_demo" dans le path) au lieu du fichier dans la liste.

Après : collecte tous les matchs, choisit le plus proche de la position
originale de l'ancre (anchor_bbox). Si pas de bbox, prend le plus central.

Élimine les clics sur les barres de titre, breadcrumbs, menus.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-21 16:40:15 +02:00
parent b3eab83a0f
commit 6ab385d671
2 changed files with 47 additions and 32 deletions

View File

@@ -327,6 +327,7 @@ def find_element_on_screen(
target_text: str,
target_description: str = "",
anchor_image_base64: Optional[str] = None,
anchor_bbox: Optional[Dict] = None,
) -> Optional[Dict[str, Any]]:
"""
Cherche un élément sur l'écran en utilisant 3 méthodes en cascade.
@@ -339,6 +340,7 @@ def find_element_on_screen(
target_text: Texte de l'élément à trouver (ex: "Demo", "Enregistrer")
target_description: Description plus longue (ex: "le dossier Demo sur le bureau")
anchor_image_base64: Image de référence de l'ancre (pour CLIP matching, réservé futur)
anchor_bbox: Position originale de l'ancre (pour désambiguïser les matchs multiples)
Returns:
{'x': int, 'y': int, 'method': str, 'confidence': float} ou None
@@ -365,7 +367,7 @@ def find_element_on_screen(
logger.info(f"[Grounding] Recherche élément: '{search_label}' (cascade 3 niveaux)")
# ─── Niveau 1 — OCR (rapide, ~1s) ───
result = _grounding_ocr(target_text)
result = _grounding_ocr(target_text, anchor_bbox=anchor_bbox)
if result:
return result
@@ -441,8 +443,13 @@ def _capture_screen():
return None, 0, 0
def _grounding_ocr(target_text: str) -> Optional[Dict[str, Any]]:
"""Niveau 1 — Cherche le texte par OCR (docTR). ~1s."""
def _grounding_ocr(target_text: str, anchor_bbox: Optional[Dict] = None) -> Optional[Dict[str, Any]]:
"""Niveau 1 — Cherche le texte par OCR (docTR). ~1s.
Collecte TOUS les matchs et choisit le plus pertinent :
- Si anchor_bbox fourni → le plus proche de la position originale
- Sinon → le plus proche du centre de l'écran (zone contenu)
"""
if not target_text:
return None
@@ -451,7 +458,6 @@ def _grounding_ocr(target_text: str) -> Optional[Dict[str, Any]]:
if screen is None:
return None
# Importer OCR (essayer les deux chemins)
try:
from services.ocr_service import ocr_extract_words
except ImportError:
@@ -466,42 +472,50 @@ def _grounding_ocr(target_text: str) -> Optional[Dict[str, Any]]:
return None
target_lower = target_text.lower()
all_matches = []
# Matching exact insensible à la casse
for word in words:
if word['text'].lower() == target_lower:
x1, y1, x2, y2 = word['bbox']
x = int((x1 + x2) / 2)
y = int((y1 + y2) / 2)
logger.info(f"[Grounding/OCR] Trouvé '{word['text']}' à ({x}, {y}) — match exact")
return {'x': x, 'y': y, 'method': 'ocr', 'confidence': 0.95}
# Matching partiel (mot coupé : "nregistrer" pour "Enregistrer")
# Collecter tous les matchs
for word in words:
word_lower = word['text'].lower()
if len(word_lower) < 3 or len(target_lower) < 3:
continue
# Le mot OCR contient le target (ou l'inverse)
if target_lower in word_lower or word_lower in target_lower:
x1, y1, x2, y2 = word['bbox']
x = int((x1 + x2) / 2)
y = int((y1 + y2) / 2)
logger.info(f"[Grounding/OCR] Trouvé '{word['text']}' à ({x}, {y}) — match partiel")
return {'x': x, 'y': y, 'method': 'ocr', 'confidence': 0.80}
x1, y1, x2, y2 = word['bbox']
cx, cy = int((x1 + x2) / 2), int((y1 + y2) / 2)
# Matching partiel lettre initiale manquante (soulignée ou coupée)
if len(target_lower) > 3:
if word_lower == target_lower:
all_matches.append({'text': word['text'], 'x': cx, 'y': cy, 'type': 'exact', 'conf': 0.95})
elif len(word_lower) >= 3 and len(target_lower) >= 3:
if target_lower in word_lower or word_lower in target_lower:
all_matches.append({'text': word['text'], 'x': cx, 'y': cy, 'type': 'partial', 'conf': 0.80})
# Matching lettre initiale manquante
if not all_matches and len(target_lower) > 3:
partial = target_lower[1:]
for word in words:
if partial in word['text'].lower():
x1, y1, x2, y2 = word['bbox']
x = int((x1 + x2) / 2)
y = int((y1 + y2) / 2)
logger.info(f"[Grounding/OCR] Trouvé '{word['text']}' à ({x}, {y}) — match partiel (lettre initiale manquante)")
return {'x': x, 'y': y, 'method': 'ocr', 'confidence': 0.70}
all_matches.append({'text': word['text'], 'x': int((x1+x2)/2), 'y': int((y1+y2)/2), 'type': 'partial_cut', 'conf': 0.70})
logger.debug(f"[Grounding/OCR] '{target_text}' non trouvé parmi {len(words)} mots")
return None
if not all_matches:
logger.debug(f"[Grounding/OCR] '{target_text}' non trouvé parmi {len(words)} mots")
return None
# Choisir le meilleur match
if len(all_matches) == 1:
best = all_matches[0]
elif anchor_bbox:
# Prendre le plus proche de la position originale de l'ancre
orig_x = anchor_bbox.get('x', 0) + anchor_bbox.get('width', 0) / 2
orig_y = anchor_bbox.get('y', 0) + anchor_bbox.get('height', 0) / 2
best = min(all_matches, key=lambda m: ((m['x'] - orig_x)**2 + (m['y'] - orig_y)**2))
else:
# Prendre le plus central (zone contenu, pas les barres de titre)
center_x, center_y = screen_w / 2, screen_h / 2
best = min(all_matches, key=lambda m: ((m['x'] - center_x)**2 + (m['y'] - center_y)**2))
for m in all_matches:
sel = " ← CHOISI" if m is best else ""
logger.info(f" [OCR] Candidat: '{m['text']}' à ({m['x']}, {m['y']}) [{m['type']}]{sel}")
return {'x': best['x'], 'y': best['y'], 'method': 'ocr', 'confidence': best['conf']}
except Exception as e:
logger.debug(f"[Grounding/OCR] Erreur: {e}")