Files
rpa_vision_v3/core/pipeline/screen_analyzer.py
Dom 9ca277a63f refactor(pipeline): ScreenAnalyzer thread-safe et isolé (Lot C)
Retrait de l'état global toxique :
  - analyze() : kwargs-only enable_ocr, enable_ui_detection, session_id
  - Ne mute JAMAIS self pour les flags (variables locales + branches)
  - _resolve_ocr_instance() / _resolve_ui_detector_instance() : lecture seule
  - _init_lock par instance pour lazy init concurrent safe
  - session_id par appel, plus via mutation singleton

Avant : ExecutionLoop mutait analyzer._ocr, _ui_detector,
_ocr_initialized, _ui_detector_initialized pour désactiver OCR/UI.
Deux loops partageant le singleton se polluaient mutuellement.

Après : deux loops partageant l'analyzer sont complètement isolés.
Preuve par TestAnalyzerIsolationBetweenLoops (3 tests).

Singleton get_screen_analyzer() préservé — garde uniquement les
ressources lourdes, plus de contexte d'exécution.

9 nouveaux tests (3 isolation + 6 kwargs-only/lazy-init).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:06:41 +02:00

533 lines
20 KiB
Python

"""
ScreenAnalyzer - Construction complète d'un ScreenState depuis un screenshot
Orchestre les 4 niveaux du ScreenState :
Niveau 1 (Raw) : métadonnées de l'image
Niveau 2 (Perception): OCR + embedding global
Niveau 3 (UI) : détection d'éléments UI
Niveau 4 (Contexte) : fenêtre active, workflow en cours
Ce module comble le chaînon manquant entre la capture brute (Couche 0)
et la construction d'embeddings (Couche 3).
=============================================================================
Thread-safety & partage multi-loops (Lot C — avril 2026)
=============================================================================
Cet analyseur peut être partagé entre plusieurs `ExecutionLoop` (singleton
`get_screen_analyzer()`). Pour éviter la contamination croisée :
• `analyze()` NE MUTE JAMAIS `self._ocr`, `self._ui_detector`,
`self._ocr_initialized`, `self._ui_detector_initialized` pour gérer les
flags runtime (enable_ocr / enable_ui_detection). Ces flags sont par
appel, résolus en variables locales.
• `session_id` circule en paramètre d'appel et renseigne la metadata du
ScreenState ; l'attribut `self.session_id` n'est qu'un défaut historique
(rétrocompat) et n'est plus la source de vérité.
• L'init lazy des composants lourds (OCR, UIDetector) est protégée par un
`_init_lock` par instance pour empêcher une double initialisation
concurrente.
"""
import contextlib
import logging
import os
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
from PIL import Image
from core.models.screen_state import (
ScreenState,
RawLevel,
PerceptionLevel,
ContextLevel,
WindowContext,
EmbeddingRef,
)
from core.models.ui_element import UIElement
logger = logging.getLogger(__name__)
# Lock d'inférence local au module : sert de fallback si le GPUResourceManager
# n'est pas disponible (import error, tests). Partagé entre toutes les instances
# ScreenAnalyzer du process, cohérent avec le singleton get_screen_analyzer().
_ANALYZE_FALLBACK_LOCK = threading.Lock()
def _acquire_gpu_context(timeout: Optional[float] = None):
"""
Retourne un context manager pour sérialiser les appels GPU.
Préfère `GPUResourceManager.acquire_inference()` si disponible (coordination
globale), sinon bascule sur un lock threading local au module.
"""
try:
from core.gpu import get_gpu_resource_manager
manager = get_gpu_resource_manager()
return manager.acquire_inference(timeout=timeout)
except Exception as e: # pragma: no cover - fallback defensif
logger.debug(f"GPUResourceManager indisponible, fallback lock local: {e}")
@contextlib.contextmanager
def _fallback():
if timeout is None:
_ANALYZE_FALLBACK_LOCK.acquire()
yield True
_ANALYZE_FALLBACK_LOCK.release()
else:
got = _ANALYZE_FALLBACK_LOCK.acquire(timeout=timeout)
try:
yield got
finally:
if got:
_ANALYZE_FALLBACK_LOCK.release()
return _fallback()
class ScreenAnalyzer:
"""
Construit un ScreenState complet (4 niveaux) depuis un screenshot.
Utilise le UIDetector pour la détection d'éléments et un OCR
(docTR ou Tesseract) pour l'extraction de texte.
Example:
>>> analyzer = ScreenAnalyzer()
>>> state = analyzer.analyze("/path/to/screenshot.png")
>>> print(state.perception.detected_text)
>>> print(len(state.ui_elements))
Runtime overrides (kwargs-only) sur analyze() :
>>> state = analyzer.analyze(
... path,
... enable_ocr=False, # bypass OCR pour cet appel
... enable_ui_detection=False, # bypass UIDetector
... session_id="session_42", # session par appel
... )
"""
def __init__(
self,
ui_detector=None,
ocr_engine: Optional[str] = None,
session_id: str = "",
):
"""
Args:
ui_detector: Instance de UIDetector (créé si None)
ocr_engine: Moteur OCR à utiliser ("doctr", "tesseract", None=auto)
session_id: ID de session par défaut (rétrocompat ; préférer passer
`session_id` en kwarg de `analyze()` pour chaque appel).
"""
self._ui_detector = ui_detector
self._ocr_engine_name = ocr_engine
self._ocr = None
# Session par défaut (rétrocompat). La source de vérité est désormais
# le paramètre `session_id` de `analyze()`.
self.session_id = session_id
# Compteur d'états — protégé par _state_lock pour être safe en parallèle.
self._state_counter = 0
self._state_lock = threading.Lock()
# Initialisation lazy pour éviter les imports lourds au démarrage.
self._ui_detector_initialized = ui_detector is not None
self._ocr_initialized = False
# Lock dédié à l'init lazy : empêche deux threads d'initialiser
# simultanément OCR ou UIDetector (double chargement GPU).
self._init_lock = threading.Lock()
# =========================================================================
# API publique
# =========================================================================
def analyze(
self,
screenshot_path: str,
window_info: Optional[Dict[str, Any]] = None,
context: Optional[Dict[str, Any]] = None,
*,
enable_ocr: bool = True,
enable_ui_detection: bool = True,
session_id: str = "",
) -> ScreenState:
"""
Analyser un screenshot et construire un ScreenState complet.
Les flags `enable_ocr`, `enable_ui_detection` et `session_id` sont
**par appel, kwargs-only**, pour ne pas polluer l'état partagé du
singleton quand plusieurs `ExecutionLoop` se partagent l'analyseur.
Args:
screenshot_path: Chemin vers le fichier image
window_info: Infos fenêtre active {"title": ..., "app_name": ...}
context: Contexte métier optionnel
enable_ocr: Active l'OCR pour cet appel (True par défaut).
False → `detected_text=[]`, aucune init d'OCR déclenchée.
enable_ui_detection: Active la détection UI pour cet appel
(True par défaut). False → `ui_elements=[]`.
session_id: ID de session pour cet appel. Si vide, on retombe sur
`self.session_id` (rétrocompat). Cette valeur est propagée
dans `ScreenState.session_id` et `metadata["session_id"]`.
Returns:
ScreenState avec les 4 niveaux remplis.
"""
screenshot_path = str(screenshot_path)
# Résolution de la session : priorité au kwarg, fallback sur l'état
# interne (legacy). Variable locale uniquement — pas de mutation.
effective_session_id = session_id or self.session_id
# Compteur incrémenté sous lock pour identifiants uniques même en
# parallèle. C'est la seule mutation tolérée : elle n'impacte pas le
# comportement OCR/UI.
with self._state_lock:
self._state_counter += 1
state_counter = self._state_counter
state_id = (
f"{effective_session_id}_state_{state_counter:04d}"
if effective_session_id
else f"state_{state_counter:04d}"
)
# Niveau 1 : Raw (léger, hors lock GPU)
raw = self._build_raw_level(screenshot_path)
# Résolution locale des instances OCR / UIDetector selon les flags.
# Aucune mutation de self ici : on décide simplement ce qu'on utilise.
ocr_instance = self._resolve_ocr_instance(enable_ocr=enable_ocr)
ui_detector_instance = self._resolve_ui_detector_instance(
enable_ui_detection=enable_ui_detection
)
# Niveaux 2 et 3 : OCR + détection UI sont les étapes lourdes en GPU.
# On sérialise via GPUResourceManager.acquire_inference() pour éviter
# que ExecutionLoop et stream_processor saturent simultanément la VRAM
# sur RTX 5070 (12 Go). Timeout généreux : un appel peut prendre 15-20s.
with _acquire_gpu_context(timeout=60.0) as acquired:
if not acquired:
logger.warning(
"Timeout en attendant le lock GPU pour ScreenAnalyzer.analyze() "
"→ exécution sans sérialisation (risque saturation VRAM)"
)
# Niveau 2 : Perception (OCR) — mesure du temps OCR
ocr_t0 = time.time()
detected_text = self._extract_text_with(ocr_instance, screenshot_path)
ocr_ms = (time.time() - ocr_t0) * 1000
# Niveau 3 : UI Elements — mesure du temps détection
ui_t0 = time.time()
ui_elements = self._detect_ui_elements_with(
ui_detector_instance, screenshot_path, window_info
)
ui_ms = (time.time() - ui_t0) * 1000
perception = PerceptionLevel(
embedding=EmbeddingRef(
provider="openclip_ViT-B-32",
vector_id=f"data/embeddings/screens/{state_id}.npy",
dimensions=512,
),
detected_text=detected_text,
text_detection_method=self._get_ocr_method_name(ocr_instance),
confidence_avg=0.85 if detected_text else 0.0,
)
# Niveau 4 : Contexte
window_ctx = self._build_window_context(window_info)
context_level = self._build_context_level(context)
state = ScreenState(
screen_state_id=state_id,
timestamp=datetime.now(),
session_id=effective_session_id,
window=window_ctx,
raw=raw,
perception=perception,
context=context_level,
metadata={
"analyzer_version": "1.1",
"session_id": effective_session_id,
"ui_elements_count": len(ui_elements),
"text_regions_count": len(detected_text),
"ocr_ms": ocr_ms,
"ui_ms": ui_ms,
"ocr_enabled": enable_ocr,
"ui_detection_enabled": enable_ui_detection,
},
ui_elements=ui_elements,
)
logger.info(
f"ScreenState {state_id} construit: "
f"{len(ui_elements)} éléments UI, {len(detected_text)} textes détectés "
f"(ocr={enable_ocr}, ui={enable_ui_detection})"
)
return state
def analyze_image(
self,
image: Image.Image,
save_dir: str = "data/screens",
window_info: Optional[Dict[str, Any]] = None,
context: Optional[Dict[str, Any]] = None,
*,
enable_ocr: bool = True,
enable_ui_detection: bool = True,
session_id: str = "",
) -> ScreenState:
"""
Analyser une PIL Image (utile quand on a déjà l'image en mémoire).
Sauvegarde l'image sur disque puis appelle analyze(). Les flags
runtime sont propagés à `analyze()` en kwargs-only.
"""
save_path = Path(save_dir)
save_path.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
filename = f"screen_{timestamp}.png"
filepath = save_path / filename
image.save(str(filepath))
return self.analyze(
str(filepath),
window_info=window_info,
context=context,
enable_ocr=enable_ocr,
enable_ui_detection=enable_ui_detection,
session_id=session_id,
)
# =========================================================================
# Résolution des instances OCR / UI selon les flags d'appel
# =========================================================================
def _resolve_ocr_instance(self, *, enable_ocr: bool):
"""
Retourner l'instance OCR à utiliser pour cet appel.
- `enable_ocr=False` → None (pas d'init, pas d'appel OCR)
- sinon → init lazy sous lock si nécessaire, puis retour de `self._ocr`
Ne mute `self._ocr` / `self._ocr_initialized` QUE pendant l'init lazy
réelle, jamais pour bypasser l'OCR d'un appel.
"""
if not enable_ocr:
return None
if not self._ocr_initialized:
with self._init_lock:
# Double-check : un autre thread a pu initialiser entretemps.
if not self._ocr_initialized:
self._ensure_ocr_locked()
return self._ocr
def _resolve_ui_detector_instance(self, *, enable_ui_detection: bool):
"""
Retourner l'instance UIDetector pour cet appel (idem _resolve_ocr_instance).
"""
if not enable_ui_detection:
return None
if not self._ui_detector_initialized:
with self._init_lock:
if not self._ui_detector_initialized:
self._ensure_ui_detector_locked()
return self._ui_detector
# =========================================================================
# Niveau 1 : Raw
# =========================================================================
def _build_raw_level(self, screenshot_path: str) -> RawLevel:
file_size = 0
try:
file_size = os.path.getsize(screenshot_path)
except OSError:
pass
return RawLevel(
screenshot_path=screenshot_path,
capture_method="mss",
file_size_bytes=file_size,
)
# =========================================================================
# Niveau 2 : Perception — OCR
# =========================================================================
def _extract_text_with(self, ocr_callable, screenshot_path: str) -> List[str]:
"""Extraire le texte via un callable OCR donné (peut être None)."""
if ocr_callable is None:
return []
try:
return ocr_callable(screenshot_path)
except Exception as e:
logger.warning(f"OCR échoué: {e}")
return []
def _ensure_ocr_locked(self) -> None:
"""
Initialiser le moteur OCR (appelé sous `self._init_lock`).
Ne doit PAS être appelé hors de `_resolve_ocr_instance()`.
"""
# Mutation intentionnelle : on installe l'instance OCR réelle.
# Protégée par le lock d'init (pas le lock GPU).
self._ocr_initialized = True
engine = self._ocr_engine_name
# Auto-détection : essayer docTR puis Tesseract
if engine is None or engine == "doctr":
try:
self._ocr = self._create_doctr_ocr()
logger.info("OCR initialisé avec docTR")
return
except Exception as e:
if engine == "doctr":
logger.warning(f"docTR non disponible: {e}")
return
if engine is None or engine == "tesseract":
try:
self._ocr = self._create_tesseract_ocr()
logger.info("OCR initialisé avec Tesseract")
return
except Exception as e:
logger.warning(f"Tesseract non disponible: {e}")
logger.warning("Aucun moteur OCR disponible — detected_text sera vide")
def _create_doctr_ocr(self):
"""Créer une fonction OCR basée sur docTR."""
from doctr.io import DocumentFile
from doctr.models import ocr_predictor
predictor = ocr_predictor(det_arch="db_resnet50", reco_arch="crnn_vgg16_bn", pretrained=True)
def ocr_func(image_path: str) -> List[str]:
doc = DocumentFile.from_images(image_path)
result = predictor(doc)
texts = []
for page in result.pages:
for block in page.blocks:
for line in block.lines:
line_text = " ".join(word.value for word in line.words)
if line_text.strip():
texts.append(line_text.strip())
return texts
return ocr_func
def _create_tesseract_ocr(self):
"""Créer une fonction OCR basée sur Tesseract."""
import pytesseract
def ocr_func(image_path: str) -> List[str]:
img = Image.open(image_path)
raw_text = pytesseract.image_to_string(img, lang="fra+eng")
lines = [line.strip() for line in raw_text.split("\n") if line.strip()]
return lines
return ocr_func
def _get_ocr_method_name(self, ocr_instance=None) -> str:
"""Nom du moteur OCR effectivement utilisé pour cet appel."""
if ocr_instance is None:
return "none"
if self._ocr_engine_name:
return self._ocr_engine_name
return "doctr"
# =========================================================================
# Niveau 3 : UI Elements
# =========================================================================
def _detect_ui_elements_with(
self,
ui_detector,
screenshot_path: str,
window_info: Optional[Dict[str, Any]] = None,
) -> List[UIElement]:
"""Détecter les éléments UI via un détecteur donné (peut être None)."""
if ui_detector is None:
return []
try:
elements = ui_detector.detect(
screenshot_path, window_context=window_info
)
return elements
except Exception as e:
logger.warning(f"Détection UI échouée: {e}")
return []
def _ensure_ui_detector_locked(self) -> None:
"""
Initialiser le UIDetector (appelé sous `self._init_lock`).
"""
self._ui_detector_initialized = True
try:
from core.detection.ui_detector import UIDetector, DetectionConfig
config = DetectionConfig(
use_owl_detection=False, # Désactiver OWL par défaut (lourd)
use_vlm_classification=True,
confidence_threshold=0.6,
)
self._ui_detector = UIDetector(config)
logger.info("UIDetector initialisé")
except Exception as e:
logger.warning(f"UIDetector non disponible: {e}")
self._ui_detector = None
# =========================================================================
# Niveau 4 : Contexte
# =========================================================================
def _build_window_context(
self, window_info: Optional[Dict[str, Any]] = None
) -> WindowContext:
if window_info:
return WindowContext(
app_name=window_info.get("app_name", "unknown"),
window_title=window_info.get("title", "Unknown"),
screen_resolution=window_info.get("screen_resolution", [1920, 1080]),
workspace=window_info.get("workspace", "main"),
monitor_index=window_info.get("monitor_index", 0),
dpi_scale=window_info.get("dpi_scale", 100),
window_bounds=window_info.get("window_bounds"),
monitors=window_info.get("monitors"),
os_theme=window_info.get("os_theme", "unknown"),
os_language=window_info.get("os_language", "unknown"),
)
return WindowContext(
app_name="unknown",
window_title="Unknown",
screen_resolution=[1920, 1080],
workspace="main",
)
def _build_context_level(
self, context: Optional[Dict[str, Any]] = None
) -> ContextLevel:
if context:
return ContextLevel(
current_workflow_candidate=context.get("workflow_candidate"),
workflow_step=context.get("workflow_step"),
user_id=context.get("user_id", ""),
tags=context.get("tags", []),
business_variables=context.get("business_variables", {}),
)
return ContextLevel()