39 KiB
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
- Une fixture (
heartbeat_1773792436.png2560×1600, dialog OK/Cancel) sur laquelle on connaît la vérité-terrain (bouton OK ≈ mid-screen,cx ≈ 0.50). - Trois backends : Ollama (
qwen2.5vl:7b= baseline buggy,qwen3-vl:8bJSON-explicit), vLLM (Qwen3-VL-8B-Instructavecresized_width/heightnatifs), Transformers in-process (InfiGUI-G1-3Bactuel + 1-2 SOTA optionnels OS-Atlas/Magma). - Pour chaque modèle : déchargement VRAM → 1 cold → 10 warm → mesure latence, VRAM pic, format brut, parse OK,
cx_pctmesuré. - Critère go/no-go :
cx_pct ∈ [0.40, 0.60]ETcy_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. - 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 actuelresolve_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<think> - 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_<model>.png = fixture + croix rouge au point retourné + texte <model> 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 :
#!/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 <think> </think> 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
# 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) :
# 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)
# 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
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 actuelresolve_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:falseest silencieusement ignoré pour qwen3-vl:8b (template bare). Vérifier sur les outputs si<think>...</think>apparaît. Workaround : préfixer le user prompt par/no_think. - Attendu : si
smart_resizecô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 viamm_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.pyen subprocess (one-shot) OU socket Unix viarpa-grounding.servicesi actif. - Prompt : du worker, sortie
point_2d(pasbbox_2d). - Spécificité : le worker écrit (x, y) en pixels source déjà (re-multiplie par
W/rW, H/rHligne 174-175). Donccx_pct = x_returned / 2560,cy_pct = y_returned / 1600direct. Vérifier au runtime. - Attendu :
cx_pct ≈ 0.50(le worker est testé en prod, c'est la baseline qui marche déjà selonHISTORIQUE_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_qwen2vlest 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_vllmETinfigui_txpassent ✅ → migration vers vLLM Qwen3-VL est safe, AXE_A2 peut être considéré comme résolu. - Si seul
infigui_txpasse → 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_resizeseul. Investiguerdivisor_w/hcôtéparse_bbox_to_norm, et la chaîne window crop (window_rectline 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
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)
.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
.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 actuelcore/grounding/smart_resize.py) puis re-bench avecfactor=32(modifierFACTOR_DEFAULTdu 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_somdansresolve_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.