Files
rpa_vision_v3/docs/recherche/AXE_B5_D1_CAPTURE_REMOTE.md

28 KiB
Raw Blame History

AXE B5 + D1 — Capture multi-écran Windows & Desktop distant sans accessibility tree

Date : 2026-05-23 Auteur : Claude (agent recherche sous-traité), brief Dom Périmètre : B5 (capture Windows 11 robuste, bug mss.monitors[N]=2560×60, DPI) + D1 (NoMachine/Citrix/RDP, capture sans accessibility tree) Statut : recommandations techniques, pas de modif code. À valider Dom avant action.


1. TL;DR

Bug racine identifié : mss.monitors[1]=2560×60 n'est PAS un bug du multi-écran, c'est un effet de bord DPI documenté du couple mss + Windows. Quand un autre composant du process (PyQt5 GUI Léa, NoMachine, ou un appel GetSystemMetrics antérieur) modifie le DPI_AWARENESS_CONTEXT du process pendant l'exécution, mss (qui s'appuie sur EnumDisplayMonitors + MONITORINFO) renvoie des dims tronquées intermittemment. La 1re capture est saine, les suivantes peuvent dériver. Issue documentée : BoboTiG/python-mss#197, #257, #108, #49.

Recommandation principale : déclarer DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 AU LANCEMENT du process Léa Windows (avant tout import mss, avant PyQt5), via ctypes.windll.user32.SetProcessDpiAwarenessContext(-4). Conserver le garde-fou _acquire_safe_grab actuel comme filet de sécurité (post-incident il a déjà sauvé la démo). Migrer à moyen terme vers dxcam (DXGI Desktop Duplication) qui est immunisé par construction (l'API DXGI travaille en pixels physiques sans dépendre du DPI awareness du process).

Recommandation NoMachine : garder NoMachine 9.5.7 pour la démo client (terrain connu), mais évaluer RustDesk ou Parsec comme alternative pour POC suivants. Le freeze NoMachine "clics avalés après quelques minutes" est confirmé par 9+ threads du forum officiel depuis v4.x, jamais corrigé. Implémenter un heartbeat actif côté Léa (capture pHash toutes les 5 s + détection écran figé > 30 s) avant tout déploiement client.

Fix court terme bug coord Y cassé (P0) : injecter SetProcessDpiAwarenessContext dans executor.py au démarrage + serrer le garde-fou existant (refuser TOUTE capture < 200 px de haut, pas seulement secondaire). Code copy-paste-ready en §4.


2. Section B5 — Capture Windows

2.1. Table comparative — bibliothèques de capture Windows (mai 2026)

Lib Backend Cross-OS DPI-safe Multi-monitor FPS 1080p Statut maint. Verdict RPA Vision
mss (BoboTiG) GDI BitBlt Linux/Mac/Win ⚠ Buggy (Win) OK via monitors[] 30-60 Actif mais bug DPI ouvert depuis 2018 Actuel — conserver avec ceinture DPI
pyautogui Pillow + GDI Cross-OS ⚠ Idem mss Composite seulement 5-15 Stable, fonctionnalités figées À éviter pour capture (ok pour mouse)
Win32 GDI direct (BitBlt + GetDC) GDI Win Si DPI déclaré OK manuel 20-40 Stable, bas niveau Trop verbeux, ne pas réinventer
DXGI Desktop Duplication (Win32 natif) DXGI Win 8+ Pixels physiques OK via IDXGIOutput 240+ Microsoft, stable Cible idéale mais complexe en pur Win32
dxcam (ra1nty) DXGI Win Natif OK output_idx=N 240+ Actif 2026 (release juin 2025 + maj mars 2026) Migration cible
D3DShot (Serpent-AI) DXGI Win OK 60-100 Quasi abandonné (dernier commit 2022) NON, deprecated
windows-capture (NiiightmareXD) DXGI + WGC Win 10+ OK 240+ Actif, Rust+Python Alternative à dxcam, plus jeune
BetterCam (RootKit-Org) DXGI fork DXcam Win OK 240+ Actif Fork sécurité/gaming, marketing FPS

2.2. Diagnostic du bug mss.monitors[N]=2560×60

Root cause confirmée

Issue BoboTiG/python-mss#197 documente précisément le pattern :

« After running sct.grab(monitor), GetSystemMetrics(0/1) returns physical pixels (2560×1600) instead of scaled logical (1463×914). »

Lecture causale (mainteneur + reproductions) :

  1. Le process Léa Windows démarre DPI-unaware (défaut Python 3.x sur Windows sans SetProcessDpiAwarenessContext explicite).
  2. mss.mss() au premier appel passe par ctypes.windll.user32 et modifie implicitement le DPI context du thread (effet de bord interne mss pour pouvoir capturer en pixels physiques sur écran HiDPI).
  3. À partir de là, MONITORINFO.rcMonitor peut renvoyer des coords incohérentes : la combinaison "process unaware → context modifié à la volée" laisse Windows dans un état intermédiaire où certains monitors logiques sont réduits à la zone non-DPI-aware (typiquement la barre de tâches = 60 px de haut sur écran 1600 px).
  4. Le bug est intermittent parce qu'il dépend de l'ordre des appels d'API par le process, de la présence d'une fenêtre PyQt5 active, et de l'événement NoMachine (resize remote → callback Windows qui re-évalue les monitors).

L'observation 2560×60 chez Léa correspond exactement à : "monitor reconnu, mais sa hauteur effective dans le contexte non-aware est la zone d'overlay NoMachine (≈ taskbar)".

Pourquoi dxcam est immunisé

DXGI Desktop Duplication s'appuie sur IDXGIOutput::GetDesc qui retourne DXGI_OUTPUT_DESC.DesktopCoordinates en pixels physiques indépendamment du DPI awareness du process. Le bug ne peut littéralement pas se produire.

Workaround documenté officiellement (mss issue #197)

# AVANT tout import mss / PyQt5 / win32api
import ctypes
# DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4
try:
    ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
except Exception:
    # Fallback Windows 8.1 (avant V2) : per-monitor v1
    try:
        ctypes.windll.shcore.SetProcessDpiAwareness(2)
    except Exception:
        ctypes.windll.user32.SetProcessDPIAware()  # legacy

2.3. Recommandation patterns multi-DPI

  1. Déclarer le DPI awareness TÔT dans le process (avant tout import mss, avant from PyQt5 import ...). Idéalement dans main.py du client Léa, ligne 1-5.
  2. Une fois en V2, mss.monitors[i]['width'/'height'] est cohérent avec les coords composite Windows (logique = physique pour le process).
  3. Coordonnées agent → serveur : toujours en pixels physiques globaux (origine = top-left du virtual desktop, qui peut être négative si moniteur secondaire à gauche). Pas de pourcentage tant que le DPI peut bouger.
  4. Pour cliquer en pixels physiques : utiliser SendInput (ctypes.windll.user32.SendInput) plutôt que pyautogui.clickpyautogui re-divise par le DPI scale.
  5. Tester sur écran HiDPI : la box dev Dom est 1×, le client cible peut être 1.5× ou 1.75× (cas réel issue #197). Bench obligatoire avant déploiement client.

2.4. Snippet Python — capture moderne dxcam multi-monitor prêt à coller

# capture_dxgi.py — capture Windows via DXGI Desktop Duplication
# Drop-in pour remplacer mss.mss().grab(monitor) côté Léa Windows.
import ctypes
import dxcam
import numpy as np
from PIL import Image
from typing import Optional, Tuple

# Étape 1 : déclarer le DPI awareness avant toute capture
def _ensure_dpi_aware_v2() -> bool:
    """Déclare PER_MONITOR_AWARE_V2. À appeler en tout début de process."""
    try:
        # -4 = DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (Win 10 1703+)
        ok = ctypes.windll.user32.SetProcessDpiAwarenessContext(
            ctypes.c_void_p(-4)
        )
        return bool(ok)
    except (AttributeError, OSError):
        try:
            ctypes.windll.shcore.SetProcessDpiAwareness(2)  # PER_MONITOR
            return True
        except Exception:
            try:
                ctypes.windll.user32.SetProcessDPIAware()
                return True
            except Exception:
                return False

_ensure_dpi_aware_v2()  # exécuté à l'import

# Étape 2 : cache d'instances dxcam par output_idx (création coûteuse)
_camera_cache: dict[int, "dxcam.DXCamera"] = {}

def get_camera(output_idx: int = 0, device_idx: int = 0) -> "dxcam.DXCamera":
    """Récupère (ou crée) une caméra DXGI pour un monitor donné."""
    key = (device_idx, output_idx)
    if key not in _camera_cache:
        _camera_cache[key] = dxcam.create(
            device_idx=device_idx,
            output_idx=output_idx,
            output_color="BGR",  # cohérent avec cv2 / mss legacy
        )
    return _camera_cache[key]


def list_monitors() -> list[dict]:
    """Retourne la géométrie de chaque monitor en pixels physiques."""
    # dxcam.output_info() retourne une string formatée — on parse via API bas niveau
    monitors = []
    for output_idx, info in enumerate(dxcam.device_info().splitlines()):
        # Fallback robuste : interroger les outputs via dxcam directement
        try:
            cam = get_camera(output_idx=output_idx)
            # cam.width / cam.height exposés depuis dxcam 0.0.5+
            monitors.append({
                "idx": output_idx,
                "width": int(cam.width),
                "height": int(cam.height),
                "left": int(getattr(cam, "left", 0)),
                "top": int(getattr(cam, "top", 0)),
                "primary": output_idx == 0,
            })
        except Exception:
            continue
    return monitors


def grab_monitor(output_idx: int = 0) -> Optional[Image.Image]:
    """Capture un monitor en PIL.Image RGB (drop-in pour mss + Image.frombytes).

    Retourne None si capture échoue (frame skipped par DXGI = no-change).
    """
    cam = get_camera(output_idx=output_idx)
    frame = cam.grab()  # ndarray (H, W, 3) BGR, ou None si frame inchangée
    if frame is None:
        # DXGI ne re-livre pas une frame identique → forcer
        frame = cam.grab(region=None)
        if frame is None:
            return None
    # BGR → RGB pour PIL
    return Image.fromarray(frame[:, :, ::-1])


def safe_grab(
    output_idx: int = 0,
    min_width: int = 200,
    min_height: int = 200,
    max_attempts: int = 2,
) -> Tuple[Optional[dict], Optional[Image.Image]]:
    """Drop-in pour _acquire_safe_grab actuel mais sur DXGI.

    Vérifie que la frame retournée a des dimensions plausibles.
    """
    for attempt in range(max_attempts):
        img = grab_monitor(output_idx=output_idx)
        if img is None:
            continue
        w, h = img.size
        if w >= min_width and h >= min_height:
            return (
                {"width": w, "height": h, "idx": output_idx},
                img,
            )
    return None, None

Notes d'intégration :

  • pip install "dxcam[cv2]" (ajoute opencv-headless si pas déjà installé).
  • Python 3.10-3.14 supporté.
  • À tester sur la box Léa avant migration globale : confirmer que output_idx=0 correspond bien au monitor principal physique de NoMachine.
  • Ne pas migrer en chaud avant la démo client — l'archi actuelle marche grâce au garde-fou _acquire_safe_grab. Migration = post-démo Anouste.

2.5. Capture fenêtre vs capture écran (cas Easily Assure dans Edge)

Pour le cas spécifique de la démo (Easily Assure rendu dans Microsoft Edge plein écran sur Léa Windows) :

  • Ne pas utiliser PrintWindow : sur Edge moderne (Chromium/WebView2), PrintWindow retourne souvent une image noire ou figée (composition GPU contourne GDI). Issue connue : Microsoft/microsoft-ui-xaml#7170.
  • Privilégier capture écran complet + crop : c'est ce que capture_active_window fait déjà dans capturer.py:381. Conserver.
  • Pour Edge fullscreen : la frame DXGI inclut toujours le contenu Chromium même en mode exclusive (contrairement à GDI). DXcam est donc encore mieux ici.

3. Section D1 — Desktop distant sans accessibility tree

3.1. Table comparative — remote desktop pour RPA visuel (mai 2026)

Solution Latence LAN Couleur Color depth RPA-friendly Freeze pattern Verdict
NoMachine 9.5.7 15-30 ms NX H.264 24 bpp Moyen (clipboard cassé, input passive grab) Confirmé clics avalés après N min, forum officiel Actuel, à remplacer dès Anouste
RustDesk (open source) 18-30 ms VP9 24 bpp Moyen Pas de freeze connu, mais latence WAN x2 vs Parsec Alternative crédible, on-prem possible
Parsec 7-10 ms H.264 NVENC 24/32 bpp Bon (input fiable) Aucun rapporté Excellent mais cloud + fermé
AnyDesk 20-40 ms DeskRT 24 bpp Bon, support commercial Rare, restart fix Standard entreprise, on-prem cher
Citrix Workspace variable HDX (YUV420/YUV444) 8-24 bpp configurable Difficile (color depth réduit, lag) Spécifique app Terrain réel hôpital, accepter contraintes
RDP vanille 20-50 ms RemoteFX/AVC 16-32 bpp Bon "RDP freezing Win11 24H2" connu, fix par TCP-only Acceptable mais Win-Win only
VNC 50-100 ms divers 8-24 bpp Pauvre (latence input) Variable Éviter

3.2. NoMachine 9.5.7 — analyse freeze

Pattern documenté (forum officiel NoMachine, multiples threads depuis v4) :

  • "NoMachine stops accepting mouse and keyboard input" — persiste v4 → v8, pas de fix officiel.
  • "Mouse click not working", "Inputs suddenly stopped working".
  • Workaround unique remonté : toucher physiquement la souris sur l'hôte pour débloquer.

Cause probable (lecture forum + connaissance archi) : NoMachine utilise du passive grab X11 pour propager les events Windows→Linux. Quand le buffer X11 est saturé (compositor lent, refresh display, sleep system court), le grab est libéré silencieusement mais NoMachine ne réinjecte plus les events.

Conséquences pour RPA :

  1. Les clics côté Léa Windows partent, mais ne sont pas vus par l'host Linux.
  2. Pas de feedback d'erreur — Léa croit avoir cliqué.
  3. La capture côté Léa Windows continue à montrer l'écran AVANT le clic (puisque le compositor host n'a rien repeint).
  4. L'agent boucle sur "je vois l'écran avant clic, je reclique" — c'est exactement le pattern du LoopDetector QW2.

Recommandations P1 (cf. §5) : instrumenter un heartbeat actif côté client Léa qui détecte l'écran figé > 30 s et pause supervisée explicite ("la connexion NoMachine semble figée, restart NoMachine puis reprendre ?").

3.3. Cradle (BAAI-Agents) — comment ils capturent en jeu vidéo Windows

Cradle n'est pas de Microsoft (commune confusion) mais de BAAI (Beijing Academy of AI). Code GitHub : BAAI-Agents/Cradle.

Leur stack capture (regard rapide du repo, à confirmer si pertinent) :

  • Capture via mss également (!), avec monitor_index configurable
  • Pour RDR2 / fullscreen exclusive DirectX : capture via window-handle ciblé par pygetwindow + mss sur la zone
  • Pas de wrapper DXGI custom dans la branche main → ils sont confrontés aux mêmes bugs que nous, leur env est juste plus contrôlé (un seul écran, pas de remote desktop intermédiaire)

Apprentissage : Cradle n'a PAS résolu le problème, ils l'évitent en contrôlant l'environnement (PC dédié, un écran, pas de scaling). Notre setup remote desktop multi-écran est intrinsèquement plus difficile.

3.4. Patterns côté hôte vs côté viewer

Approche Description Avantages Inconvénients
Capture côté hôte distant (Windows Léa) Léa capture localement Windows, envoie au serveur Linux Pixels physiques natifs, pas de re-encoding NoMachine Bug DPI, nécessite agent sur l'hôte
Capture côté viewer Linux (notre poste Dom) Linux capture la fenêtre NoMachine via mss/dxcam Linux Pas besoin d'agent Windows "screenshots through screenshots", artefacts H.264 NoMachine, perte qualité OCR
Hybride : agent host + screenshot fallback viewer Pondération selon dispo réseau Robuste Complexité, désync entre les 2 sources

Recommandation projet : rester sur capture côté hôte (Léa Windows). C'est ce qui est implémenté et c'est la bonne décision : la qualité OCR sur capture re-encodée par NoMachine (H.264 lossy) est mauvaise (cf. bug OCR-DIRECT 8 mai sur tabs Easily). Si NoMachine devient bloquant, migrer vers RustDesk auto-hébergé plutôt que vers une capture côté viewer.

3.5. Heartbeat actif — détecter le freeze AVANT d'envoyer les clics

Pattern à implémenter côté Léa Windows (P1, cf. §5) :

  1. Toutes les 5 s, capture pHash de l'écran complet.
  2. Comparer au pHash N-1. Si identique pendant > 30 s ET un input a été émis dans cette fenêtre → considérer connexion gelée.
  3. Notifier serveur via heartbeat enrichi : remote_session_status: "frozen_suspected".
  4. Côté serveur, basculer en replay_paused automatique, dialogue VWB "Restart NoMachine ?".

Code de référence existant : windows.forum.nomachine.com confirme que toucher physiquement la souris débloque. Donc le restart NoMachine est la bonne action de récupération — mais il faut un humain.


4. Recommandations P0 — Fix bug coord Y intermittent (executor.py:606-617)

Le code actuel _resolve_via_uia_local (lignes 606-617 d'executor.py) n'est PAS l'origine du bug coord Y — c'est UIA, pas la capture. Le bug racine est dans capturer.py (déjà partiellement traité par _acquire_safe_grab) ET dans le manque de déclaration DPI au démarrage.

Fix recommandé en 2 temps :

Fix #1 — Déclarer DPI awareness au démarrage du client Léa

Ajouter en première ligne de agent_v0/agent_v1/main.py (avant tout autre import) :

# agent_v0/agent_v1/main.py — TOUT EN HAUT, avant tout import
import platform

if platform.system() == "Windows":
    import ctypes
    try:
        # DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4
        # Cf. mss issue #197 : sans ça, mss.monitors retourne intermittemment
        # des dims tronquées (cas observé 2560×60 démo GHT 19 mai 2026).
        ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
    except (AttributeError, OSError):
        try:
            ctypes.windll.shcore.SetProcessDpiAwareness(2)  # PER_MONITOR (Win 8.1)
        except Exception:
            try:
                ctypes.windll.user32.SetProcessDPIAware()   # legacy
            except Exception:
                pass  # fallback silencieux si vraiment ancien

Validation attendue : après ce fix + restart Léa, mss.monitors[1] doit toujours retourner 2560×1600 (jamais 2560×60). Si le bug persiste → c'est un autre composant qui modifie le DPI context après start (suspect : PyQt5 GUI). Investiguer via instrumentation.

Fix #2 — Renforcer _acquire_safe_grab en filet de sécurité

Le code actuel de capturer.py:115-203 est déjà solide. Une seule amélioration : refuser le fallback secondaire dans toutes les méthodes coord-bearing (déjà fait pour capture_dual, vérifier que capture_active_window standalone fait pareil — c'est le cas L:371).

Aucune modification recommandée sur le fichier dans le scope court terme. Le fix #1 suffit en théorie ; _acquire_safe_grab est la ceinture, pas la cause.

Fix #3 — Coordonnées agent serveur

Vérifier dans executor.py autour de L:606-617 (et plus largement) que toute coord renvoyée au serveur est en pixels physiques absolus du virtual desktop. Si ailleurs dans le code des pourcentages sont calculés AVEC mss.monitors[1]['height'] qui peut être 60 → division par 60 → y_pct × 1600 = grand nombre. Le bug Y ÷27 vient probablement de là, pas du clic en sortie.

Action : grep -rn "monitors\[" agent_v0/agent_v1/ et auditer chaque site. Hors scope de ce doc.


5. Recommandations P1 — Détection + recovery freeze NoMachine

Détection (côté client Léa)

Ajouter à capturer.py un thread heartbeat de monitoring :

# pseudocode pour ajout heartbeat dans VisionCapturer
import threading, time

class FreezeDetector(threading.Thread):
    def __init__(self, capturer, on_freeze_callback,
                 interval_s=5.0, freeze_threshold_s=30.0):
        super().__init__(daemon=True)
        self.capturer = capturer
        self.callback = on_freeze_callback
        self.interval = interval_s
        self.threshold = freeze_threshold_s
        self._last_hash = None
        self._last_change_ts = time.time()
        self._stop = threading.Event()

    def run(self):
        while not self._stop.is_set():
            try:
                _mon, img = _acquire_safe_grab()
                if img is not None:
                    h = self.capturer._compute_quick_hash(img)
                    now = time.time()
                    if h != self._last_hash:
                        self._last_hash = h
                        self._last_change_ts = now
                    elif (now - self._last_change_ts) > self.threshold:
                        self.callback(stale_for_s=(now - self._last_change_ts))
                        self._last_change_ts = now  # éviter re-trigger en rafale
            except Exception:
                pass
            self._stop.wait(self.interval)

    def stop(self):
        self._stop.set()

Recovery (côté serveur / VWB)

Sur réception d'un heartbeat enrichi remote_session_status="frozen_suspected" :

  1. Pause replay (replay_paused=True) + bulle Léa "Connexion NoMachine figée détectée".
  2. Dialog VWB côté Dom : [Restart NoMachine] [J'ai débloqué, reprendre] [Stop].
  3. Tracer le freeze dans data/runner_captures/freeze_events.jsonl pour stats post-démo.

6. Dépendances / liens avec AXE B1 (transport)

Le bug coord Y et le freeze NoMachine ont une interaction critique avec le bug transport diagnostiqué le 8 mai (REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md) :

  • Si client Léa freeze (NoMachine) ET timeout HTTP client = 5 s → l'action est doublement perdue.
  • Le watchdog _retry_pending côté serveur (fix moyen-terme du doc 8 mai) doit s'articuler avec le heartbeat freeze : ne PAS re-dispatcher une action si la session client est suspectée gelée (sinon empilement).

Recommandation transverse : ne pas implémenter le watchdog _retry_pending sans intégrer le heartbeat freeze. Sinon on multiplie les clics fantômes pendant un freeze.


7. Sources

Bug mss DPI / monitors

DXGI / dxcam / windows-capture

DPI awareness Windows

NoMachine freeze (forum officiel)

Alternatives remote desktop

Citrix / RDP / accessibility tree

Cradle / agent computer use


Document de recherche, lecture seule. Toute implémentation nécessite arbitrage explicite de Dom — la migration mss → dxcam est NON urgente tant que la ceinture _acquire_safe_grab tient. Le fix DPI awareness §4.1 est par contre chirurgical, 8 lignes, à valider rapidement (post-démo client Anouste).