rpx_benchmark.vqa.outputs

Strict parsing and normalization of VQA model outputs.

  1"""Strict parsing and normalization of VQA model outputs."""
  2
  3from __future__ import annotations
  4
  5import json
  6import re
  7import unicodedata
  8from dataclasses import dataclass
  9
 10from ..exceptions import AdapterError, RPXError
 11from .contract import ATTRIBUTE_TYPES, BBOX_TYPES, BINARY_TYPES, VQASample
 12
 13_LOC_RE = re.compile(
 14    r"<loc(?P<y0>\d{4})><loc(?P<x0>\d{4})><loc(?P<y1>\d{4})><loc(?P<x1>\d{4})>"
 15)
 16_MOLMO_POINT_TAG_RE = re.compile(r"<(?:point|points)\b(?P<attrs>[^>]*)>", re.IGNORECASE)
 17_MOLMO_POINT_ATTR_RE = re.compile(
 18    r"\b(?P<axis>[xy])(?P<index>\d*)\s*=\s*[\"']"
 19    r"(?P<value>[+-]?(?:\d+(?:\.\d*)?|\.\d+))[\"']",
 20    re.IGNORECASE,
 21)
 22_NUMBER_RE = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
 23_INTERNVL_BBOX_RE = re.compile(
 24    rf"(?<!\[)\[\[?\s*({_NUMBER_RE})\s*,\s*({_NUMBER_RE})\s*,\s*"
 25    rf"({_NUMBER_RE})\s*,\s*({_NUMBER_RE})\s*\]\]?(?!\])"
 26)
 27
 28
 29def normalize_label(value: str) -> str:
 30    value = unicodedata.normalize("NFKC", value).strip().lower().replace("_", " ")
 31    value = re.sub(r"[^\w\s-]", "", value)
 32    return " ".join(value.split())
 33
 34
 35@dataclass(frozen=True)
 36class ParsedOutput:
 37    valid: bool
 38    label: str | None = None
 39    bbox: tuple[float, float, float, float] | None = None
 40    error: str | None = None
 41    coordinate_format: str | None = None
 42
 43
 44@dataclass(frozen=True)
 45class ParsedPoints:
 46    """A native Molmo pointing response, kept separate from bbox parsing."""
 47
 48    valid: bool
 49    points: tuple[tuple[float, float], ...] = ()
 50    error: str | None = None
 51    coordinate_format: str = "molmo_point_percent_0_100"
 52
 53
 54def parse_molmo_points(raw: str) -> ParsedPoints:
 55    """Parse Molmo's documented ``<point>``/``<points>`` markup.
 56
 57    Coordinates remain percentages on Molmo's native 0--100 grid.  This
 58    function intentionally cannot return a bbox: a point carries no object
 59    extent and must not enter the benchmark's bbox-IoU metric.
 60    """
 61    points: list[tuple[float, float]] = []
 62    errors: list[str] = []
 63    tags = list(_MOLMO_POINT_TAG_RE.finditer(raw))
 64    if not tags:
 65        return ParsedPoints(False, error="missing Molmo <point> or <points> tag")
 66    for tag in tags:
 67        by_index: dict[str, dict[str, float]] = {}
 68        for match in _MOLMO_POINT_ATTR_RE.finditer(tag.group("attrs")):
 69            index = match.group("index") or "1"
 70            axis = match.group("axis").lower()
 71            slot = by_index.setdefault(index, {})
 72            if axis in slot:
 73                errors.append(f"duplicate {axis}{index} coordinate")
 74                continue
 75            slot[axis] = float(match.group("value"))
 76        if not by_index:
 77            errors.append("point tag contains no coordinate attributes")
 78            continue
 79        for index, coordinates in sorted(
 80            by_index.items(), key=lambda item: int(item[0])
 81        ):
 82            if set(coordinates) != {"x", "y"}:
 83                errors.append(f"point {index} is missing x or y")
 84                continue
 85            x, y = coordinates["x"], coordinates["y"]
 86            if not (0 <= x <= 100 and 0 <= y <= 100):
 87                errors.append(f"point {index} is outside the 0-100 grid")
 88                continue
 89            points.append((x, y))
 90    if errors:
 91        return ParsedPoints(False, points=tuple(points), error="; ".join(errors))
 92    if not points:
 93        return ParsedPoints(False, error="Molmo point markup contains no complete point")
 94    return ParsedPoints(True, points=tuple(points))
 95
 96
 97def _json_object(raw: str) -> dict:
 98    text = raw.strip()
 99    if text.startswith("```"):
100        text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.IGNORECASE)
101    start, end = text.find("{"), text.rfind("}")
102    if start < 0 or end < start:
103        raise AdapterError("missing JSON object", hint="return the requested bbox JSON only")
104    value = json.loads(text[start : end + 1])
105    if not isinstance(value, dict):
106        raise AdapterError("output is not a JSON object", hint="return one JSON object")
107    return value
108
109
110def _cosmos_bbox_candidates(raw: str) -> list[dict]:
111    """Extract complete native Reason2 ``bbox_2d`` objects from JSON values.
112
113    Reason2 may wrap its answer in a JSON code fence or an ``<answer>`` block.
114    Decode real JSON values instead of using a bracket regex, and retain every
115    candidate so the one-object benchmark can reject ambiguous multi-box output.
116    """
117    text = raw.strip()
118    answer = re.search(r"<answer>(.*?)</answer>", text, flags=re.IGNORECASE | re.DOTALL)
119    if answer:
120        text = answer.group(1).strip()
121    fenced = re.search(r"```(?:json)?\s*(.*?)\s*```", text, flags=re.IGNORECASE | re.DOTALL)
122    if fenced:
123        text = fenced.group(1).strip()
124
125    decoder = json.JSONDecoder()
126    candidates: list[dict] = []
127    offset = 0
128    while offset < len(text):
129        starts = [
130            index
131            for index in (text.find("[", offset), text.find("{", offset))
132            if index >= 0
133        ]
134        if not starts:
135            break
136        start = min(starts)
137        try:
138            value, consumed = decoder.raw_decode(text[start:])
139        except json.JSONDecodeError:
140            offset = start + 1
141            continue
142        values = value if isinstance(value, list) else [value]
143        candidates.extend(
144            item for item in values if isinstance(item, dict) and "bbox_2d" in item
145        )
146        offset = start + max(consumed, 1)
147    return candidates
148
149
150def parse_output(
151    sample: VQASample,
152    raw: str,
153    model_key: str,
154    *,
155    paligemma_target_only: bool = False,
156) -> ParsedOutput:
157    label: str | None
158    match: re.Match[str] | None
159    if sample.question_type in BINARY_TYPES:
160        token = normalize_label(raw)
161        if token not in {"yes", "no"}:
162            return ParsedOutput(False, error="expected exactly yes or no")
163        return ParsedOutput(True, label=token)
164    if sample.question_type in ATTRIBUTE_TYPES:
165        label = normalize_label(raw)
166        if not label or len(label.split()) > 8:
167            return ParsedOutput(False, error="invalid object-name answer")
168        return ParsedOutput(True, label=label)
169    if sample.question_type in BBOX_TYPES and model_key.startswith("cosmos-reason2-"):
170        candidates = _cosmos_bbox_candidates(raw)
171        if len(candidates) != 1:
172            return ParsedOutput(
173                False,
174                error=f"expected exactly one Cosmos bbox_2d candidate, got {len(candidates)}",
175            )
176        value = candidates[0]
177        try:
178            raw_bbox = value["bbox_2d"]
179            if not isinstance(raw_bbox, list) or len(raw_bbox) != 4:
180                raise AdapterError(
181                    "Cosmos bbox_2d must contain four numbers",
182                    hint="return one normalized XYXY box",
183                )
184            bbox = tuple(float(coordinate) for coordinate in raw_bbox)
185            if not all(float("-inf") < coordinate < float("inf") for coordinate in bbox):
186                raise AdapterError("Cosmos bbox contains a non-finite number")
187            if not all(0 <= coordinate <= 1000 for coordinate in bbox):
188                raise AdapterError("Cosmos bbox is outside the 0-1000 grid")
189            x0, y0, x1, y1 = bbox
190            if not (x0 <= x1 and y0 <= y1):
191                raise AdapterError("Cosmos bbox corners are not ordered XYXY")
192            scaled = (
193                x0 * (sample.img_w - 1) / 1000,
194                y0 * (sample.img_h - 1) / 1000,
195                x1 * (sample.img_w - 1) / 1000,
196                y1 * (sample.img_h - 1) / 1000,
197            )
198            label = normalize_label(str(value.get("label", ""))) or None
199            return ParsedOutput(
200                True,
201                label=label,
202                bbox=scaled,
203                coordinate_format="cosmos_bbox_2d_0_1000",
204            )
205        except (KeyError, TypeError, ValueError, RPXError) as exc:
206            return ParsedOutput(False, error=str(exc))
207    if sample.question_type in BBOX_TYPES and model_key.startswith("internvl3.5-"):
208        # InternVL's official RefCOCO evaluation protocol serializes one
209        # target-relative 0--1000 box.  Its own evaluator deliberately accepts
210        # either ``label[[x0,y0,x1,y1]]`` or ``[x0,y0,x1,y1]``.  Accept those
211        # two serializations before the generic JSON fallback, but never choose
212        # silently among multiple candidates in this one-object benchmark.
213        matches = list(_INTERNVL_BBOX_RE.finditer(raw))
214        if matches:
215            if len(matches) != 1:
216                return ParsedOutput(False, error="expected exactly one InternVL bbox")
217            match = matches[0]
218            bbox = tuple(float(value) for value in match.groups())
219            if not all(0 <= value <= 1000 for value in bbox):
220                return ParsedOutput(False, error="InternVL bbox is outside the 0-1000 grid")
221            x0, y0, x1, y1 = bbox
222            if not (x0 <= x1 and y0 <= y1):
223                return ParsedOutput(False, error="InternVL bbox corners are not ordered XYXY")
224            prefix = raw[: match.start()]
225            prefix = re.sub(r"</?(?:ref|box)>", "", prefix, flags=re.IGNORECASE)
226            label = normalize_label(prefix.splitlines()[-1]) or None
227            return ParsedOutput(
228                True,
229                label=label,
230                bbox=(
231                    x0 * (sample.img_w - 1) / 1000,
232                    y0 * (sample.img_h - 1) / 1000,
233                    x1 * (sample.img_w - 1) / 1000,
234                    y1 * (sample.img_h - 1) / 1000,
235                ),
236                coordinate_format="internvl_bbox_0_1000",
237            )
238        # The 1B checkpoint sometimes preserves the same 0--1000 coordinate
239        # contract but spells the single box out as labelled corners.  This is
240        # a deterministic serialization variant, not box repair: require one
241        # complete pair, validate it identically, and never infer coordinates
242        # from descriptive prose.
243        verbose_matches = re.findall(
244            r"top[\s-]*left\s+corner\s*:\s*[\[(]\s*"
245            r"(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*[\])]"
246            r".*?bottom[\s-]*right\s+corner\s*:\s*[\[(]\s*"
247            r"(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*[\])]",
248            raw,
249            flags=re.IGNORECASE | re.DOTALL,
250        )
251        if verbose_matches:
252            if len(verbose_matches) != 1:
253                return ParsedOutput(False, error="expected exactly one InternVL corner pair")
254            bbox = tuple(float(value) for value in verbose_matches[0])
255            if not all(0 <= value <= 1000 for value in bbox):
256                return ParsedOutput(False, error="InternVL bbox is outside the 0-1000 grid")
257            x0, y0, x1, y1 = bbox
258            if not (x0 <= x1 and y0 <= y1):
259                return ParsedOutput(False, error="InternVL bbox corners are not ordered XYXY")
260            return ParsedOutput(
261                True,
262                bbox=(
263                    x0 * (sample.img_w - 1) / 1000,
264                    y0 * (sample.img_h - 1) / 1000,
265                    x1 * (sample.img_w - 1) / 1000,
266                    y1 * (sample.img_h - 1) / 1000,
267                ),
268                coordinate_format="internvl_verbose_bbox_0_1000",
269            )
270        # Retain the strict JSON fallback for a checkpoint that follows the
271        # explicit coordinate contract but emits the generic RPX envelope.
272    if sample.question_type in BBOX_TYPES and model_key.startswith("paligemma2-"):
273        match = _LOC_RE.search(raw)
274        if match:
275            if sample.is_in_context and not paligemma_target_only:
276                return ParsedOutput(
277                    False,
278                    error=(
279                        "native PaliGemma loc tokens are relative to the labelled "
280                        "two-image composite, not Image 2; return target-relative JSON"
281                    ),
282                )
283            vals = {key: int(value) for key, value in match.groupdict().items()}
284            bbox = (
285                vals["x0"] * sample.img_w / 1024,
286                vals["y0"] * sample.img_h / 1024,
287                vals["x1"] * sample.img_w / 1024,
288                vals["y1"] * sample.img_h / 1024,
289            )
290            label = normalize_label(raw[match.end() :].split("<", 1)[0]) or None
291            return ParsedOutput(
292                True, label=label, bbox=bbox, coordinate_format="paligemma_loc_1024"
293            )
294        # The scored single-call prompt requests the same JSON contract used
295        # by the other VLMs. Fall through to that parser when PaliGemma obeys
296        # it; native <loc> remains accepted as an architecture-specific bbox
297        # serialization, but no second model call is made.
298    if sample.question_type in BBOX_TYPES:
299        try:
300            value = _json_object(raw)
301            raw_bbox = value["bbox"]
302            # Some otherwise-correct VLM responses add one redundant list
303            # dimension.  Unwrap exactly one singleton; do not guess any more
304            # complicated structure.
305            if (
306                isinstance(raw_bbox, list)
307                and len(raw_bbox) == 1
308                and isinstance(raw_bbox[0], list)
309            ):
310                raw_bbox = raw_bbox[0]
311            if not isinstance(raw_bbox, list) or len(raw_bbox) != 4:
312                raise AdapterError(
313                    "bbox must contain four numbers", hint="return normalized xyxy coordinates"
314                )
315            bbox = tuple(float(v) for v in raw_bbox)
316            if not all(float("-inf") < value < float("inf") for value in bbox):
317                raise AdapterError("bbox contains a non-finite number", hint="return finite pixels")
318            # Molmo's model-facing bbox protocol uses the same 0--100
319            # percentage grid as its documented spatial pointing interface.
320            # This is a complete four-corner box, not a point-derived box.
321            # Convert it deterministically here while retaining the untouched
322            # model text in raw_output for auditability.
323            molmo_percent = model_key in {"molmo-7b-d", "molmoe-1b"}
324            if molmo_percent and not all(0 <= coordinate <= 100 for coordinate in bbox):
325                raise AdapterError(
326                    "Molmo bbox is outside percentage 0-100 coordinates",
327                    hint="return percentage xyxy coordinates",
328                )
329
330            # JSON produced by an adapter may contain a machine-rounding
331            # residue at an exact normalized endpoint (for example
332            # 999 * 1000 / 999 -> 1000.0000000000001). Accept only that tiny
333            # numerical residue, then clamp it; genuinely out-of-range model
334            # coordinates remain invalid.
335            coordinate_epsilon = 1e-9
336            if not all(
337                -coordinate_epsilon <= coordinate <= 1000 + coordinate_epsilon
338                for coordinate in bbox
339            ):
340                raise AdapterError(
341                    "bbox is outside normalized 0-1000 coordinates",
342                    hint="return normalized xyxy coordinates",
343                )
344            bbox = tuple(min(1000.0, max(0.0, coordinate)) for coordinate in bbox)
345            # Decode the two widespread normalized conventions without using
346            # model identity. Values containing a genuine fraction and entirely
347            # within [0,1] are unit-normalized; everything else follows the
348            # requested [0,1000] protocol. This fixes the previous silent bug
349            # where [0.5,...] became a sub-pixel box at the top-left.
350            unit_normalized = not molmo_percent and all(
351                0 <= coordinate <= 1 for coordinate in bbox
352            )
353            denominator = 100 if molmo_percent else 1 if unit_normalized else 1000
354            coordinate_format = (
355                "molmo_bbox_percent_0_100"
356                if molmo_percent
357                else "normalized_0_1"
358                if unit_normalized
359                else "normalized_0_1000"
360            )
361            bbox = (
362                bbox[0] * (sample.img_w - 1) / denominator,
363                bbox[1] * (sample.img_h - 1) / denominator,
364                bbox[2] * (sample.img_w - 1) / denominator,
365                bbox[3] * (sample.img_h - 1) / denominator,
366            )
367            x0, y0, x1, y1 = bbox
368            if not (0 <= x0 <= x1 < sample.img_w and 0 <= y0 <= y1 < sample.img_h):
369                raise AdapterError(
370                    "bbox is outside the original image", hint="undo processor resizing first"
371                )
372            label = normalize_label(str(value.get("label", ""))) or None
373            return ParsedOutput(
374                True, label=label, bbox=bbox, coordinate_format=coordinate_format
375            )
376        except (KeyError, TypeError, ValueError, json.JSONDecodeError, RPXError) as exc:
377            return ParsedOutput(False, error=str(exc))
378    return ParsedOutput(False, error="unsupported task")
def normalize_label(value: str) -> str:
30def normalize_label(value: str) -> str:
31    value = unicodedata.normalize("NFKC", value).strip().lower().replace("_", " ")
32    value = re.sub(r"[^\w\s-]", "", value)
33    return " ".join(value.split())
@dataclass(frozen=True)
class ParsedOutput:
36@dataclass(frozen=True)
37class ParsedOutput:
38    valid: bool
39    label: str | None = None
40    bbox: tuple[float, float, float, float] | None = None
41    error: str | None = None
42    coordinate_format: str | None = None
ParsedOutput( valid: bool, label: str | None = None, bbox: tuple[float, float, float, float] | None = None, error: str | None = None, coordinate_format: str | None = None)
valid: bool
label: str | None = None
bbox: tuple[float, float, float, float] | None = None
error: str | None = None
coordinate_format: str | None = None
@dataclass(frozen=True)
class ParsedPoints:
45@dataclass(frozen=True)
46class ParsedPoints:
47    """A native Molmo pointing response, kept separate from bbox parsing."""
48
49    valid: bool
50    points: tuple[tuple[float, float], ...] = ()
51    error: str | None = None
52    coordinate_format: str = "molmo_point_percent_0_100"

A native Molmo pointing response, kept separate from bbox parsing.

ParsedPoints( valid: bool, points: tuple[tuple[float, float], ...] = (), error: str | None = None, coordinate_format: str = 'molmo_point_percent_0_100')
valid: bool
points: tuple[tuple[float, float], ...] = ()
error: str | None = None
coordinate_format: str = 'molmo_point_percent_0_100'
def parse_molmo_points(raw: str) -> ParsedPoints:
55def parse_molmo_points(raw: str) -> ParsedPoints:
56    """Parse Molmo's documented ``<point>``/``<points>`` markup.
57
58    Coordinates remain percentages on Molmo's native 0--100 grid.  This
59    function intentionally cannot return a bbox: a point carries no object
60    extent and must not enter the benchmark's bbox-IoU metric.
61    """
62    points: list[tuple[float, float]] = []
63    errors: list[str] = []
64    tags = list(_MOLMO_POINT_TAG_RE.finditer(raw))
65    if not tags:
66        return ParsedPoints(False, error="missing Molmo <point> or <points> tag")
67    for tag in tags:
68        by_index: dict[str, dict[str, float]] = {}
69        for match in _MOLMO_POINT_ATTR_RE.finditer(tag.group("attrs")):
70            index = match.group("index") or "1"
71            axis = match.group("axis").lower()
72            slot = by_index.setdefault(index, {})
73            if axis in slot:
74                errors.append(f"duplicate {axis}{index} coordinate")
75                continue
76            slot[axis] = float(match.group("value"))
77        if not by_index:
78            errors.append("point tag contains no coordinate attributes")
79            continue
80        for index, coordinates in sorted(
81            by_index.items(), key=lambda item: int(item[0])
82        ):
83            if set(coordinates) != {"x", "y"}:
84                errors.append(f"point {index} is missing x or y")
85                continue
86            x, y = coordinates["x"], coordinates["y"]
87            if not (0 <= x <= 100 and 0 <= y <= 100):
88                errors.append(f"point {index} is outside the 0-100 grid")
89                continue
90            points.append((x, y))
91    if errors:
92        return ParsedPoints(False, points=tuple(points), error="; ".join(errors))
93    if not points:
94        return ParsedPoints(False, error="Molmo point markup contains no complete point")
95    return ParsedPoints(True, points=tuple(points))

Parse Molmo's documented <point>/<points> markup.

Coordinates remain percentages on Molmo's native 0--100 grid. This function intentionally cannot return a bbox: a point carries no object extent and must not enter the benchmark's bbox-IoU metric.

def parse_output( sample: rpx_benchmark.vqa.VQASample, raw: str, model_key: str, *, paligemma_target_only: bool = False) -> ParsedOutput:
151def parse_output(
152    sample: VQASample,
153    raw: str,
154    model_key: str,
155    *,
156    paligemma_target_only: bool = False,
157) -> ParsedOutput:
158    label: str | None
159    match: re.Match[str] | None
160    if sample.question_type in BINARY_TYPES:
161        token = normalize_label(raw)
162        if token not in {"yes", "no"}:
163            return ParsedOutput(False, error="expected exactly yes or no")
164        return ParsedOutput(True, label=token)
165    if sample.question_type in ATTRIBUTE_TYPES:
166        label = normalize_label(raw)
167        if not label or len(label.split()) > 8:
168            return ParsedOutput(False, error="invalid object-name answer")
169        return ParsedOutput(True, label=label)
170    if sample.question_type in BBOX_TYPES and model_key.startswith("cosmos-reason2-"):
171        candidates = _cosmos_bbox_candidates(raw)
172        if len(candidates) != 1:
173            return ParsedOutput(
174                False,
175                error=f"expected exactly one Cosmos bbox_2d candidate, got {len(candidates)}",
176            )
177        value = candidates[0]
178        try:
179            raw_bbox = value["bbox_2d"]
180            if not isinstance(raw_bbox, list) or len(raw_bbox) != 4:
181                raise AdapterError(
182                    "Cosmos bbox_2d must contain four numbers",
183                    hint="return one normalized XYXY box",
184                )
185            bbox = tuple(float(coordinate) for coordinate in raw_bbox)
186            if not all(float("-inf") < coordinate < float("inf") for coordinate in bbox):
187                raise AdapterError("Cosmos bbox contains a non-finite number")
188            if not all(0 <= coordinate <= 1000 for coordinate in bbox):
189                raise AdapterError("Cosmos bbox is outside the 0-1000 grid")
190            x0, y0, x1, y1 = bbox
191            if not (x0 <= x1 and y0 <= y1):
192                raise AdapterError("Cosmos bbox corners are not ordered XYXY")
193            scaled = (
194                x0 * (sample.img_w - 1) / 1000,
195                y0 * (sample.img_h - 1) / 1000,
196                x1 * (sample.img_w - 1) / 1000,
197                y1 * (sample.img_h - 1) / 1000,
198            )
199            label = normalize_label(str(value.get("label", ""))) or None
200            return ParsedOutput(
201                True,
202                label=label,
203                bbox=scaled,
204                coordinate_format="cosmos_bbox_2d_0_1000",
205            )
206        except (KeyError, TypeError, ValueError, RPXError) as exc:
207            return ParsedOutput(False, error=str(exc))
208    if sample.question_type in BBOX_TYPES and model_key.startswith("internvl3.5-"):
209        # InternVL's official RefCOCO evaluation protocol serializes one
210        # target-relative 0--1000 box.  Its own evaluator deliberately accepts
211        # either ``label[[x0,y0,x1,y1]]`` or ``[x0,y0,x1,y1]``.  Accept those
212        # two serializations before the generic JSON fallback, but never choose
213        # silently among multiple candidates in this one-object benchmark.
214        matches = list(_INTERNVL_BBOX_RE.finditer(raw))
215        if matches:
216            if len(matches) != 1:
217                return ParsedOutput(False, error="expected exactly one InternVL bbox")
218            match = matches[0]
219            bbox = tuple(float(value) for value in match.groups())
220            if not all(0 <= value <= 1000 for value in bbox):
221                return ParsedOutput(False, error="InternVL bbox is outside the 0-1000 grid")
222            x0, y0, x1, y1 = bbox
223            if not (x0 <= x1 and y0 <= y1):
224                return ParsedOutput(False, error="InternVL bbox corners are not ordered XYXY")
225            prefix = raw[: match.start()]
226            prefix = re.sub(r"</?(?:ref|box)>", "", prefix, flags=re.IGNORECASE)
227            label = normalize_label(prefix.splitlines()[-1]) or None
228            return ParsedOutput(
229                True,
230                label=label,
231                bbox=(
232                    x0 * (sample.img_w - 1) / 1000,
233                    y0 * (sample.img_h - 1) / 1000,
234                    x1 * (sample.img_w - 1) / 1000,
235                    y1 * (sample.img_h - 1) / 1000,
236                ),
237                coordinate_format="internvl_bbox_0_1000",
238            )
239        # The 1B checkpoint sometimes preserves the same 0--1000 coordinate
240        # contract but spells the single box out as labelled corners.  This is
241        # a deterministic serialization variant, not box repair: require one
242        # complete pair, validate it identically, and never infer coordinates
243        # from descriptive prose.
244        verbose_matches = re.findall(
245            r"top[\s-]*left\s+corner\s*:\s*[\[(]\s*"
246            r"(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*[\])]"
247            r".*?bottom[\s-]*right\s+corner\s*:\s*[\[(]\s*"
248            r"(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*[\])]",
249            raw,
250            flags=re.IGNORECASE | re.DOTALL,
251        )
252        if verbose_matches:
253            if len(verbose_matches) != 1:
254                return ParsedOutput(False, error="expected exactly one InternVL corner pair")
255            bbox = tuple(float(value) for value in verbose_matches[0])
256            if not all(0 <= value <= 1000 for value in bbox):
257                return ParsedOutput(False, error="InternVL bbox is outside the 0-1000 grid")
258            x0, y0, x1, y1 = bbox
259            if not (x0 <= x1 and y0 <= y1):
260                return ParsedOutput(False, error="InternVL bbox corners are not ordered XYXY")
261            return ParsedOutput(
262                True,
263                bbox=(
264                    x0 * (sample.img_w - 1) / 1000,
265                    y0 * (sample.img_h - 1) / 1000,
266                    x1 * (sample.img_w - 1) / 1000,
267                    y1 * (sample.img_h - 1) / 1000,
268                ),
269                coordinate_format="internvl_verbose_bbox_0_1000",
270            )
271        # Retain the strict JSON fallback for a checkpoint that follows the
272        # explicit coordinate contract but emits the generic RPX envelope.
273    if sample.question_type in BBOX_TYPES and model_key.startswith("paligemma2-"):
274        match = _LOC_RE.search(raw)
275        if match:
276            if sample.is_in_context and not paligemma_target_only:
277                return ParsedOutput(
278                    False,
279                    error=(
280                        "native PaliGemma loc tokens are relative to the labelled "
281                        "two-image composite, not Image 2; return target-relative JSON"
282                    ),
283                )
284            vals = {key: int(value) for key, value in match.groupdict().items()}
285            bbox = (
286                vals["x0"] * sample.img_w / 1024,
287                vals["y0"] * sample.img_h / 1024,
288                vals["x1"] * sample.img_w / 1024,
289                vals["y1"] * sample.img_h / 1024,
290            )
291            label = normalize_label(raw[match.end() :].split("<", 1)[0]) or None
292            return ParsedOutput(
293                True, label=label, bbox=bbox, coordinate_format="paligemma_loc_1024"
294            )
295        # The scored single-call prompt requests the same JSON contract used
296        # by the other VLMs. Fall through to that parser when PaliGemma obeys
297        # it; native <loc> remains accepted as an architecture-specific bbox
298        # serialization, but no second model call is made.
299    if sample.question_type in BBOX_TYPES:
300        try:
301            value = _json_object(raw)
302            raw_bbox = value["bbox"]
303            # Some otherwise-correct VLM responses add one redundant list
304            # dimension.  Unwrap exactly one singleton; do not guess any more
305            # complicated structure.
306            if (
307                isinstance(raw_bbox, list)
308                and len(raw_bbox) == 1
309                and isinstance(raw_bbox[0], list)
310            ):
311                raw_bbox = raw_bbox[0]
312            if not isinstance(raw_bbox, list) or len(raw_bbox) != 4:
313                raise AdapterError(
314                    "bbox must contain four numbers", hint="return normalized xyxy coordinates"
315                )
316            bbox = tuple(float(v) for v in raw_bbox)
317            if not all(float("-inf") < value < float("inf") for value in bbox):
318                raise AdapterError("bbox contains a non-finite number", hint="return finite pixels")
319            # Molmo's model-facing bbox protocol uses the same 0--100
320            # percentage grid as its documented spatial pointing interface.
321            # This is a complete four-corner box, not a point-derived box.
322            # Convert it deterministically here while retaining the untouched
323            # model text in raw_output for auditability.
324            molmo_percent = model_key in {"molmo-7b-d", "molmoe-1b"}
325            if molmo_percent and not all(0 <= coordinate <= 100 for coordinate in bbox):
326                raise AdapterError(
327                    "Molmo bbox is outside percentage 0-100 coordinates",
328                    hint="return percentage xyxy coordinates",
329                )
330
331            # JSON produced by an adapter may contain a machine-rounding
332            # residue at an exact normalized endpoint (for example
333            # 999 * 1000 / 999 -> 1000.0000000000001). Accept only that tiny
334            # numerical residue, then clamp it; genuinely out-of-range model
335            # coordinates remain invalid.
336            coordinate_epsilon = 1e-9
337            if not all(
338                -coordinate_epsilon <= coordinate <= 1000 + coordinate_epsilon
339                for coordinate in bbox
340            ):
341                raise AdapterError(
342                    "bbox is outside normalized 0-1000 coordinates",
343                    hint="return normalized xyxy coordinates",
344                )
345            bbox = tuple(min(1000.0, max(0.0, coordinate)) for coordinate in bbox)
346            # Decode the two widespread normalized conventions without using
347            # model identity. Values containing a genuine fraction and entirely
348            # within [0,1] are unit-normalized; everything else follows the
349            # requested [0,1000] protocol. This fixes the previous silent bug
350            # where [0.5,...] became a sub-pixel box at the top-left.
351            unit_normalized = not molmo_percent and all(
352                0 <= coordinate <= 1 for coordinate in bbox
353            )
354            denominator = 100 if molmo_percent else 1 if unit_normalized else 1000
355            coordinate_format = (
356                "molmo_bbox_percent_0_100"
357                if molmo_percent
358                else "normalized_0_1"
359                if unit_normalized
360                else "normalized_0_1000"
361            )
362            bbox = (
363                bbox[0] * (sample.img_w - 1) / denominator,
364                bbox[1] * (sample.img_h - 1) / denominator,
365                bbox[2] * (sample.img_w - 1) / denominator,
366                bbox[3] * (sample.img_h - 1) / denominator,
367            )
368            x0, y0, x1, y1 = bbox
369            if not (0 <= x0 <= x1 < sample.img_w and 0 <= y0 <= y1 < sample.img_h):
370                raise AdapterError(
371                    "bbox is outside the original image", hint="undo processor resizing first"
372                )
373            label = normalize_label(str(value.get("label", ""))) or None
374            return ParsedOutput(
375                True, label=label, bbox=bbox, coordinate_format=coordinate_format
376            )
377        except (KeyError, TypeError, ValueError, json.JSONDecodeError, RPXError) as exc:
378            return ParsedOutput(False, error=str(exc))
379    return ParsedOutput(False, error="unsupported task")