""" Tests de robustesse visuelle — Grounding VLM qwen2.5vl:7b. Objectifs : 1. Reproductibilité : même screenshot + même cible → même résultat 10 fois 2. Robustesse Citrix : screenshots compressés JPEG qualité 15-25 → ça marche 3. Mesure de variance : coordonnées stables à < 5% de l'écran Architecture des coordonnées qwen2.5vl : - Format bbox_2d : [x1, y1, x2, y2] en pixels relatifs à l'image envoyée - Pour une image 1280x800, X va de 0 à 1280 et Y de 0 à 800 - Normalisation : diviser par les dimensions de l'image (pas par 1000) Calibration mesurée (5 avril 2026) sur screenshots 1280x800 : - shot_0001/Rechercher (taskbar) : cx=0.458, cy=0.789 - shot_0001/agent_v1 (dossier) : cx=0.247, cy=0.201 - shot_0004/Fichier (menu) : cx=0.095, cy=0.086 - shot_0004/Modifier (menu) : cx=0.142, cy=0.085 - shot_0004/Ceci est un test.txt (onglet): cx=0.694, cy=0.053 - shot_0004/Close X (Bloc-notes) : cx=0.990, cy=0.041 - shot_0014/Google search (centre) : cx=0.539, cy=0.389 - shot_0014/Gmail (haut-droite) : cx=0.913, cy=0.130 """ import base64 import io import json import re import statistics import sys import time from pathlib import Path from typing import Dict, List, Optional, Tuple import pytest _ROOT = str(Path(__file__).resolve().parents[2]) if _ROOT not in sys.path: sys.path.insert(0, _ROOT) # Répertoire des screenshots de test _SHOTS_DIR = ( Path(_ROOT) / "data/training/live_sessions/DESKTOP-ST3VBSD_windows" / "sess_20260404T135010_cec5c8/shots" ) # Résolution des screenshots _SCREEN_W = 1280 _SCREEN_H = 800 # Nombre de répétitions pour les tests de reproductibilité _N_REPEATS = 10 # Tolérance de variance maximale (en fraction de l'écran, 0.05 = 5%) _MAX_VARIANCE = 0.05 # Taux de détection minimal (X sur _N_REPEATS) _MIN_DETECTION_RATE = 8 # ========================================================================= # Utilitaires # ========================================================================= def _load_screenshot(name: str) -> Optional[str]: """Charger un screenshot en base64.""" path = _SHOTS_DIR / name if not path.is_file(): pytest.skip(f"Screenshot {name} non disponible") return base64.b64encode(path.read_bytes()).decode() def _degrade_citrix(screenshot_b64: str, quality: int = 20) -> str: """Simuler compression Citrix : JPEG qualité basse puis retour PNG b64.""" from PIL import Image raw = base64.b64decode(screenshot_b64) img = Image.open(io.BytesIO(raw)) # Compression JPEG qualité basse (simulation Citrix) buf_jpg = io.BytesIO() img.save(buf_jpg, "JPEG", quality=quality) buf_jpg.seek(0) citrix_img = Image.open(buf_jpg) # Re-encoder en PNG pour l'envoi au VLM buf_png = io.BytesIO() citrix_img.save(buf_png, "PNG") return base64.b64encode(buf_png.getvalue()).decode() def _grounding_vlm( screenshot_b64: str, element_description: str, timeout: int = 60, ) -> Tuple[Optional[float], Optional[float], Optional[List[int]], str]: """Appeler qwen2.5vl pour localiser un élément. Retourne (cx, cy, [x1,y1,x2,y2], raw_content). cx et cy sont les centres normalisés sur la grille 1000. """ import requests try: resp = requests.post( "http://localhost:11434/api/chat", json={ "model": "qwen2.5vl:7b", "messages": [ { "role": "user", "content": ( f"Detect the element '{element_description}' " f"with a bounding box." ), "images": [screenshot_b64], } ], "stream": False, "options": {"temperature": 0.1, "num_predict": 100}, }, timeout=timeout, ) except requests.ConnectionError: pytest.skip("Ollama non disponible (localhost:11434)") except requests.Timeout: pytest.skip("qwen2.5vl timeout — modèle en cours de chargement ?") content = resp.json().get("message", {}).get("content", "") # Parser bbox_2d depuis la réponse JSON # qwen2.5vl retourne des coordonnées en pixels relatifs à l'image envoyée, # PAS sur une grille 1000x1000. bbox_match = re.search( r'"bbox_2d"\s*:\s*\[(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\]', content, ) if bbox_match: x1, y1, x2, y2 = [int(bbox_match.group(i)) for i in range(1, 5)] # Normaliser par les dimensions de l'image (pixels → 0-1) cx = (x1 + x2) / 2 / _SCREEN_W cy = (y1 + y2) / 2 / _SCREEN_H return cx, cy, [x1, y1, x2, y2], content return None, None, None, content def _run_n_times( screenshot_b64: str, description: str, n: int = _N_REPEATS, delay: float = 0.2, ) -> List[Dict]: """Exécuter le grounding N fois et collecter les résultats.""" results = [] for i in range(n): cx, cy, bbox, raw = _grounding_vlm(screenshot_b64, description) results.append({ "run": i + 1, "cx": cx, "cy": cy, "bbox": bbox, "detected": cx is not None, "raw": raw, }) if i < n - 1: time.sleep(delay) return results def _compute_stats(results: List[Dict]) -> Dict: """Calculer les statistiques de détection et de variance.""" detected = [r for r in results if r["detected"]] n_total = len(results) n_detected = len(detected) stats = { "total": n_total, "detected": n_detected, "rate": n_detected / n_total if n_total > 0 else 0, "rate_str": f"{n_detected}/{n_total}", } if n_detected >= 2: xs = [r["cx"] for r in detected] ys = [r["cy"] for r in detected] stats.update({ "x_min": min(xs), "x_max": max(xs), "x_mean": statistics.mean(xs), "x_range": max(xs) - min(xs), "x_stdev": statistics.stdev(xs) if n_detected >= 2 else 0, "y_min": min(ys), "y_max": max(ys), "y_mean": statistics.mean(ys), "y_range": max(ys) - min(ys), "y_stdev": statistics.stdev(ys) if n_detected >= 2 else 0, }) elif n_detected == 1: stats.update({ "x_min": detected[0]["cx"], "x_max": detected[0]["cx"], "x_mean": detected[0]["cx"], "x_range": 0, "x_stdev": 0, "y_min": detected[0]["cy"], "y_max": detected[0]["cy"], "y_mean": detected[0]["cy"], "y_range": 0, "y_stdev": 0, }) return stats def _assert_reproducible( stats: Dict, element_name: str, min_rate: int = _MIN_DETECTION_RATE, max_var: float = _MAX_VARIANCE, ): """Vérifier la reproductibilité : taux de détection + variance faible.""" assert stats["detected"] >= min_rate, ( f"{element_name}: seulement {stats['rate_str']} détections " f"(minimum requis: {min_rate}/{stats['total']})" ) if stats["detected"] >= 2: assert stats["x_range"] < max_var, ( f"{element_name}: variance X trop élevée: " f"{stats['x_range']:.4f} (max={max_var})" ) assert stats["y_range"] < max_var, ( f"{element_name}: variance Y trop élevée: " f"{stats['y_range']:.4f} (max={max_var})" ) def _assert_in_zone( stats: Dict, zone: Dict[str, float], element_name: str, ): """Vérifier que la position moyenne est dans la zone attendue.""" assert stats["detected"] >= 1, f"{element_name}: aucune détection" cx = stats["x_mean"] cy = stats["y_mean"] assert zone["x_min"] <= cx <= zone["x_max"], ( f"{element_name}: X moyen {cx:.4f} hors zone " f"[{zone['x_min']:.2f}-{zone['x_max']:.2f}]" ) assert zone["y_min"] <= cy <= zone["y_max"], ( f"{element_name}: Y moyen {cy:.4f} hors zone " f"[{zone['y_min']:.2f}-{zone['y_max']:.2f}]" ) # ========================================================================= # Zones calibrées (mesurées le 5 avril 2026) # ========================================================================= CALIBRATED_ZONES = { # shot_0001 — Explorateur de fichiers Windows "rechercher_taskbar": { "x_min": 0.40, "x_max": 0.60, "y_min": 0.74, "y_max": 0.84, }, "agent_v1_folder": { "x_min": 0.18, "x_max": 0.30, "y_min": 0.16, "y_max": 0.26, }, # shot_0004 — Bloc-notes avec onglets "fichier_menu": { "x_min": 0.06, "x_max": 0.13, "y_min": 0.06, "y_max": 0.12, }, "modifier_menu": { "x_min": 0.11, "x_max": 0.18, "y_min": 0.06, "y_max": 0.12, }, "ceci_est_un_test_tab": { "x_min": 0.65, "x_max": 0.75, "y_min": 0.03, "y_max": 0.08, }, "close_x_notepad": { "x_min": 0.95, "x_max": 1.02, "y_min": 0.02, "y_max": 0.06, }, # shot_0014 — Google Chrome "google_search_bar": { "x_min": 0.48, "x_max": 0.60, "y_min": 0.35, "y_max": 0.43, }, "gmail_link": { "x_min": 0.87, "x_max": 0.95, "y_min": 0.10, "y_max": 0.16, }, } # ========================================================================= # Tests de reproductibilité — 10 appels consécutifs # ========================================================================= @pytest.mark.visual class TestReproductibilite: """Chaque test appelle le VLM 10 fois et vérifie la cohérence. Critères de réussite : - Au moins 8/10 détections - Variance des coordonnées < 5% de l'écran sur chaque axe - Position moyenne dans la zone calibrée """ # -- shot_0001 : Explorateur de fichiers -- @pytest.fixture(scope="class") def shot_0001(self): return _load_screenshot("shot_0001_full.png") def test_rechercher_10_fois(self, shot_0001): """Le VLM trouve 'Rechercher' au même endroit 10 fois de suite.""" results = _run_n_times( shot_0001, "the 'Rechercher' search text in the Windows taskbar at the bottom", ) stats = _compute_stats(results) _assert_reproducible(stats, "Rechercher (taskbar)") _assert_in_zone(stats, CALIBRATED_ZONES["rechercher_taskbar"], "Rechercher") # Afficher le résumé pour le rapport print(f"\n [Rechercher] {stats['rate_str']} détections, " f"X=[{stats.get('x_min', 0):.4f}-{stats.get('x_max', 0):.4f}], " f"Y=[{stats.get('y_min', 0):.4f}-{stats.get('y_max', 0):.4f}]") def test_agent_v1_10_fois(self, shot_0001): """Le VLM trouve le dossier 'agent_v1' au même endroit 10 fois.""" results = _run_n_times( shot_0001, "the folder named 'agent_v1' in the file list", ) stats = _compute_stats(results) _assert_reproducible(stats, "agent_v1 (dossier)") _assert_in_zone(stats, CALIBRATED_ZONES["agent_v1_folder"], "agent_v1") print(f"\n [agent_v1] {stats['rate_str']} détections, " f"X=[{stats.get('x_min', 0):.4f}-{stats.get('x_max', 0):.4f}], " f"Y=[{stats.get('y_min', 0):.4f}-{stats.get('y_max', 0):.4f}]") def test_close_x_explorateur_10_fois(self, shot_0001): """Le bouton X de la fenêtre maximisée : overflow X attendu. Ce test vérifie que le VLM détecte bien le bouton X de façon cohérente. Sur les fenêtres maximisées (1280px de large), les coordonnées X dépassent la grille 1000 normalisée (cx > 1.0). Note : le VLM peut parfois confondre le bouton X de la fenêtre avec celui de l'onglet (ambiguïté multiple close buttons). On vérifie que la majorité des détections ciblent le bon bouton. """ results = _run_n_times( shot_0001, "the X close button of the 'Lea' window", ) # Vérifier que le VLM détecte bien quelque chose detected = [r for r in results if r["detected"]] assert len(detected) >= _MIN_DETECTION_RATE, ( f"Close X: seulement {len(detected)}/{len(results)} détections" ) # Classer les détections : overflow (bouton fenêtre) vs non-overflow (bouton onglet) overflows = [r for r in detected if r["cx"] > 1.0] non_overflows = [r for r in detected if r["cx"] <= 1.0] # Au moins 60% des détections doivent viser le bouton fenêtre (overflow) assert len(overflows) >= len(detected) * 0.6, ( f"Close X: seulement {len(overflows)}/{len(detected)} en overflow. " f"Ambiguïté avec bouton onglet ({len(non_overflows)} non-overflow)." ) # Vérifier la cohérence des détections overflow (le cluster principal) if len(overflows) >= 2: bboxes = [r["bbox"] for r in overflows] x1s = [b[0] for b in bboxes] y1s = [b[1] for b in bboxes] assert max(x1s) - min(x1s) < 20, ( f"Close X overflow: x1 trop variable: {min(x1s)}-{max(x1s)}" ) assert max(y1s) - min(y1s) < 20, ( f"Close X overflow: y1 trop variable: {min(y1s)}-{max(y1s)}" ) print(f"\n [Close X Explorer] {len(detected)}/{len(results)} détections, " f"{len(overflows)} overflow (fenêtre), {len(non_overflows)} non-overflow (onglet). " f"cx_mean_overflow={statistics.mean([r['cx'] for r in overflows]):.4f}" if overflows else "") # -- shot_0004 : Bloc-notes -- @pytest.fixture(scope="class") def shot_0004(self): return _load_screenshot("shot_0004_full.png") def test_fichier_10_fois(self, shot_0004): """Le VLM trouve le menu 'Fichier' au même endroit 10 fois.""" results = _run_n_times( shot_0004, "the 'Fichier' menu item in the menu bar", ) stats = _compute_stats(results) _assert_reproducible(stats, "Fichier (menu)") _assert_in_zone(stats, CALIBRATED_ZONES["fichier_menu"], "Fichier") print(f"\n [Fichier] {stats['rate_str']} détections, " f"X=[{stats.get('x_min', 0):.4f}-{stats.get('x_max', 0):.4f}], " f"Y=[{stats.get('y_min', 0):.4f}-{stats.get('y_max', 0):.4f}]") def test_modifier_10_fois(self, shot_0004): """Le VLM trouve le menu 'Modifier' au même endroit 10 fois.""" results = _run_n_times( shot_0004, "the 'Modifier' menu item in the menu bar", ) stats = _compute_stats(results) _assert_reproducible(stats, "Modifier (menu)") _assert_in_zone(stats, CALIBRATED_ZONES["modifier_menu"], "Modifier") print(f"\n [Modifier] {stats['rate_str']} détections, " f"X=[{stats.get('x_min', 0):.4f}-{stats.get('x_max', 0):.4f}], " f"Y=[{stats.get('y_min', 0):.4f}-{stats.get('y_max', 0):.4f}]") def test_ceci_est_un_test_10_fois(self, shot_0004): """Le VLM trouve l'onglet 'Ceci est un test.txt' au même endroit 10 fois.""" results = _run_n_times( shot_0004, "the tab labeled 'Ceci est un test.txt'", ) stats = _compute_stats(results) _assert_reproducible(stats, "Ceci est un test.txt (onglet)") _assert_in_zone(stats, CALIBRATED_ZONES["ceci_est_un_test_tab"], "Ceci est un test.txt") print(f"\n [Ceci est un test.txt] {stats['rate_str']} détections, " f"X=[{stats.get('x_min', 0):.4f}-{stats.get('x_max', 0):.4f}], " f"Y=[{stats.get('y_min', 0):.4f}-{stats.get('y_max', 0):.4f}]") # -- shot_0014 : Google Chrome -- @pytest.fixture(scope="class") def shot_0014(self): return _load_screenshot("shot_0014_full.png") def test_google_search_10_fois(self, shot_0014): """Le VLM trouve la barre de recherche Google au même endroit 10 fois.""" results = _run_n_times( shot_0014, "the Google search bar 'Rechercher sur Google ou saisir une URL'", ) stats = _compute_stats(results) _assert_reproducible(stats, "Recherche Google") _assert_in_zone(stats, CALIBRATED_ZONES["google_search_bar"], "Recherche Google") print(f"\n [Google search] {stats['rate_str']} détections, " f"X=[{stats.get('x_min', 0):.4f}-{stats.get('x_max', 0):.4f}], " f"Y=[{stats.get('y_min', 0):.4f}-{stats.get('y_max', 0):.4f}]") def test_gmail_10_fois(self, shot_0014): """Le VLM trouve le lien Gmail au même endroit 10 fois.""" results = _run_n_times( shot_0014, "the 'Gmail' link at the top of the page", ) stats = _compute_stats(results) _assert_reproducible(stats, "Gmail") _assert_in_zone(stats, CALIBRATED_ZONES["gmail_link"], "Gmail") print(f"\n [Gmail] {stats['rate_str']} détections, " f"X=[{stats.get('x_min', 0):.4f}-{stats.get('x_max', 0):.4f}], " f"Y=[{stats.get('y_min', 0):.4f}-{stats.get('y_max', 0):.4f}]") # ========================================================================= # Tests de robustesse Citrix — JPEG dégradé # ========================================================================= @pytest.mark.visual class TestCitrixRobustesse: """Vérifier que le grounding fonctionne sur des images compressées. Simule un environnement Citrix/RDP avec compression JPEG qualité 15-25. Compare les résultats original vs dégradé. """ @pytest.fixture(scope="class") def shots_original(self): return { "shot_0001": _load_screenshot("shot_0001_full.png"), "shot_0004": _load_screenshot("shot_0004_full.png"), "shot_0014": _load_screenshot("shot_0014_full.png"), } @pytest.fixture(scope="class") def shots_citrix(self, shots_original): return { name: _degrade_citrix(b64, quality=20) for name, b64 in shots_original.items() } def _compare_original_vs_citrix( self, original_b64: str, citrix_b64: str, description: str, element_name: str, zone: Dict, n_runs: int = 5, ) -> Dict: """Comparer les résultats original vs Citrix.""" # 5 runs sur l'original results_orig = _run_n_times(original_b64, description, n=n_runs, delay=0.2) stats_orig = _compute_stats(results_orig) # 5 runs sur le Citrix results_citrix = _run_n_times(citrix_b64, description, n=n_runs, delay=0.2) stats_citrix = _compute_stats(results_citrix) return { "original": stats_orig, "citrix": stats_citrix, } def test_rechercher_citrix(self, shots_original, shots_citrix): """'Rechercher' détecté malgré compression JPEG Q20.""" comp = self._compare_original_vs_citrix( shots_original["shot_0001"], shots_citrix["shot_0001"], "the 'Rechercher' search text in the Windows taskbar at the bottom", "Rechercher", CALIBRATED_ZONES["rechercher_taskbar"], ) # Au moins 3/5 détections sur Citrix assert comp["citrix"]["detected"] >= 3, ( f"Citrix Rechercher: seulement {comp['citrix']['rate_str']} détections" ) # Position dans la zone calibrée if comp["citrix"]["detected"] >= 1: _assert_in_zone(comp["citrix"], CALIBRATED_ZONES["rechercher_taskbar"], "Rechercher (Citrix)") print(f"\n [Rechercher Citrix] orig={comp['original']['rate_str']}, " f"citrix={comp['citrix']['rate_str']}") def test_fichier_citrix(self, shots_original, shots_citrix): """Menu 'Fichier' détecté malgré compression JPEG Q20.""" comp = self._compare_original_vs_citrix( shots_original["shot_0004"], shots_citrix["shot_0004"], "the 'Fichier' menu item in the menu bar", "Fichier", CALIBRATED_ZONES["fichier_menu"], ) assert comp["citrix"]["detected"] >= 3, ( f"Citrix Fichier: seulement {comp['citrix']['rate_str']} détections" ) if comp["citrix"]["detected"] >= 1: _assert_in_zone(comp["citrix"], CALIBRATED_ZONES["fichier_menu"], "Fichier (Citrix)") print(f"\n [Fichier Citrix] orig={comp['original']['rate_str']}, " f"citrix={comp['citrix']['rate_str']}") def test_ceci_est_un_test_citrix(self, shots_original, shots_citrix): """Onglet 'Ceci est un test.txt' détecté malgré compression JPEG Q20.""" comp = self._compare_original_vs_citrix( shots_original["shot_0004"], shots_citrix["shot_0004"], "the tab labeled 'Ceci est un test.txt'", "Ceci est un test.txt", CALIBRATED_ZONES["ceci_est_un_test_tab"], ) assert comp["citrix"]["detected"] >= 3, ( f"Citrix tab: seulement {comp['citrix']['rate_str']} détections" ) if comp["citrix"]["detected"] >= 1: _assert_in_zone( comp["citrix"], CALIBRATED_ZONES["ceci_est_un_test_tab"], "Ceci est un test.txt (Citrix)", ) print(f"\n [Ceci est un test.txt Citrix] orig={comp['original']['rate_str']}, " f"citrix={comp['citrix']['rate_str']}") def test_google_search_citrix(self, shots_original, shots_citrix): """Barre de recherche Google détectée malgré compression JPEG Q20.""" comp = self._compare_original_vs_citrix( shots_original["shot_0014"], shots_citrix["shot_0014"], "the Google search bar 'Rechercher sur Google ou saisir une URL'", "Recherche Google", CALIBRATED_ZONES["google_search_bar"], ) assert comp["citrix"]["detected"] >= 3, ( f"Citrix Google: seulement {comp['citrix']['rate_str']} détections" ) if comp["citrix"]["detected"] >= 1: _assert_in_zone( comp["citrix"], CALIBRATED_ZONES["google_search_bar"], "Recherche Google (Citrix)", ) print(f"\n [Google search Citrix] orig={comp['original']['rate_str']}, " f"citrix={comp['citrix']['rate_str']}") def test_gmail_citrix(self, shots_original, shots_citrix): """Lien Gmail détecté malgré compression JPEG Q20.""" comp = self._compare_original_vs_citrix( shots_original["shot_0014"], shots_citrix["shot_0014"], "the 'Gmail' link at the top of the page", "Gmail", CALIBRATED_ZONES["gmail_link"], ) assert comp["citrix"]["detected"] >= 3, ( f"Citrix Gmail: seulement {comp['citrix']['rate_str']} détections" ) if comp["citrix"]["detected"] >= 1: _assert_in_zone(comp["citrix"], CALIBRATED_ZONES["gmail_link"], "Gmail (Citrix)") print(f"\n [Gmail Citrix] orig={comp['original']['rate_str']}, " f"citrix={comp['citrix']['rate_str']}") # ========================================================================= # Tests de dégradation progressive — qualité JPEG 50 → 15 → 5 # ========================================================================= @pytest.mark.visual class TestDegradationProgressive: """Mesurer à partir de quelle qualité JPEG le grounding échoue.""" @pytest.fixture(scope="class") def shot_0004(self): return _load_screenshot("shot_0004_full.png") def test_fichier_degradation_progressive(self, shot_0004): """Fichier menu : tester JPEG Q50, Q25, Q15, Q10, Q5.""" qualities = [50, 25, 15, 10, 5] results_by_quality = {} for q in qualities: degraded = _degrade_citrix(shot_0004, quality=q) results = _run_n_times( degraded, "the 'Fichier' menu item in the menu bar", n=3, delay=0.2, ) stats = _compute_stats(results) results_by_quality[q] = stats # Afficher le rapport de dégradation print("\n === Dégradation progressive : Fichier menu ===") for q in qualities: s = results_by_quality[q] zone_ok = "" if s["detected"] >= 1: cx = s["x_mean"] cy = s["y_mean"] z = CALIBRATED_ZONES["fichier_menu"] in_zone = z["x_min"] <= cx <= z["x_max"] and z["y_min"] <= cy <= z["y_max"] zone_ok = " (in zone)" if in_zone else f" (HORS zone: {cx:.3f},{cy:.3f})" print(f" Q{q:>2}: {s['rate_str']} détections{zone_ok}") # Au moins Q50 et Q25 doivent fonctionner assert results_by_quality[50]["detected"] >= 2, "Q50 devrait fonctionner" assert results_by_quality[25]["detected"] >= 2, "Q25 devrait fonctionner" # ========================================================================= # Rapport final — exécuté en dernier, résume tout # ========================================================================= @pytest.mark.visual class TestRapportFinal: """Rapport complet des capacités de grounding VLM. Ce test exécute une batterie de détections et produit un rapport structuré avec taux de détection, variance, et comparaison Citrix. """ def test_rapport_complet(self): """Génère le rapport final de robustesse du grounding VLM.""" from PIL import Image shots = { "shot_0001": _load_screenshot("shot_0001_full.png"), "shot_0004": _load_screenshot("shot_0004_full.png"), "shot_0014": _load_screenshot("shot_0014_full.png"), } targets = [ ("shot_0001", "Rechercher (taskbar)", "the 'Rechercher' search text in the Windows taskbar at the bottom", CALIBRATED_ZONES["rechercher_taskbar"]), ("shot_0001", "agent_v1 (dossier)", "the folder named 'agent_v1' in the file list", CALIBRATED_ZONES["agent_v1_folder"]), ("shot_0004", "Fichier (menu)", "the 'Fichier' menu item in the menu bar", CALIBRATED_ZONES["fichier_menu"]), ("shot_0004", "Modifier (menu)", "the 'Modifier' menu item in the menu bar", CALIBRATED_ZONES["modifier_menu"]), ("shot_0004", "Ceci est un test.txt (onglet)", "the tab labeled 'Ceci est un test.txt'", CALIBRATED_ZONES["ceci_est_un_test_tab"]), ("shot_0004", "Close X (Bloc-notes)", "the close button X of the Notepad window at the top right", CALIBRATED_ZONES["close_x_notepad"]), ("shot_0014", "Recherche Google (barre)", "the Google search bar 'Rechercher sur Google ou saisir une URL'", CALIBRATED_ZONES["google_search_bar"]), ("shot_0014", "Gmail (lien)", "the 'Gmail' link at the top of the page", CALIBRATED_ZONES["gmail_link"]), ] report_lines = [ "", "=" * 80, "RAPPORT DE ROBUSTESSE — Grounding VLM qwen2.5vl:7b", f"Date: {time.strftime('%Y-%m-%d %H:%M:%S')}", f"Screenshots: 1280x800 (3 images, {len(targets)} cibles)", f"Répétitions: 5 par cible (original + Citrix Q20)", "=" * 80, "", "--- ORIGINAL (PNG) ---", f"{'Élément':<35} {'Taux':>6} {'X moy':>8} {'Y moy':>8} " f"{'Var X':>8} {'Var Y':>8} {'Zone':>6}", "-" * 80, ] all_original_stats = [] all_citrix_stats = [] for shot_name, label, desc, zone in targets: # Original : 5 runs results_orig = _run_n_times(shots[shot_name], desc, n=5, delay=0.2) stats_orig = _compute_stats(results_orig) all_original_stats.append((label, stats_orig, zone)) in_zone = "?" if stats_orig["detected"] >= 1: cx, cy = stats_orig["x_mean"], stats_orig["y_mean"] ok = (zone["x_min"] <= cx <= zone["x_max"] and zone["y_min"] <= cy <= zone["y_max"]) in_zone = "OK" if ok else "HORS" report_lines.append( f"{label:<35} {stats_orig['rate_str']:>6} " f"{stats_orig.get('x_mean', 0):>8.4f} " f"{stats_orig.get('y_mean', 0):>8.4f} " f"{stats_orig.get('x_range', 0):>8.4f} " f"{stats_orig.get('y_range', 0):>8.4f} " f"{in_zone:>6}" ) report_lines.extend([ "", "--- CITRIX (JPEG Q20) ---", f"{'Élément':<35} {'Taux':>6} {'X moy':>8} {'Y moy':>8} " f"{'Var X':>8} {'Var Y':>8} {'Zone':>6} {'Écart orig':>10}", "-" * 90, ]) for i, (shot_name, label, desc, zone) in enumerate(targets): citrix_b64 = _degrade_citrix(shots[shot_name], quality=20) results_citrix = _run_n_times(citrix_b64, desc, n=5, delay=0.2) stats_citrix = _compute_stats(results_citrix) all_citrix_stats.append((label, stats_citrix, zone)) in_zone = "?" ecart = "N/A" if stats_citrix["detected"] >= 1: cx, cy = stats_citrix["x_mean"], stats_citrix["y_mean"] ok = (zone["x_min"] <= cx <= zone["x_max"] and zone["y_min"] <= cy <= zone["y_max"]) in_zone = "OK" if ok else "HORS" # Calculer l'écart avec l'original orig_stats = all_original_stats[i][1] if orig_stats["detected"] >= 1: dx = abs(cx - orig_stats["x_mean"]) dy = abs(cy - orig_stats["y_mean"]) ecart = f"{dx:.4f}/{dy:.4f}" report_lines.append( f"{label:<35} {stats_citrix['rate_str']:>6} " f"{stats_citrix.get('x_mean', 0):>8.4f} " f"{stats_citrix.get('y_mean', 0):>8.4f} " f"{stats_citrix.get('x_range', 0):>8.4f} " f"{stats_citrix.get('y_range', 0):>8.4f} " f"{in_zone:>6} {ecart:>10}" ) # Résumé orig_total = sum(s["detected"] for _, s, _ in all_original_stats) orig_max = sum(s["total"] for _, s, _ in all_original_stats) citrix_total = sum(s["detected"] for _, s, _ in all_citrix_stats) citrix_max = sum(s["total"] for _, s, _ in all_citrix_stats) orig_in_zone = sum( 1 for _, s, z in all_original_stats if s["detected"] >= 1 and z["x_min"] <= s["x_mean"] <= z["x_max"] and z["y_min"] <= s["y_mean"] <= z["y_max"] ) citrix_in_zone = sum( 1 for _, s, z in all_citrix_stats if s["detected"] >= 1 and z["x_min"] <= s["x_mean"] <= z["x_max"] and z["y_min"] <= s["y_mean"] <= z["y_max"] ) # Éléments non fiables unreliable = [] for label, s, _ in all_original_stats: if s["detected"] < 3: unreliable.append(f"{label} (taux {s['rate_str']})") elif s.get("x_range", 0) >= _MAX_VARIANCE or s.get("y_range", 0) >= _MAX_VARIANCE: unreliable.append( f"{label} (variance X={s.get('x_range', 0):.4f} " f"Y={s.get('y_range', 0):.4f})" ) report_lines.extend([ "", "=" * 80, "RÉSUMÉ", "=" * 80, f" Détection original : {orig_total}/{orig_max} " f"({orig_total/orig_max*100:.0f}%)", f" Détection Citrix Q20: {citrix_total}/{citrix_max} " f"({citrix_total/citrix_max*100:.0f}%)", f" Positionnement correct (original) : {orig_in_zone}/{len(all_original_stats)}", f" Positionnement correct (Citrix) : {citrix_in_zone}/{len(all_citrix_stats)}", "", ]) if unreliable: report_lines.append(" ÉLÉMENTS NON FIABLES :") for u in unreliable: report_lines.append(f" - {u}") else: report_lines.append(" Tous les éléments sont fiables.") report_lines.extend([ "", " NOTES TECHNIQUES :", " - qwen2.5vl bbox_2d retourne des pixels relatifs à l'image envoyée", " - Normalisation : diviser par les dimensions de l'image (W, H)", " - temperature=0.1 donne une variance < 0.003 typiquement", "=" * 80, ]) report = "\n".join(report_lines) print(report) # Le test réussit si au moins 80% des détections originales fonctionnent assert orig_total / orig_max >= 0.80, ( f"Taux de détection global trop bas: {orig_total}/{orig_max}" )