v1.0 - Version stable: multi-PC, détection UI-DETR-1, 3 modes exécution

- Frontend v4 accessible sur réseau local (192.168.1.40)
- Ports ouverts: 3002 (frontend), 5001 (backend), 5004 (dashboard)
- Ollama GPU fonctionnel
- Self-healing interactif
- Dashboard confiance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-01-29 11:23:51 +01:00
parent 21bfa3b337
commit a27b74cf22
1595 changed files with 412691 additions and 400 deletions

202
core/capture/README.md Normal file
View File

@@ -0,0 +1,202 @@
# Module de Capture d'Écran
## Vue d'ensemble
Le module `screen_capturer` fournit une interface unifiée pour capturer des screenshots avec fallback automatique entre différentes bibliothèques.
## Fonctionnalités
- ✅ Capture d'écran rapide avec `mss` (méthode préférée)
- ✅ Fallback automatique vers `pyautogui` si mss n'est pas disponible
- ✅ Détection de la fenêtre active avec `pygetwindow`
- ✅ Conversion automatique au format RGB numpy
- ✅ Validation des images capturées
- ✅ Gestion propre des ressources
## Installation
```bash
# Installer les dépendances
cd rpa_vision_v3
./install_capture_deps.sh
# Ou manuellement
pip install mss>=9.0.0 pygetwindow>=0.0.9
```
## Utilisation
### Capture Simple
```python
from core.capture.screen_capturer import ScreenCapturer
# Initialiser le capturer
capturer = ScreenCapturer()
# Capturer l'écran
img = capturer.capture() # numpy array (H, W, 3) RGB
# Vérifier la capture
if img is not None:
print(f"Image capturée: {img.shape}")
```
### Détection de Fenêtre Active
```python
# Obtenir les infos de la fenêtre active
window = capturer.get_active_window()
if window:
print(f"Fenêtre: {window['title']}")
print(f"Position: ({window['x']}, {window['y']})")
print(f"Taille: {window['width']}x{window['height']}")
```
### Intégration avec PIL
```python
from PIL import Image
# Capturer et convertir en PIL Image
img_array = capturer.capture()
img_pil = Image.fromarray(img_array)
# Sauvegarder
img_pil.save("screenshot.png")
```
## Architecture
```
ScreenCapturer
├── __init__() # Initialise avec mss ou pyautogui
├── capture() # Capture l'écran complet
├── get_active_window() # Détecte la fenêtre active
├── _capture_mss() # Capture avec mss (rapide)
└── _capture_pyautogui()# Capture avec pyautogui (fallback)
```
## Performance
| Méthode | Temps moyen | Mémoire |
|---------|-------------|---------|
| mss | ~10-20ms | Faible |
| pyautogui | ~50-100ms | Moyenne |
**Recommandation**: Utiliser `mss` pour les captures fréquentes.
## Format de Sortie
- **Type**: `numpy.ndarray`
- **Shape**: `(hauteur, largeur, 3)`
- **Dtype**: `uint8`
- **Ordre des canaux**: RGB (pas BGR)
- **Valeurs**: 0-255
## Gestion d'Erreurs
```python
try:
img = capturer.capture()
if img is None:
print("Capture a échoué")
except Exception as e:
print(f"Erreur: {e}")
```
## Tests
```bash
# Tester le module
python examples/test_screen_capturer.py
# Résultat attendu:
# ✓ Méthode utilisée: mss
# ✓ Image capturée: (1080, 1920, 3)
# ✓ Format RGB valide
# ✓ Fenêtre active détectée
```
## Dépendances
### Obligatoires
- `numpy>=1.24.0`
### Optionnelles (au moins une requise)
- `mss>=9.0.0` (recommandé)
- `pyautogui>=0.9.54` (fallback)
### Pour détection de fenêtre
- `pygetwindow>=0.0.9`
## Limitations
1. **Multi-écrans**: Capture actuellement le moniteur principal uniquement
2. **Fenêtre active**: Peut ne pas fonctionner sur tous les gestionnaires de fenêtres Linux
3. **Permissions**: Peut nécessiter des permissions spéciales sur certains systèmes
## Compatibilité
- ✅ Linux (X11)
- ✅ Linux (Wayland) - avec limitations
- ✅ Windows
- ✅ macOS
## Troubleshooting
### Erreur: "Neither mss nor pyautogui available"
```bash
pip install mss pyautogui
```
### Erreur: "Captured image has invalid dimensions"
Vérifier que l'écran est bien détecté:
```python
import mss
with mss.mss() as sct:
print(sct.monitors)
```
### Fenêtre active non détectée
Sur certains systèmes Linux, installer:
```bash
sudo apt-get install python3-xlib
```
## Exemples Avancés
### Capture d'une région spécifique
```python
# TODO: À implémenter
# capturer.capture_region(x, y, width, height)
```
### Capture avec timestamp
```python
from datetime import datetime
img = capturer.capture()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"screenshot_{timestamp}.png"
Image.fromarray(img).save(filename)
```
## Roadmap
- [ ] Support de capture de région spécifique
- [ ] Support multi-écrans avec sélection
- [ ] Cache de captures pour optimisation
- [ ] Compression automatique des images
- [ ] Support de formats de sortie alternatifs (JPEG, WebP)
## Contribution
Pour améliorer ce module, voir `rpa_vision_v3/docs/specs/tasks.md`.

4
core/capture/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""Screen capture module"""
from .screen_capturer import ScreenCapturer
__all__ = ['ScreenCapturer']

View File

@@ -0,0 +1,485 @@
"""
Screen Capture Module - Capture d'écran continue pour RPA Vision V3
Fonctionnalités:
- Capture unique ou continue
- Buffer circulaire pour historique
- Détection de changement d'écran
- Support multi-moniteur
- Optimisation mémoire
"""
import numpy as np
from typing import Optional, Dict, List, Callable, Tuple
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
import threading
import time
import logging
import hashlib
from PIL import Image
logger = logging.getLogger(__name__)
@dataclass
class CaptureFrame:
"""Un frame capturé avec métadonnées"""
image: np.ndarray
timestamp: datetime
frame_id: int
hash: str
window_info: Optional[Dict] = None
changed_from_previous: bool = True
@dataclass
class CaptureStats:
"""Statistiques de capture"""
total_captures: int = 0
captures_per_second: float = 0.0
unchanged_frames_skipped: int = 0
average_capture_time_ms: float = 0.0
buffer_size: int = 0
memory_usage_mb: float = 0.0
class ScreenCapturer:
"""
Capturer d'écran avancé avec mode continu.
Modes:
- Single: Capture unique à la demande
- Continuous: Capture en boucle avec callback
- Buffered: Maintient un historique des N derniers frames
Example:
>>> capturer = ScreenCapturer(buffer_size=10)
>>> # Capture unique
>>> frame = capturer.capture()
>>> # Mode continu
>>> capturer.start_continuous(callback=on_frame, interval_ms=500)
>>> # ... plus tard ...
>>> capturer.stop_continuous()
"""
def __init__(
self,
buffer_size: int = 10,
detect_changes: bool = True,
change_threshold: float = 0.02,
monitor_index: int = 1
):
"""
Initialiser le capturer.
Args:
buffer_size: Nombre de frames à garder en mémoire
detect_changes: Détecter si l'écran a changé
change_threshold: Seuil de changement (0-1)
monitor_index: Index du moniteur (1=principal)
"""
self.buffer_size = buffer_size
self.detect_changes = detect_changes
self.change_threshold = change_threshold
self.monitor_index = monitor_index
# Buffer circulaire
self._buffer: List[CaptureFrame] = []
self._frame_counter = 0
self._last_hash: Optional[str] = None
# Mode continu
self._continuous_running = False
self._continuous_thread: Optional[threading.Thread] = None
self._continuous_callback: Optional[Callable[[CaptureFrame], None]] = None
self._continuous_interval_ms = 500
self._lock = threading.Lock()
# Stats
self._stats = CaptureStats()
self._capture_times: List[float] = []
# Initialiser le backend de capture
self._init_capture_backend()
logger.info(f"ScreenCapturer initialized (buffer={buffer_size}, changes={detect_changes})")
def _init_capture_backend(self) -> None:
"""Initialiser le backend de capture (mss ou pyautogui)."""
# Ne plus garder self.sct - créer MSS à chaque capture (Option A - ultra stable)
self.sct = None
self.pyautogui = None
self.method = None
try:
import mss
# Test que mss fonctionne sans garder l'instance
with mss.mss() as test_sct:
pass
self.method = "mss"
logger.info("Using mss for screen capture (thread-safe mode)")
except ImportError:
try:
import pyautogui
self.pyautogui = pyautogui
self.method = "pyautogui"
logger.info("Using pyautogui for screen capture")
except ImportError:
raise ImportError("Neither mss nor pyautogui available for screen capture")
# =========================================================================
# Capture unique
# =========================================================================
def capture(self) -> Optional[np.ndarray]:
"""
Capture unique de l'écran.
Returns:
Screenshot as numpy array (H, W, 3) RGB ou None si erreur
"""
try:
start_time = time.time()
if self.method == "mss":
img = self._capture_mss()
else:
img = self._capture_pyautogui()
# Stats
capture_time = (time.time() - start_time) * 1000
self._capture_times.append(capture_time)
if len(self._capture_times) > 100:
self._capture_times.pop(0)
self._stats.total_captures += 1
self._stats.average_capture_time_ms = sum(self._capture_times) / len(self._capture_times)
return img
except Exception as e:
logger.error(f"Capture failed: {e}")
return None
def capture_frame(self) -> Optional[CaptureFrame]:
"""
Capture avec métadonnées complètes.
Returns:
CaptureFrame avec image, timestamp, hash, etc.
"""
img = self.capture()
return self._create_frame(img)
def _capture_frame_threaded(self, thread_sct) -> Optional[CaptureFrame]:
"""
Capture avec instance mss thread-local.
Args:
thread_sct: Instance mss créée dans le thread
Returns:
CaptureFrame ou None
"""
try:
start_time = time.time()
if self.method == "mss" and thread_sct:
monitor_idx = self.monitor_index if len(thread_sct.monitors) > self.monitor_index else 0
monitor = thread_sct.monitors[monitor_idx]
sct_img = thread_sct.grab(monitor)
img = np.array(sct_img)
img = img[:, :, :3][:, :, ::-1] # BGRA to RGB
else:
img = self._capture_pyautogui()
# Stats
capture_time = (time.time() - start_time) * 1000
self._capture_times.append(capture_time)
if len(self._capture_times) > 100:
self._capture_times.pop(0)
self._stats.total_captures += 1
self._stats.average_capture_time_ms = sum(self._capture_times) / len(self._capture_times)
return self._create_frame(img)
except Exception as e:
logger.error(f"Threaded capture failed: {e}")
return None
def _create_frame(self, img: Optional[np.ndarray]) -> Optional[CaptureFrame]:
"""Créer un CaptureFrame à partir d'une image."""
if img is None:
return None
# Calculer le hash pour détecter les changements
img_hash = self._compute_hash(img)
changed = True
if self.detect_changes and self._last_hash:
changed = img_hash != self._last_hash
if not changed:
self._stats.unchanged_frames_skipped += 1
self._last_hash = img_hash
self._frame_counter += 1
frame = CaptureFrame(
image=img,
timestamp=datetime.now(),
frame_id=self._frame_counter,
hash=img_hash,
window_info=self.get_active_window(),
changed_from_previous=changed
)
# Ajouter au buffer
self._add_to_buffer(frame)
return frame
def capture_screen(self) -> Optional[Image.Image]:
"""
Capture et retourne une PIL Image (compatibilité avec ExecutionLoop).
Returns:
PIL Image ou None
"""
img = self.capture()
if img is None:
return None
return Image.fromarray(img)
def _capture_mss(self) -> np.ndarray:
"""Capture using mss - Option A: créer MSS à chaque capture (ultra stable)."""
import mss
# Créer une nouvelle instance MSS à chaque capture - zéro surprise, marche dans n'importe quel thread
with mss.mss() as sct:
monitor_idx = 1 if len(sct.monitors) > 1 else 0
monitor = sct.monitors[monitor_idx]
sct_img = sct.grab(monitor)
img = np.array(sct_img)
# Convert BGRA to RGB
img = img[:, :, :3][:, :, ::-1]
if img.size == 0 or img.shape[0] == 0 or img.shape[1] == 0:
raise ValueError("Captured image has invalid dimensions")
return img
def _capture_pyautogui(self) -> np.ndarray:
"""Capture using pyautogui."""
screenshot = self.pyautogui.screenshot()
img = np.array(screenshot)
if img.size == 0 or img.shape[0] == 0 or img.shape[1] == 0:
raise ValueError("Captured image has invalid dimensions")
return img
# =========================================================================
# Mode continu
# =========================================================================
def start_continuous(
self,
callback: Callable[[CaptureFrame], None],
interval_ms: int = 500,
skip_unchanged: bool = True
) -> bool:
"""
Démarrer la capture continue.
Args:
callback: Fonction appelée pour chaque frame
interval_ms: Intervalle entre captures (ms)
skip_unchanged: Ne pas appeler callback si écran inchangé
Returns:
True si démarré avec succès
"""
with self._lock:
if self._continuous_running:
logger.warning("Continuous capture already running")
return False
self._continuous_callback = callback
self._continuous_interval_ms = interval_ms
self._skip_unchanged = skip_unchanged
self._continuous_running = True
self._continuous_thread = threading.Thread(
target=self._continuous_loop,
daemon=True
)
self._continuous_thread.start()
logger.info(f"Started continuous capture (interval={interval_ms}ms)")
return True
def stop_continuous(self) -> None:
"""Arrêter la capture continue."""
with self._lock:
self._continuous_running = False
if self._continuous_thread:
self._continuous_thread.join(timeout=2.0)
self._continuous_thread = None
logger.info("Stopped continuous capture")
def is_continuous_running(self) -> bool:
"""Vérifier si la capture continue est active."""
return self._continuous_running
def _continuous_loop(self) -> None:
"""Boucle de capture continue (thread)."""
last_capture_time = 0
captures_in_second = 0
second_start = time.time()
# Créer une nouvelle instance mss pour ce thread (requis pour X11)
thread_sct = None
if self.method == "mss":
import mss
thread_sct = mss.mss()
while self._continuous_running:
try:
# Capturer avec l'instance thread-local
frame = self._capture_frame_threaded(thread_sct)
if frame:
# Calculer FPS
captures_in_second += 1
if time.time() - second_start >= 1.0:
self._stats.captures_per_second = captures_in_second
captures_in_second = 0
second_start = time.time()
# Appeler callback si changement ou si on ne skip pas
if self._continuous_callback:
if frame.changed_from_previous or not self._skip_unchanged:
try:
self._continuous_callback(frame)
except Exception as e:
logger.error(f"Callback error: {e}")
# Attendre l'intervalle
elapsed = (time.time() - last_capture_time) * 1000
sleep_time = max(0, self._continuous_interval_ms - elapsed) / 1000.0
if sleep_time > 0:
time.sleep(sleep_time)
last_capture_time = time.time()
except Exception as e:
logger.error(f"Continuous capture error: {e}")
time.sleep(0.1)
# Cleanup thread-local mss
if thread_sct:
try:
thread_sct.close()
except Exception:
pass
# =========================================================================
# Buffer et historique
# =========================================================================
def _add_to_buffer(self, frame: CaptureFrame) -> None:
"""Ajouter un frame au buffer circulaire."""
with self._lock:
self._buffer.append(frame)
if len(self._buffer) > self.buffer_size:
self._buffer.pop(0)
self._stats.buffer_size = len(self._buffer)
# Calculer utilisation mémoire
if self._buffer:
frame_size = self._buffer[0].image.nbytes / (1024 * 1024)
self._stats.memory_usage_mb = frame_size * len(self._buffer)
def get_buffer(self) -> List[CaptureFrame]:
"""Obtenir une copie du buffer."""
with self._lock:
return list(self._buffer)
def get_last_frame(self) -> Optional[CaptureFrame]:
"""Obtenir le dernier frame capturé."""
with self._lock:
return self._buffer[-1] if self._buffer else None
def get_frame_by_id(self, frame_id: int) -> Optional[CaptureFrame]:
"""Obtenir un frame par son ID."""
with self._lock:
for frame in self._buffer:
if frame.frame_id == frame_id:
return frame
return None
def clear_buffer(self) -> None:
"""Vider le buffer."""
with self._lock:
self._buffer.clear()
self._stats.buffer_size = 0
# =========================================================================
# Utilitaires
# =========================================================================
def _compute_hash(self, img: np.ndarray) -> str:
"""Calculer un hash rapide de l'image pour détecter les changements."""
# Sous-échantillonner pour un hash rapide
small = img[::20, ::20, :].tobytes()
return hashlib.md5(small).hexdigest()
def get_active_window(self) -> Optional[Dict]:
"""Obtenir les infos de la fenêtre active."""
try:
import pygetwindow as gw
active = gw.getActiveWindow()
if active:
return {
'title': active.title,
'x': active.left,
'y': active.top,
'width': active.width,
'height': active.height,
'app': getattr(active, '_app', 'unknown')
}
except Exception as e:
logger.debug(f"Could not get active window: {e}")
return None
def get_screen_resolution(self) -> Tuple[int, int]:
"""Obtenir la résolution de l'écran."""
if self.method == "mss":
import mss
# Créer une instance temporaire pour obtenir la résolution
with mss.mss() as sct:
monitor = sct.monitors[1] if len(sct.monitors) > 1 else sct.monitors[0]
return (monitor['width'], monitor['height'])
else:
size = self.pyautogui.size()
return (size.width, size.height)
def get_stats(self) -> CaptureStats:
"""Obtenir les statistiques de capture."""
return self._stats
def save_frame(self, frame: CaptureFrame, path: str) -> bool:
"""Sauvegarder un frame sur disque."""
try:
img = Image.fromarray(frame.image)
img.save(path)
return True
except Exception as e:
logger.error(f"Failed to save frame: {e}")
return False
def __del__(self):
"""Cleanup."""
self.stop_continuous()
# Plus besoin de fermer self.sct car nous créons MSS à chaque capture