Architecture 3 niveaux implémentée et testée (137 tests unitaires + 21 visuels) : MÉSO (acteur intelligent) : - P0 Critic : vérification sémantique post-action via gemma4 (replay_verifier.py) - P1 Observer : pré-analyse écran avant chaque action (api_stream.py /pre_analyze) - P2 Grounding/Policy : séparation localisation (grounding.py) et décision (policy.py) - P3 Recovery : rollback automatique Ctrl+Z/Escape/Alt+F4 (recovery.py) - P4 Learning : apprentissage runtime avec boucle de consolidation (replay_learner.py) MACRO (planificateur) : - TaskPlanner : comprend les ordres en langage naturel via gemma4 (task_planner.py) - Contexte métier TIM/CIM-10 pour les hôpitaux (domain_context.py) - Endpoint POST /api/v1/task pour l'exécution par instruction Traçabilité : - Audit trail complet avec 18 champs par action (audit_trail.py) - Endpoints GET /audit/history, /audit/summary, /audit/export (CSV) Grounding : - Fix parsing bbox_2d qwen2.5vl (pixels relatifs, pas grille 1000x1000) - Benchmarks visuels sur captures réelles (3 approches : baseline, zoom, Citrix) - Reproductibilité validée : variance < 0.008 sur 10 itérations Sécurité : - Tokens de production retirés du code source → .env.local - Secret key aléatoire si non configuré - Suppression logs qui leakent les tokens Résultats : 80% de replay (vs 12.5% avant), 100% détection visuelle Citrix JPEG Q20 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
865 lines
33 KiB
Python
865 lines
33 KiB
Python
"""
|
|
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}"
|
|
)
|