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>
533 lines
20 KiB
Python
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()
|