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