Files
rpa_vision_v3/core/grounding/title_verifier.py
Dom 343d6fbe95 perf(ocr): EasyOCR remplace docTR dans FastDetector + TitleVerifier
FastDetector : EasyOCR GPU en singleton (~192ms vs 1300ms docTR = 6.8x)
- "Corbeille" lu correctement (docTR lisait "Gorbeille")
- "Google Chrome" en deux mots propres
- Détection complète (RF-DETR + OCR) en 313ms à chaud
- Fallback docTR si EasyOCR non disponible

TitleVerifier : EasyOCR pour le crop titre (fallback docTR)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 03:32:43 +02:00

175 lines
6.0 KiB
Python

"""
core/grounding/title_verifier.py — Vérification post-action par titre de fenêtre
Après chaque action (clic, double-clic), vérifie que la fenêtre active
a changé de manière attendue en lisant le titre via OCR sur un crop
de 45px en haut de l'écran.
Léger (~120ms), non-bloquant (échec = warning + retry, pas stop).
Utilisation :
from core.grounding.title_verifier import TitleVerifier
verifier = TitleVerifier()
title = verifier.read_title(screenshot_pil)
changed = verifier.has_title_changed(title_before, title_after)
"""
from __future__ import annotations
import time
from difflib import SequenceMatcher
from typing import Optional
class TitleVerifier:
"""Vérifie le titre de la fenêtre active via OCR sur crop."""
# Hauteur du crop pour la barre de titre Windows
TITLE_BAR_HEIGHT = 45
def __init__(self):
self._ocr_fn = None # Lazy load
def read_title(self, screenshot_pil) -> str:
"""Lit le titre de la fenêtre active via OCR sur le crop supérieur.
Args:
screenshot_pil: Image PIL du screenshot complet.
Returns:
Texte du titre (peut être vide si OCR échoue).
"""
t0 = time.time()
try:
w, h = screenshot_pil.size
# Crop la barre de titre (45px du haut)
title_crop = screenshot_pil.crop((0, 0, w, min(self.TITLE_BAR_HEIGHT, h)))
# OCR sur le petit crop
ocr_fn = self._get_ocr()
if ocr_fn is None:
return ""
text = ocr_fn(title_crop)
dt = (time.time() - t0) * 1000
# Nettoyer le texte
title = text.strip() if text else ""
if title:
print(f"📋 [TitleVerify] Titre lu: '{title[:60]}' ({dt:.0f}ms)")
return title
except Exception as e:
print(f"⚠️ [TitleVerify] Erreur lecture titre: {e}")
return ""
def has_title_changed(self, title_before: str, title_after: str) -> bool:
"""Vérifie si le titre a changé de manière significative."""
if not title_before and not title_after:
return False
if not title_before or not title_after:
return True # Un des deux est vide = changement
# Comparaison fuzzy — les titres peuvent avoir des variations mineures
ratio = SequenceMatcher(None, title_before.lower(), title_after.lower()).ratio()
return ratio < 0.85 # Changement si < 85% similaire
def verify_action(
self,
screenshot_before,
screenshot_after,
action_type: str,
) -> dict:
"""Vérifie qu'une action a produit l'effet attendu sur le titre.
Args:
screenshot_before: Screenshot PIL avant l'action.
screenshot_after: Screenshot PIL après l'action.
action_type: Type d'action ("double_click", "click", "type", "hotkey").
Returns:
Dict avec success, title_before, title_after, changed.
"""
# Les actions qui ne changent pas le titre
if action_type in ('type_text', 'keyboard_shortcut', 'wait_for_anchor', 'hover'):
return {
'success': True,
'title_before': '',
'title_after': '',
'changed': False,
'reason': f"Action '{action_type}' — vérification titre non requise",
}
title_before = self.read_title(screenshot_before)
title_after = self.read_title(screenshot_after)
changed = self.has_title_changed(title_before, title_after)
# Pour un double-clic (ouverture fichier/dossier), le titre DOIT changer
# Mais seulement si les titres lus sont significatifs (> 3 chars)
# docTR sur un crop 45px dans une VM peut donner du bruit ('o', 'a', etc.)
if action_type in ('double_click_anchor',) and not changed:
if len(title_before) > 3 and len(title_after) > 3:
return {
'success': False,
'title_before': title_before,
'title_after': title_after,
'changed': False,
'reason': f"Double-clic sans changement de titre ('{title_after[:40]}')",
}
# Titres trop courts = bruit OCR, on ne peut pas conclure
return {
'success': True,
'title_before': title_before,
'title_after': title_after,
'changed': False,
'reason': f"Titre trop court pour vérifier ('{title_after}')",
}
# Pour un clic simple, le changement est optionnel
return {
'success': True,
'title_before': title_before,
'title_after': title_after,
'changed': changed,
'reason': 'Titre changé' if changed else 'Titre identique (acceptable)',
}
_easyocr_reader = None # Singleton partagé
def _get_ocr(self):
"""Lazy load de la fonction OCR (EasyOCR prioritaire, fallback docTR)."""
if self._ocr_fn is not None:
return self._ocr_fn
# EasyOCR (rapide, bonne qualité GUI)
try:
import easyocr
import numpy as np
if TitleVerifier._easyocr_reader is None:
TitleVerifier._easyocr_reader = easyocr.Reader(
['fr', 'en'], gpu=True, verbose=False
)
def _easyocr_extract_text(img):
results = TitleVerifier._easyocr_reader.readtext(np.array(img))
return ' '.join(r[1] for r in results if r[1].strip())
self._ocr_fn = _easyocr_extract_text
return self._ocr_fn
except ImportError:
pass
# Fallback docTR
try:
import sys
sys.path.insert(0, 'visual_workflow_builder/backend')
from services.ocr_service import ocr_extract_text
self._ocr_fn = ocr_extract_text
return self._ocr_fn
except ImportError:
return None