Files
rpa_vision_v3/docs/recherche/AXE_A3_BENCH_PROTOCOL.md

39 KiB
Raw Blame History

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 <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 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 <think>...</think> 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

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 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.