docs: add POC specs, handoffs, and research notes
This commit is contained in:
895
docs/recherche/AXE_A3_BENCH_PROTOCOL.md
Normal file
895
docs/recherche/AXE_A3_BENCH_PROTOCOL.md
Normal file
@@ -0,0 +1,895 @@
|
||||
# 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 :
|
||||
|
||||
```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 <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
|
||||
|
||||
```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 `<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
|
||||
|
||||
```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.*
|
||||
Reference in New Issue
Block a user