Avant : 4 occurrences de parsing en cascade dans resolve_engine.py
(L840-885, L903-915, L2569-2580, ~110 lignes au total).
Après : centralisation dans core/grounding/bbox_parser.py avec
paramètre formats= permettant de filtrer les formats reconnus
selon le contrat sémantique de chaque site d'appel.
Préservation des contrats sémantiques (strict no-op) :
- Occ 1+2 (cascade principale) : tous formats (par défaut)
- Occ 3 (retry multi-image) : formats={"xy_json", "raw_array"}
pour respecter le prompt qui impose {"x": NNN, "y": NNN} in pixels
- Occ 4 (_locate_popup_button) : formats={"bbox_2d"} pour respecter
le prompt qui demande "bounding box"
Notes :
- Mini-bug Occ 3 retry multi-image (division systématique sans
heuristique x>1, produisait coordonnées aberrantes ~0.0004 si
VLM retournait déjà du pourcentage) corrigé incidemment via
centralisation. Pas de régression possible (résultat précédent
aberrant par construction).
- Occ 4 : bbox_2d strict 4-coords élargi à bbox_2d 2 ou 4 coords.
Contrat sémantique "bounding box" respecté ; un point 2-coords
interprété comme centre de bbox.
Tests : 26 cas dans test_bbox_parser.py (tous formats × cascade
+ filtre formats= + validated). 121 PASS / 0 FAIL sur le périmètre
refactor (5 fichiers ciblés).
Net : -96 lignes dans resolve_engine.py, +120 lignes module
+ 250 lignes tests.
refs DETTE-006 (étape 2/5 du fix smart_resize)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
121 lines
4.5 KiB
Python
121 lines
4.5 KiB
Python
"""
|
|
Parser des réponses VLM de grounding (bbox_2d, x/y, x_pct/y_pct, array brut).
|
|
|
|
Centralise le parsing des coordonnées retournées par les modèles VLM
|
|
(Qwen-VL via Ollama, vLLM ou Transformers direct) vers une représentation
|
|
normalisée (x_pct, y_pct).
|
|
|
|
Module pur : regex + arithmétique, sans dépendance lourde.
|
|
|
|
Convention des diviseurs (DETTE-006 ouverte) : actuellement les call sites
|
|
passent les dimensions de l'image envoyée au VLM (PRE-resize). C'est la
|
|
source du bug d'échelle pixel grounding — sera corrigé au commit 3/5 du
|
|
fix DETTE-006 en passant les dimensions POST-smart_resize.
|
|
"""
|
|
|
|
import re
|
|
|
|
|
|
_ALL_FORMATS = frozenset({"bbox_2d", "xy_json", "xy_pct", "raw_array"})
|
|
|
|
|
|
def parse_bbox_to_norm(
|
|
content: str,
|
|
divisor_w: int | float,
|
|
divisor_h: int | float,
|
|
*,
|
|
formats: set[str] | None = None,
|
|
) -> tuple[float | None, float | None]:
|
|
"""Parse une réponse VLM en (x_pct, y_pct) normalisés.
|
|
|
|
Cascade des formats (premier qui matche gagne) :
|
|
1. ``"bbox_2d"`` : ``{"bbox_2d": [x, y]}`` ou ``[x1, y1, x2, y2]``
|
|
2. ``"xy_json"`` : ``{"x": ..., "y": ...}`` (heuristique x>1 → pixels)
|
|
3. ``"xy_pct"`` : ``{"x_pct": ..., "y_pct": ...}``
|
|
4. ``"raw_array"`` : array brut ``[...]`` 2 ou 4 coords
|
|
|
|
Args:
|
|
content: réponse texte du VLM.
|
|
divisor_w, divisor_h: dimensions normalisant les pixels en pct.
|
|
formats: ensemble des formats à essayer. Si ``None`` (défaut),
|
|
cascade complète des 4. Pour restreindre, passer un sous-ensemble
|
|
de ``{"bbox_2d", "xy_json", "xy_pct", "raw_array"}``.
|
|
|
|
Returns:
|
|
``(x_pct, y_pct)`` ou ``(None, None)`` si aucun format ne matche.
|
|
"""
|
|
enabled = _ALL_FORMATS if formats is None else formats
|
|
x_pct, y_pct = None, None
|
|
|
|
# Format 1 : bbox_2d en pixels [x, y] ou [x1, y1, x2, y2]
|
|
if "bbox_2d" in enabled:
|
|
bbox_match = re.search(r'"bbox_2d"\s*:\s*\[([^\]]+)\]', content)
|
|
if bbox_match:
|
|
coords = [float(v.strip()) for v in bbox_match.group(1).split(",")]
|
|
if len(coords) == 2:
|
|
x_pct = coords[0] / divisor_w
|
|
y_pct = coords[1] / divisor_h
|
|
elif len(coords) >= 4:
|
|
x_pct = (coords[0] + coords[2]) / 2 / divisor_w
|
|
y_pct = (coords[1] + coords[3]) / 2 / divisor_h
|
|
|
|
# Format 2 : JSON {"x": 0.XX, "y": 0.YY}
|
|
if x_pct is None and "xy_json" in enabled:
|
|
json_match = re.search(r'"x"\s*:\s*([\d.]+).*?"y"\s*:\s*([\d.]+)', content)
|
|
if json_match:
|
|
x_val, y_val = float(json_match.group(1)), float(json_match.group(2))
|
|
if x_val > 1:
|
|
x_pct = x_val / divisor_w
|
|
y_pct = y_val / divisor_h
|
|
else:
|
|
x_pct = x_val
|
|
y_pct = y_val
|
|
|
|
# Format 3 : JSON {"x_pct": 0.XX, "y_pct": 0.YY}
|
|
if x_pct is None and "xy_pct" in enabled:
|
|
pct_match = re.search(r'"x_pct"\s*:\s*([\d.]+).*?"y_pct"\s*:\s*([\d.]+)', content)
|
|
if pct_match:
|
|
x_pct = float(pct_match.group(1))
|
|
y_pct = float(pct_match.group(2))
|
|
|
|
# Format 4 : array brut [x1, y1, x2, y2] ou [x, y]
|
|
if x_pct is None and "raw_array" in enabled:
|
|
arr_match = re.search(
|
|
r'\[[\s]*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+)\s*,\s*([\d.]+))?\s*\]',
|
|
content,
|
|
)
|
|
if arr_match:
|
|
vals = [float(v) for v in arr_match.groups() if v is not None]
|
|
if len(vals) >= 4:
|
|
x_pct = (vals[0] + vals[2]) / 2 / divisor_w
|
|
y_pct = (vals[1] + vals[3]) / 2 / divisor_h
|
|
elif len(vals) == 2:
|
|
x_pct = vals[0] / divisor_w
|
|
y_pct = vals[1] / divisor_h
|
|
|
|
return x_pct, y_pct
|
|
|
|
|
|
def parse_bbox_to_norm_validated(
|
|
content: str,
|
|
divisor_w: int | float,
|
|
divisor_h: int | float,
|
|
*,
|
|
formats: set[str] | None = None,
|
|
) -> tuple[float | None, float | None]:
|
|
"""Idem :func:`parse_bbox_to_norm` + validation domaine [0, 1].
|
|
|
|
Retourne ``(None, None)`` si le résultat parsé est hors ``[0, 1]`` sur
|
|
l'un des deux axes — comportement de ``_locate_popup_button``
|
|
(cf. resolve_engine.py:2569-2580).
|
|
|
|
Implémentation : appelle :func:`parse_bbox_to_norm` puis valide. Pas
|
|
de duplication de la logique de parsing.
|
|
"""
|
|
x_pct, y_pct = parse_bbox_to_norm(content, divisor_w, divisor_h, formats=formats)
|
|
if x_pct is None or y_pct is None:
|
|
return None, None
|
|
if not (0.0 <= x_pct <= 1.0 and 0.0 <= y_pct <= 1.0):
|
|
return None, None
|
|
return x_pct, y_pct
|