# AXE A3 — Protocole de bench grounding VLM (post smart_resize) **Date** : 2026-05-23 **Auteur** : Claude (session dispatchée AXE A3) **Contexte** : `MIGRATION_VLM_PLAN_2026-05-09.md` §2 a relevé un bug d'échelle `bbox_2d` (cx ≈ 0.17 au lieu de ~0.45-0.55) sur 4 configs Ollama. Le module `core/grounding/smart_resize.py` a été commité (`0d7bcd18a`) mais **jamais reverifié** par un bench end-to-end. DETTE-014 indique qu'il est mal calé (factor 28 vs 32). Ce protocole comble le trou méthodologique. **Statut** : protocole + script prêts. Aucune exécution réelle réalisée par Claude — Dom décide quand lancer. --- ## 1. TL;DR 1. **Une fixture** (`heartbeat_1773792436.png` 2560×1600, dialog OK/Cancel) sur laquelle on connaît la vérité-terrain (bouton OK ≈ mid-screen, `cx ≈ 0.50`). 2. **Trois backends** : Ollama (`qwen2.5vl:7b` = baseline buggy, `qwen3-vl:8b` JSON-explicit), vLLM (`Qwen3-VL-8B-Instruct` avec `resized_width/height` natifs), Transformers in-process (`InfiGUI-G1-3B` actuel + 1-2 SOTA optionnels OS-Atlas/Magma). 3. **Pour chaque modèle** : déchargement VRAM → 1 cold → 10 warm → mesure latence, VRAM pic, format brut, parse OK, `cx_pct` mesuré. 4. **Critère go/no-go** : `cx_pct ∈ [0.40, 0.60]` ET `cy_pct ∈ [0.40, 0.60]` (le bouton OK est au centre du dialog, le dialog est centré écran) ET parse regex prod OK ET latence cold < 12 s. 5. **Livrable** : un CSV `/tmp/bench_grounding_2026-05-23.csv` + overlay PNG par modèle pour validation visuelle. **Go / no-go pour la migration AXE_A2** : si **aucun** modèle ne passe `cx_pct ∈ [0.40, 0.60]`, le bug n'est PAS uniquement `smart_resize` mais aussi côté preprocessing/prompt → escalader avant de remplacer la prod. --- ## 2. Protocole détaillé ### 2.1. Preprocessing image (par backend) | Backend | Resize | resized_w/h passé au modèle | Coords retournées en | |---|---|---|---| | **Ollama** (qwen2.5vl, qwen3-vl) | Implicite côté serveur, opaque | NON (non supporté) | Pixels post-resize OLLAMA (inconnu) ⚠ | | **vLLM** (qwen3-vl-8b) | Côté client via `smart_resize` officiel | OUI (via `min_pixels`/`max_pixels` extra body) | Pixels post-resize CLIENT (connu) | | **Transformers** (InfiGUI, OS-Atlas, Magma) | Côté script via `core/grounding/smart_resize.py` | OUI (passé au `processor`) | Pixels post-resize CLIENT (connu) | **Côté script** : on calcule `(rH, rW) = smart_resize(H, W)` sur l'image originale en utilisant `core.grounding.smart_resize` (factor 28 par défaut, à challenger après lecture du `probe_qwen3vl_processor.py` qui dump le factor effectif via `patch_size × merge_size`). On envoie l'image **redimensionnée** au backend (sauf Ollama qui re-resize de toute façon). ### 2.2. Prompt (par famille de modèle) - **Qwen2.5-VL** (baseline) : `Detect 'OK button' in this image with a bounding box.` (prompt actuel `resolve_engine.py:942`) - **Qwen3-VL Instruct** : prompt JSON explicite obligatoire — `Locate the "OK" button. Return ONLY this JSON: {"bbox_2d":[x1,y1,x2,y2],"label":"OK"}.` (Sans cette directive, sortie liste nue cf. MIGRATION_VLM_PLAN §2) - **InfiGUI-G1-3B** : prompt du worker existant (`infigui_worker.py:130-135`) — `The screen's resolution is {rW}x{rH}. Locate the UI element(s) for "OK button", output the coordinates using JSON format: [{"point_2d": [x, y]}, ...]` + system avec `` - **OS-Atlas-Base-7B** (Qwen2-VL-7B-FT) : `In the image, please find the bbox of "OK button". Output format: [[x1,y1,x2,y2]] with each value in [0,1000].` - **Magma-8B** : pas dans le périmètre v0 (Set-of-Mark requiert SomEngine, hors A3) — documenter comme extension. ### 2.3. Parsing sortie Réutiliser `core.grounding.bbox_parser.parse_bbox_to_norm(content, divisor_w, divisor_h)`. **CLEF du bench** : appeler avec `divisor_w = rW` et `divisor_h = rH` (post-resize), pas avec `orig_w`/`orig_h`. C'est le fix qu'AXE_A2 documente comme nécessaire. Pour OS-Atlas (sortie 0-1000), parser à part puis convertir : `cx_pct = (x1 + x2) / 2 / 1000`. ### 2.4. Métriques mesurées (par modèle, en sortie CSV) | Colonne | Méthode | |---|---| | `model` | nom + backend | | `cold_s` | 1er appel après `unload_all` (Ollama) ou après reload process (Transformers) | | `warm_avg_s`, `warm_p50_s`, `warm_p95_s` | sur 10 runs warm (même image, même prompt) | | `vram_pic_mib` | sample `nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits` toutes 200 ms pendant l'appel, max retenu | | `raw_output` | premier 250 char de la réponse brute | | `format_detected` | `bbox_2d` / `point_2d` / `xy_json` / `raw_array` / `unknown` | | `parse_ok` | bool, parser regex prod renvoie un (x, y) | | `cx_pct`, `cy_pct` | coords normalisées calculées avec `divisor = (rW, rH)` | | `validated` | bool, `cx_pct ∈ [0.40, 0.60]` ET `cy_pct ∈ [0.40, 0.60]` | | `error` | string si exception/timeout | ### 2.5. Overlay PNG pour validation visuelle Pour chaque modèle qui retourne un (cx_pct, cy_pct), générer `bench_grounding_.png` = fixture + croix rouge au point retourné + texte ` cx=0.XX cy=0.YY`. Dom regarde les overlays côte-à-côte. --- ## 3. Script Python autonome À créer en `/home/dom/ai/rpa_vision_v3/tools/bench_grounding_2026-05-23.py`. Code prêt à coller : ```python #!/usr/bin/env python3 """Bench grounding VLM — AXE A3 post smart_resize (2026-05-23). Objectif : refaire le bench bbox_2d du 8 mai 2026 après commit du module `core/grounding/smart_resize.py` (0d7bcd18a) sur la fixture interne du projet. Mesure latence cold/warm, VRAM pic, format brut, parse OK, cx_pct/cy_pct mesuré contre vérité-terrain visuelle (bouton OK ≈ centre écran). Usage : .venv/bin/python tools/bench_grounding_2026-05-23.py [--models qwen25vl_ollama,qwen3vl_ollama,qwen3vl_vllm,infigui_tx] [--warm 10] [--out /tmp/bench_grounding_2026-05-23.csv] [--overlay-dir /tmp/bench_grounding_overlays] Pré-requis runtime (à confirmer avant lancement, cf. §4) : - Ollama tourne sur :11434 avec qwen2.5vl:7b et qwen3-vl:8b pull - vLLM tourne sur :8100 avec Qwen3-VL-8B-Instruct (cf. §4) - Transformers : .venv contient transformers + bitsandbytes - nvidia-smi accessible (mesure VRAM) """ from __future__ import annotations import argparse import base64 import csv import io import json import math import os import re import statistics import subprocess import sys import threading import time from dataclasses import dataclass, field from pathlib import Path from typing import Any, Optional import requests from PIL import Image, ImageDraw, ImageFont # Ajout du repo au path pour réutiliser core.grounding.* REPO_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO_ROOT)) from core.grounding.smart_resize import smart_resize # noqa: E402 from core.grounding.bbox_parser import parse_bbox_to_norm # noqa: E402 # ============================================================================ # Configuration # ============================================================================ FIXTURE_PATH = REPO_ROOT / "data/training/live_sessions/bg_DESKTOP-58D5CAC_windows/shots/heartbeat_1773792436.png" TARGET_LABEL = "OK button" # Vérité-terrain manuelle (cf. §7) : bouton OK approximativement au centre GT_CX_RANGE = (0.40, 0.60) GT_CY_RANGE = (0.40, 0.60) OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434") VLLM_URL = os.environ.get("VLLM_URL", "http://localhost:8100") # Une entrée = un modèle à bencher. Le champ "runner" identifie l'appel à faire. MODEL_CATALOG: dict[str, dict[str, Any]] = { "qwen25vl_ollama": { "label": "qwen2.5vl:7b (Ollama, baseline buggy)", "runner": "ollama", "ollama_model": "qwen2.5vl:7b", "prompt_style": "qwen25_bbox", "num_predict": 100, "think": None, # n/a }, "qwen3vl_ollama": { "label": "qwen3-vl:8b (Ollama, prompt JSON explicite)", "runner": "ollama", "ollama_model": "qwen3-vl:8b", "prompt_style": "qwen3_bbox_explicit", "num_predict": 128, "think": False, }, "qwen3vl_vllm": { "label": "Qwen3-VL-8B-Instruct (vLLM, resized_w/h natif)", "runner": "vllm", "vllm_model": "Qwen/Qwen3-VL-8B-Instruct", "prompt_style": "qwen3_bbox_explicit", "max_tokens": 128, }, "infigui_tx": { "label": "InfiGUI-G1-3B (Transformers, prod)", "runner": "transformers_infigui", "model_id": "InfiX-ai/InfiGUI-G1-3B", "prompt_style": "infigui_point", }, # Extensions facultatives (AXE A1) — à activer via --models "os_atlas_tx": { "label": "OS-Atlas-Base-7B (Transformers, SOTA grounding)", "runner": "transformers_qwen2vl", "model_id": "OS-Copilot/OS-Atlas-Base-7B", "prompt_style": "os_atlas_bbox_1000", }, } # ============================================================================ # Prompts par style # ============================================================================ def build_prompt(style: str, rW: int, rH: int, label: str) -> tuple[str, str]: """Retourne (system, user). System "" si pas utile.""" if style == "qwen25_bbox": return ( "You locate UI elements on screenshots. Return coordinates.", f"Detect '{label}' in this image with a bounding box.", ) if style == "qwen3_bbox_explicit": return ( "You are a UI element locator. Output raw JSON only. No explanation.", f'Locate the "{label}" in this {rW}x{rH} screenshot. ' f'Return ONLY this JSON object: ' f'{{"bbox_2d":[x1,y1,x2,y2],"label":"{label}"}}', ) if style == "infigui_point": return ( "You FIRST think about the reasoning process as an internal monologue " "and then provide the final answer.\n" "The reasoning process MUST BE enclosed within tags.", f'The screen\'s resolution is {rW}x{rH}.\n' f'Locate the UI element(s) for "{label}", ' f'output the coordinates using JSON format: ' f'[{{"point_2d": [x, y]}}, ...]', ) if style == "os_atlas_bbox_1000": return ( "", f'In the image, please find the bbox of "{label}". ' f'Output the bounding boxes in this format: [[x1,y1,x2,y2]], ' f'where each value is normalized in [0, 1000].', ) raise ValueError(f"prompt_style inconnu : {style}") # ============================================================================ # VRAM monitoring (nvidia-smi sampling) # ============================================================================ class VRAMSampler: """Sample nvidia-smi en thread, expose pic en MiB.""" def __init__(self, interval_s: float = 0.2): self.interval = interval_s self.peak_mib = 0 self._stop = threading.Event() self._thread: Optional[threading.Thread] = None def _loop(self) -> None: while not self._stop.is_set(): try: out = subprocess.check_output( ["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits", "-i", "0"], timeout=2, ).decode().strip() mib = int(out.splitlines()[0]) self.peak_mib = max(self.peak_mib, mib) except Exception: pass self._stop.wait(self.interval) def start(self) -> None: self.peak_mib = 0 self._stop.clear() self._thread = threading.Thread(target=self._loop, daemon=True) self._thread.start() def stop(self) -> int: self._stop.set() if self._thread: self._thread.join(timeout=2) return self.peak_mib # ============================================================================ # Runners par backend # ============================================================================ @dataclass class CallResult: elapsed_s: float raw: str = "" error: str = "" vram_pic_mib: int = 0 def _encode_image(img: Image.Image) -> str: buf = io.BytesIO() img.save(buf, format="JPEG", quality=85) return base64.b64encode(buf.getvalue()).decode("ascii") def _ollama_unload_all() -> None: try: ps = requests.get(f"{OLLAMA_URL}/api/ps", timeout=5).json() for m in ps.get("models", []): requests.post( f"{OLLAMA_URL}/api/generate", json={"model": m["name"], "prompt": "", "keep_alive": 0, "stream": False}, timeout=10, ) except Exception: pass time.sleep(2) def run_ollama(cfg: dict, img: Image.Image, rW: int, rH: int) -> CallResult: system, user = build_prompt(cfg["prompt_style"], rW, rH, TARGET_LABEL) img_resized = img.resize((rW, rH)) img_b64 = _encode_image(img_resized) messages = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": user, "images": [img_b64]}) options: dict[str, Any] = { "temperature": 0.1, "num_predict": cfg.get("num_predict", 128), } payload = { "model": cfg["ollama_model"], "messages": messages, "stream": False, "options": options, } if cfg.get("think") is False: payload["think"] = False sampler = VRAMSampler() sampler.start() t0 = time.perf_counter() try: resp = requests.post(f"{OLLAMA_URL}/api/chat", json=payload, timeout=120) elapsed = time.perf_counter() - t0 vram = sampler.stop() if resp.status_code != 200: return CallResult(elapsed, error=f"HTTP_{resp.status_code}", vram_pic_mib=vram) content = resp.json().get("message", {}).get("content", "") return CallResult(elapsed, raw=content, vram_pic_mib=vram) except Exception as e: sampler.stop() return CallResult(time.perf_counter() - t0, error=f"NET:{type(e).__name__}") def run_vllm(cfg: dict, img: Image.Image, rW: int, rH: int) -> CallResult: system, user = build_prompt(cfg["prompt_style"], rW, rH, TARGET_LABEL) img_resized = img.resize((rW, rH)) img_b64 = _encode_image(img_resized) messages = [] if system: messages.append({"role": "system", "content": system}) messages.append({ "role": "user", "content": [ {"type": "text", "text": user}, {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}, ], }) payload = { "model": cfg["vllm_model"], "messages": messages, "temperature": 0.1, "max_tokens": cfg.get("max_tokens", 128), # vLLM Qwen-VL extension : passer min/max pixels en kwargs # (cf. github QwenLM/Qwen3-VL issue #1434 — peut être ignoré selon version vllm) "mm_processor_kwargs": {"min_pixels": rW * rH, "max_pixels": rW * rH}, } sampler = VRAMSampler() sampler.start() t0 = time.perf_counter() try: resp = requests.post( f"{VLLM_URL}/v1/chat/completions", json=payload, timeout=120, ) elapsed = time.perf_counter() - t0 vram = sampler.stop() if resp.status_code != 200: return CallResult(elapsed, error=f"HTTP_{resp.status_code}", raw=resp.text[:250], vram_pic_mib=vram) content = (resp.json().get("choices", [{}])[0] .get("message", {}).get("content", "")) return CallResult(elapsed, raw=content, vram_pic_mib=vram) except Exception as e: sampler.stop() return CallResult(time.perf_counter() - t0, error=f"NET:{type(e).__name__}") # Pour les runners Transformers, on délègue au worker existant (subprocess) # pour ne pas surcharger ce script en deps lourdes. Variante one-shot. def run_transformers_infigui(cfg: dict, img: Image.Image, rW: int, rH: int) -> CallResult: """Appel one-shot via core/grounding/infigui_worker.py. Note : InfiGUI utilise un système Unix-socket persistant en prod (`core/grounding/infigui_server.py`). Pour le bench cold, on lance le worker en subprocess one-shot (charge le modèle à chaque cold). Pour les warms, on bench via le socket si dispo, sinon en subprocess (et la mesure cold/warm sera identique — à documenter dans le CSV). """ req = { "image_path": str(FIXTURE_PATH), "target": TARGET_LABEL, "description": "", } sampler = VRAMSampler() sampler.start() t0 = time.perf_counter() try: proc = subprocess.run( [sys.executable, "-m", "core.grounding.infigui_worker"], input=json.dumps(req), capture_output=True, text=True, timeout=180, cwd=str(REPO_ROOT), ) elapsed = time.perf_counter() - t0 vram = sampler.stop() if proc.returncode != 0: return CallResult(elapsed, error=f"PROC_{proc.returncode}", raw=(proc.stderr or "")[:250], vram_pic_mib=vram) # Le worker écrit sur stdout du JSON final out = proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else "{}" return CallResult(elapsed, raw=out, vram_pic_mib=vram) except Exception as e: sampler.stop() return CallResult(time.perf_counter() - t0, error=f"EXC:{type(e).__name__}") def run_transformers_qwen2vl(cfg: dict, img: Image.Image, rW: int, rH: int) -> CallResult: """Stub pour OS-Atlas / autres Qwen2-VL fine-tunés. À étoffer si Dom décide d'inclure OS-Atlas dans la 1ère salve. Procédure : charger le modèle via Qwen2_5_VLForConditionalGeneration + AutoProcessor, smart_resize côté script, prompt `os_atlas_bbox_1000`, parser 0-1000. Implémentation effective hors périmètre v0 — retourne un CallResult vide. """ return CallResult(0.0, error="NOT_IMPLEMENTED_V0") RUNNERS = { "ollama": run_ollama, "vllm": run_vllm, "transformers_infigui": run_transformers_infigui, "transformers_qwen2vl": run_transformers_qwen2vl, } # ============================================================================ # Parsing & validation # ============================================================================ def detect_format(content: str) -> str: if '"bbox_2d"' in content: return "bbox_2d" if '"point_2d"' in content: return "point_2d" if '"x_pct"' in content and '"y_pct"' in content: return "xy_pct" if re.search(r'"x"\s*:\s*[\d.]+.*?"y"\s*:\s*[\d.]+', content, re.S): return "xy_json" if re.search(r'\[\s*\[\s*\d+\s*,', content): return "raw_2d_array" # OS-Atlas 0-1000 style if re.search(r'\[\s*[\d.]+\s*,', content): return "raw_array" return "unknown" def parse_coords(content: str, rW: int, rH: int, prompt_style: str ) -> tuple[Optional[float], Optional[float]]: """Retourne (cx_pct, cy_pct) normalisés ∈ [0, 1].""" # OS-Atlas-style : 0-1000 if prompt_style == "os_atlas_bbox_1000": m = re.search(r'\[\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\]', content) if m: x1, y1, x2, y2 = [int(v) for v in m.groups()] return (x1 + x2) / 2 / 1000.0, (y1 + y2) / 2 / 1000.0 return None, None # InfiGUI point_2d (parser dédié, sortie en pixels post-resize rW/rH) if prompt_style == "infigui_point": m = re.search(r'"point_2d"\s*:\s*\[(\d+)\s*,\s*(\d+)\]', content) if m: x, y = int(m.group(1)), int(m.group(2)) return x / rW, y / rH # Le worker InfiGUI renvoie directement {"x": .., "y": ..} en pixels # source résolution. On essaie aussi ce format. m2 = re.search(r'"x"\s*:\s*(\d+).*?"y"\s*:\s*(\d+)', content, re.S) if m2: x, y = int(m2.group(1)), int(m2.group(2)) # ici x/y sont en pixels image source — diviser par fixture size return x / 2560.0, y / 1600.0 return None, None # Cas général : parser regex prod avec divisor post-resize return parse_bbox_to_norm(content, rW, rH) def is_validated(cx: Optional[float], cy: Optional[float]) -> bool: if cx is None or cy is None: return False return (GT_CX_RANGE[0] <= cx <= GT_CX_RANGE[1] and GT_CY_RANGE[0] <= cy <= GT_CY_RANGE[1]) # ============================================================================ # Overlay PNG pour validation visuelle # ============================================================================ def draw_overlay(img: Image.Image, cx: float, cy: float, label: str, out_path: Path) -> None: overlay = img.copy().convert("RGB") draw = ImageDraw.Draw(overlay) W, H = overlay.size px, py = int(cx * W), int(cy * H) r = 30 draw.ellipse([px - r, py - r, px + r, py + r], outline="red", width=5) draw.line([px - r * 2, py, px + r * 2, py], fill="red", width=3) draw.line([px, py - r * 2, px, py + r * 2], fill="red", width=3) try: font = ImageFont.truetype( "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 28) except OSError: font = ImageFont.load_default() txt = f"{label}\ncx={cx:.3f} cy={cy:.3f}" draw.rectangle([10, 10, 700, 90], fill="white") draw.text((20, 15), txt, fill="black", font=font) overlay.save(out_path) # ============================================================================ # Bench main # ============================================================================ @dataclass class ModelStats: key: str label: str cold_s: float = 0.0 warm_times: list[float] = field(default_factory=list) vram_peaks: list[int] = field(default_factory=list) raw_first: str = "" format_detected: str = "unknown" parse_ok: bool = False cx: Optional[float] = None cy: Optional[float] = None validated: bool = False error: str = "" def bench_one_model(key: str, cfg: dict, img: Image.Image, rW: int, rH: int, warm_runs: int, overlay_dir: Optional[Path]) -> ModelStats: stats = ModelStats(key=key, label=cfg["label"]) runner = RUNNERS[cfg["runner"]] print(f"\n══════ {cfg['label']} ══════") # Déchargement VRAM avant cold (Ollama seulement — Transformers/vLLM # gardent le modèle, c'est attendu) if cfg["runner"] == "ollama": _ollama_unload_all() # Cold print(f" [cold]", end=" ", flush=True) r0 = runner(cfg, img, rW, rH) stats.cold_s = r0.elapsed_s if r0.error: stats.error = r0.error print(f"❌ {r0.error}") return stats stats.raw_first = r0.raw[:250] stats.format_detected = detect_format(r0.raw) cx, cy = parse_coords(r0.raw, rW, rH, cfg["prompt_style"]) stats.parse_ok = (cx is not None and cy is not None) stats.cx, stats.cy = cx, cy stats.validated = is_validated(cx, cy) stats.vram_peaks.append(r0.vram_pic_mib) print(f"{stats.cold_s:.2f}s | fmt={stats.format_detected} | " f"cx={cx} cy={cy} | val={stats.validated}") # Warm print(f" [warm × {warm_runs}]", end=" ", flush=True) for i in range(warm_runs): rN = runner(cfg, img, rW, rH) if rN.error: print(f" run{i}=❌{rN.error}", end="") continue stats.warm_times.append(rN.elapsed_s) stats.vram_peaks.append(rN.vram_pic_mib) print() if stats.warm_times: print(f" warm avg={statistics.mean(stats.warm_times):.2f}s | " f"p95={sorted(stats.warm_times)[int(len(stats.warm_times)*0.95)-1]:.2f}s") # Overlay (si parse OK) if overlay_dir and cx is not None and cy is not None: overlay_dir.mkdir(parents=True, exist_ok=True) draw_overlay(img, cx, cy, cfg["label"], overlay_dir / f"bench_{key}.png") return stats def write_csv(all_stats: list[ModelStats], out_path: Path) -> None: with out_path.open("w", newline="") as f: w = csv.writer(f) w.writerow([ "key", "label", "cold_s", "warm_avg_s", "warm_p50_s", "warm_p95_s", "vram_pic_mib", "format", "parse_ok", "cx_pct", "cy_pct", "validated", "raw_first", "error", ]) for s in all_stats: warm_avg = statistics.mean(s.warm_times) if s.warm_times else 0.0 warm_p50 = statistics.median(s.warm_times) if s.warm_times else 0.0 warm_p95 = (sorted(s.warm_times)[int(len(s.warm_times)*0.95)-1] if len(s.warm_times) > 1 else (s.warm_times[0] if s.warm_times else 0.0)) vram = max(s.vram_peaks) if s.vram_peaks else 0 w.writerow([ s.key, s.label, f"{s.cold_s:.2f}", f"{warm_avg:.2f}", f"{warm_p50:.2f}", f"{warm_p95:.2f}", vram, s.format_detected, s.parse_ok, f"{s.cx:.4f}" if s.cx is not None else "", f"{s.cy:.4f}" if s.cy is not None else "", s.validated, s.raw_first.replace("\n", " "), s.error, ]) def print_summary(all_stats: list[ModelStats]) -> None: print("\n\n══════════════════ SYNTHÈSE ══════════════════") print("| Modèle | Cold (s) | Warm avg | VRAM MiB | Fmt | cx | cy | VAL |") print("|---|---:|---:|---:|---|---:|---:|:---:|") for s in all_stats: warm_avg = statistics.mean(s.warm_times) if s.warm_times else 0.0 vram = max(s.vram_peaks) if s.vram_peaks else 0 cx_s = f"{s.cx:.3f}" if s.cx is not None else "—" cy_s = f"{s.cy:.3f}" if s.cy is not None else "—" mark = "✅" if s.validated else ("⚠" if s.parse_ok else "❌") print(f"| {s.label[:40]} | {s.cold_s:.1f} | {warm_avg:.1f} | " f"{vram} | {s.format_detected} | {cx_s} | {cy_s} | {mark} |") def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--models", default="qwen25vl_ollama,qwen3vl_ollama,qwen3vl_vllm,infigui_tx", help="liste séparée par virgules (clés dans MODEL_CATALOG)") ap.add_argument("--warm", type=int, default=10) ap.add_argument("--out", default="/tmp/bench_grounding_2026-05-23.csv") ap.add_argument("--overlay-dir", default="/tmp/bench_grounding_overlays") args = ap.parse_args() if not FIXTURE_PATH.exists(): print(f"ERROR: fixture absente — {FIXTURE_PATH}") return 2 img = Image.open(FIXTURE_PATH).convert("RGB") W, H = img.size rH, rW = smart_resize(H, W) print(f"Fixture : {FIXTURE_PATH}") print(f" source : {W}×{H}") print(f" smart_resize() → {rW}×{rH} (factor=28)") print(f" vérité-terrain : cx ∈ {GT_CX_RANGE}, cy ∈ {GT_CY_RANGE}") print(f" cible : '{TARGET_LABEL}'") keys = [k.strip() for k in args.models.split(",") if k.strip()] unknown = [k for k in keys if k not in MODEL_CATALOG] if unknown: print(f"ERROR: modèles inconnus : {unknown}") print(f"Catalog : {list(MODEL_CATALOG)}") return 2 overlay_dir = Path(args.overlay_dir) if args.overlay_dir else None all_stats: list[ModelStats] = [] for k in keys: try: stats = bench_one_model( k, MODEL_CATALOG[k], img, rW, rH, args.warm, overlay_dir) all_stats.append(stats) except KeyboardInterrupt: print(f"\n⚠ Interrompu pendant {k}") break except Exception as e: print(f"\n❌ Crash {k}: {e}") all_stats.append(ModelStats(key=k, label=MODEL_CATALOG[k]["label"], error=f"crash:{e}")) out = Path(args.out) write_csv(all_stats, out) print(f"\nCSV écrit : {out}") if overlay_dir: print(f"Overlays : {overlay_dir}/") print_summary(all_stats) return 0 if __name__ == "__main__": sys.exit(main()) ``` **Vérification syntaxique** : imports cohérents avec `core/grounding/smart_resize.py` (export `smart_resize`) et `core/grounding/bbox_parser.py` (export `parse_bbox_to_norm`). Pas de dépendance externe hors `requests`, `Pillow` (déjà dans `.venv`). `subprocess` pour Transformers worker + nvidia-smi. **À tester par Dom** avec `--models qwen25vl_ollama` seul d'abord pour valider l'I/O. --- ## 4. Procédure d'install pour Dom ### 4.1. Prérequis Ollama ```bash # Vérifier que les 2 modèles sont pull ollama list | grep -E "qwen2.5vl|qwen3-vl" # Si manquants : ollama pull qwen2.5vl:7b # ~8 GB ollama pull qwen3-vl:8b # ~6 GB ``` ### 4.2. Prérequis vLLM (option recommandée) `vLLM` est listé dans `MIGRATION_VLM_PLAN_2026-05-09.md` §3 comme cible. Démarrage suggéré dans un terminal séparé (ou un service systemd dédié, voir `tools/start_grounding_server.sh` pour le pattern existant) : ```bash # Dans venv_v3 (créer pip install vllm si manquant) cd ~/ai/rpa_vision_v3 && source venv_v3/bin/activate pip install --upgrade vllm # >= 0.6.5 pour Qwen3-VL python -m vllm.entrypoints.openai.api_server \ --model Qwen/Qwen3-VL-8B-Instruct \ --host 0.0.0.0 --port 8100 \ --gpu-memory-utilization 0.55 \ --max-model-len 8192 \ --limit-mm-per-prompt image=1 \ --mm-processor-kwargs '{"min_pixels": 100352, "max_pixels": 1003520}' ``` **Note** : le hardware n'a que 12 GB VRAM. Si Ollama tourne en parallèle avec un autre modèle chargé, prévoir `unload_all` Ollama avant lancement vLLM (`for m in $(ollama ps | awk 'NR>1 {print $1}'); do curl -X POST localhost:11434/api/generate -d "{\"model\":\"$m\",\"keep_alive\":0}"; done`). ### 4.3. Modèles optionnels (HuggingFace) ```bash # OS-Atlas (si on l'active dans --models) huggingface-cli download OS-Copilot/OS-Atlas-Base-7B # Magma (extension future) huggingface-cli download microsoft/Magma-8B ``` Les modèles HF se cachent automatiquement dans `~/.cache/huggingface/hub/`. Comptez ~15 GB par modèle 7-8B en bf16, ~4 GB en 4-bit NF4 (notre stack quantize à la volée pour InfiGUI). ### 4.4. Lancement bench ```bash cd ~/ai/rpa_vision_v3 && source venv_v3/bin/activate # Salve baseline (sans vLLM, sans OS-Atlas) .venv/bin/python tools/bench_grounding_2026-05-23.py \ --models qwen25vl_ollama,qwen3vl_ollama,infigui_tx \ --warm 10 # Salve complète (vLLM démarré préalablement) .venv/bin/python tools/bench_grounding_2026-05-23.py \ --models qwen25vl_ollama,qwen3vl_ollama,qwen3vl_vllm,infigui_tx \ --warm 10 # Avec OS-Atlas (nécessite implémentation effective de run_transformers_qwen2vl) .venv/bin/python tools/bench_grounding_2026-05-23.py \ --models qwen25vl_ollama,qwen3vl_vllm,infigui_tx,os_atlas_tx \ --warm 10 ``` Durée estimée pour la salve baseline : 1 cold + 10 warm × 3 modèles. Si cold ≈ 11s et warm ≈ 2s : ~2 min/modèle, total ~7-10 min. --- ## 5. Modèles candidats avec configs précises ### 5.1. `qwen2.5vl:7b` (Ollama, baseline buggy attendue) - Backend : Ollama HTTP `/api/chat` - Prompt : `Detect 'OK button' in this image with a bounding box.` (prompt actuel `resolve_engine.py:942`) - Options : `temperature=0.1`, `num_predict=100` - **Attendu** : bbox_2d en pixels post-resize Ollama (opaque) → `cx_pct ≈ 0.17` (bug confirmé 8 mai) - Rôle dans le bench : **témoin du bug**, doit échouer pour confirmer que le bug est reproductible et que le bench discrimine. ### 5.2. `qwen3-vl:8b` (Ollama, prompt JSON explicite) - Backend : Ollama HTTP `/api/chat` - Prompt système : `You are a UI element locator. Output raw JSON only. No explanation.` - Prompt user : `Locate the "OK button" in this {rW}x{rH} screenshot. Return ONLY this JSON object: {"bbox_2d":[x1,y1,x2,y2],"label":"OK button"}` - Options : `temperature=0.1`, `num_predict=128`, `think:false` - ⚠ Note web search : Ollama issue #14798 — `think:false` est **silencieusement ignoré** pour qwen3-vl:8b (template bare). Vérifier sur les outputs si `...` apparaît. Workaround : préfixer le user prompt par `/no_think`. - **Attendu** : si `smart_resize` côté script correspond au resize interne Ollama, `cx ≈ 0.50`. Sinon, même bug que qwen2.5vl. ### 5.3. `Qwen3-VL-8B-Instruct` (vLLM, cible migration) - Backend : vLLM OpenAI-compat sur :8100 - Image envoyée **déjà resize côté client** au `smart_resize`-output → vLLM ne re-resize plus (à confirmer via `mm_processor_kwargs={"min_pixels": rW*rH, "max_pixels": rW*rH}` pour forcer no-op). - Prompt : idem 5.2 - `max_tokens=128`, `temperature=0.1` - **Attendu** : `cx_pct ∈ [0.40, 0.60]` si la chaîne resize+prompt+parse est cohérente. C'est la config qui valide AXE_A2. ### 5.4. `InfiGUI-G1-3B` (Transformers, prod actuelle) - Backend : `core/grounding/infigui_worker.py` en subprocess (one-shot) OU socket Unix via `rpa-grounding.service` si actif. - Prompt : du worker, sortie `point_2d` (pas `bbox_2d`). - **Spécificité** : le worker écrit (x, y) en pixels **source** déjà (re-multiplie par `W/rW, H/rH` ligne 174-175). Donc `cx_pct = x_returned / 2560`, `cy_pct = y_returned / 1600` direct. Vérifier au runtime. - **Attendu** : `cx_pct ≈ 0.50` (le worker est testé en prod, c'est la baseline qui marche déjà selon `HISTORIQUE_VLM_IMPLEMENTATIONS_2026-05-08.md`). ### 5.5. `OS-Atlas-Base-7B` (Transformers, candidate SOTA, AXE A1) - Backend : Transformers Qwen2.5-VL chargé en 4-bit NF4 (à implémenter, runner `transformers_qwen2vl` est un stub v0). - Prompt : `In the image, please find the bbox of "OK button". Output the bounding boxes in this format: [[x1,y1,x2,y2]], where each value is normalized in [0, 1000].` - Output 0-1000 normalisé → conversion directe `cx_pct = (x1 + x2) / 2 / 1000`. - **Attendu** : SOTA sur ScreenSpot/ScreenSpot-Pro selon HF README → `cx_pct ∈ [0.40, 0.60]` probable. ### 5.6. `Magma-8B` (extension future, non v0) Magma utilise **Set-of-Mark** : il faut détecter d'abord les éléments (SomEngine ou OmniParser) puis lui demander de choisir un numéro. Pas directement comparable aux 5 candidats ci-dessus. À benchmarker dans un AXE A4 séparé. --- ## 6. Critère de validation success (matrice) | Modèle | Latence cold | Warm avg | cx_pct ∈ [0.40, 0.60] | cy_pct ∈ [0.40, 0.60] | Parse regex prod | Verdict | |---|---|---|---|---|---|---| | qwen25vl_ollama | < 12 s | < 12 s | ❌ (attendu 0.17) | ? | ✅ | **Témoin OK si tout sauf cx pass** | | qwen3vl_ollama | < 5 s | < 3 s | ✅ ou ❌ selon resize | ✅ | ✅ si prompt JSON | go si ✅ | | qwen3vl_vllm | < 8 s | < 3 s | ✅ requis | ✅ requis | ✅ requis | **CIBLE migration AXE_A2** | | infigui_tx | < 15 s | < 4 s | ✅ requis | ✅ requis | ✅ (point_2d) | Baseline prod | | os_atlas_tx | < 15 s | < 5 s | ✅ requis | ✅ requis | ✅ (raw_2d 0-1000) | Candidat upgrade | **Verdict global AXE_A3** : - Si `qwen3vl_vllm` ET `infigui_tx` passent ✅ → migration vers vLLM Qwen3-VL est **safe**, AXE_A2 peut être considéré comme résolu. - Si **seul `infigui_tx` passe** → la prod actuelle est valide, vLLM Qwen3-VL n'apporte rien à part la latence — décision business sur la migration. - Si **rien ne passe** → le bug n'est PAS dans `smart_resize` seul. Investiguer `divisor_w/h` côté `parse_bbox_to_norm`, et la chaîne window crop (`window_rect` line 916-919) qui pourrait introduire un offset. --- ## 7. Vérité terrain manuelle pour Dom Avant tout bench, Dom doit confirmer visuellement où est le bouton OK sur la fixture. Trois méthodes au choix : ### Méthode 1 : aperçu rapide via xdg-open ```bash xdg-open /home/dom/ai/rpa_vision_v3/data/training/live_sessions/bg_DESKTOP-58D5CAC_windows/shots/heartbeat_1773792436.png ``` Ouvrir l'image, identifier le bouton OK à l'œil, estimer `cx_pct ≈ pixel_x / 2560` et `cy_pct ≈ pixel_y / 1600`. ### Méthode 2 : overlay grille via PIL (one-liner) ```bash .venv/bin/python -c " from PIL import Image, ImageDraw img = Image.open('data/training/live_sessions/bg_DESKTOP-58D5CAC_windows/shots/heartbeat_1773792436.png').convert('RGB') draw = ImageDraw.Draw(img) W, H = img.size # Grille de référence en % for pct in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]: x = int(pct * W) draw.line([(x, 0), (x, H)], fill='cyan', width=2) draw.text((x + 5, 10), f'{pct:.1f}', fill='cyan') y = int(pct * H) draw.line([(0, y), (W, y)], fill='cyan', width=2) draw.text((10, y + 5), f'{pct:.1f}', fill='cyan') img.save('/tmp/heartbeat_grid.png') print('grille → /tmp/heartbeat_grid.png') " xdg-open /tmp/heartbeat_grid.png ``` Dom regarde où est le bouton OK et note `(cx_gt, cy_gt)`. Si OK n'est pas dans `[0.40, 0.60]² ` après cette vérif, **ajuster `GT_CX_RANGE` / `GT_CY_RANGE` dans le script** avant tout bench. ### Méthode 3 : crop autour de la zone OK ```bash .venv/bin/python -c " from PIL import Image img = Image.open('data/training/live_sessions/bg_DESKTOP-58D5CAC_windows/shots/heartbeat_1773792436.png') # Crop la zone supposée 0.4-0.6 × 0.4-0.6 W, H = img.size img.crop((int(0.35*W), int(0.35*H), int(0.65*W), int(0.65*H))).save('/tmp/heartbeat_center_crop.png') " xdg-open /tmp/heartbeat_center_crop.png ``` Si le bouton OK est visible dans ce crop, vérité-terrain `[0.40, 0.60]` est valide. --- ## 8. Extensions futures (autres fixtures à ajouter) Une seule fixture = sous-dimensionné pour conclure. Au minimum 3 fixtures additionnelles, idéalement issues du replay réel post-démo : | Fixture | Origine | Cible | cx_gt approx | Difficulté | |---|---|---|---|---| | **Tabs Easily Assure** | replay 8 mai bug step 10 | "Imagerie" / "Notes médicales" / "Synthèse Urgences" (3 cas) | par tab | discriminer 3 textes dans une barre — confondus en center-of-line (REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md §4.2) | | **Popup IPP recherche** | recording VWB urgence_aiva_demo | bouton "Rechercher" | mid-screen | popup centré, peut avoir plusieurs occurrences du même texte (cf. `original_position` y_relative pour désambiguer) | | **Bandeau outils Easily** | shot quelconque | icône "disquette" (sans texte) | top-left de la barre | grounding visuel pur (pas d'OCR), c'est le cas pour lequel le grounding VLM est censé exister | Ces extensions justifieraient un **AXE A3.2 — Multi-fixture grounding bench** une fois A3.1 (cette spec) validé. Le script ci-dessus est déjà conçu pour accepter une liste de fixtures (refacto trivial : `FIXTURE_PATH` + `TARGET_LABEL` + `GT_*_RANGE` → liste de tuples, boucle externe). --- ## 9. Dépendances et liens avec autres AXES - **AXE A1 (sélection modèles)** : alimente la section §5 — si A1 retient OS-Atlas-Pro-7B ou ShowUI-2B en lieu et place de OS-Atlas-Base-7B, ajuster `MODEL_CATALOG`. - **AXE A2 (smart_resize calibration)** : ce bench est la **validation end-to-end** d'A2. Tant qu'A2 n'a pas tranché entre factor=28 (Qwen2-VL) et factor=32 (Qwen3-VL), lancer A3 d'abord avec `factor=28` (état actuel `core/grounding/smart_resize.py`) puis re-bench avec `factor=32` (modifier `FACTOR_DEFAULT` du module ou patcher le script). - **Hors scope A3** : SomEngine + Magma (Set-of-Mark) ; ces 2 stratégies de grounding nécessitent un détecteur amont (cf. `_resolve_by_som` dans `resolve_engine.py:1095+`) et un bench distinct. --- *Document destiné à être consommé par Dom et un agent d'exécution. Aucune action runtime déclenchée par cette spec. À mettre à jour quand A1 et A2 auront tranché leurs paramètres.*