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>
175 lines
6.0 KiB
Python
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
|