Spaces:
Running on Zero
Running on Zero
| """ | |
| coords.py — Coordinate normalization (the correctness centerpiece). | |
| Different VLMs report boxes in different spaces: | |
| * Qwen3-VL emits RELATIVE coordinates in 0..1000 (integers). | |
| * Qwen3.5 / many checkpoints emit 0..1 floats. | |
| * COCO / LVIS ground truth is ABSOLUTE pixels. | |
| If predictions and ground truth are compared in mismatched spaces, every IoU is | |
| silently wrong and detection/3D/segmentation metrics collapse. To prevent that, | |
| the canonical internal form is ALWAYS pixel-absolute xyxy. GT is converted to | |
| canonical at load time; predictions are converted at score time. No metric ever | |
| sees a non-canonical coordinate (enforced by tests). | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from enum import Enum | |
| from typing import Sequence | |
| class CoordSpace(str, Enum): | |
| """The space a set of raw coordinates lives in.""" | |
| PIXEL_ABS = "pixel_abs" # absolute pixels, image-sized | |
| NORM_0_1 = "norm_0_1" # 0..1 floats | |
| NORM_0_1000 = "norm_0_1000" # 0..1000 ints (Qwen3-VL native) | |
| # Box coordinate layouts a model might emit / GT might store. | |
| XYXY = "xyxy" # [x1, y1, x2, y2] | |
| XYWH = "xywh" # [x, y, w, h] (COCO GT layout) | |
| def xywh_to_xyxy(box: Sequence[float]) -> list[float]: | |
| x, y, w, h = box | |
| return [x, y, x + w, y + h] | |
| def xyxy_to_xywh(box: Sequence[float]) -> list[float]: | |
| x1, y1, x2, y2 = box | |
| return [x1, y1, x2 - x1, y2 - y1] | |
| class BBox: | |
| """A bounding box in the canonical form: pixel-absolute xyxy.""" | |
| x1: float | |
| y1: float | |
| x2: float | |
| y2: float | |
| def area(self) -> float: | |
| return max(0.0, self.x2 - self.x1) * max(0.0, self.y2 - self.y1) | |
| def clip(self, size: tuple[int, int]) -> "BBox": | |
| """Clip to image bounds. size = (W, H).""" | |
| w, h = size | |
| return BBox( | |
| x1=min(max(self.x1, 0.0), w), | |
| y1=min(max(self.y1, 0.0), h), | |
| x2=min(max(self.x2, 0.0), w), | |
| y2=min(max(self.y2, 0.0), h), | |
| ) | |
| def iou(self, other: "BBox") -> float: | |
| ix1 = max(self.x1, other.x1) | |
| iy1 = max(self.y1, other.y1) | |
| ix2 = min(self.x2, other.x2) | |
| iy2 = min(self.y2, other.y2) | |
| iw = max(0.0, ix2 - ix1) | |
| ih = max(0.0, iy2 - iy1) | |
| inter = iw * ih | |
| if inter <= 0.0: | |
| return 0.0 | |
| union = self.area() + other.area() - inter | |
| return inter / union if union > 0 else 0.0 | |
| def as_list(self) -> list[float]: | |
| return [self.x1, self.y1, self.x2, self.y2] | |
| def _scale_for_space(space: CoordSpace, size: tuple[int, int]) -> tuple[float, float]: | |
| """Return (x_scale, y_scale) that maps a raw coord in `space` to pixels.""" | |
| w, h = size | |
| if space == CoordSpace.PIXEL_ABS: | |
| return 1.0, 1.0 | |
| if space == CoordSpace.NORM_0_1: | |
| return float(w), float(h) | |
| if space == CoordSpace.NORM_0_1000: | |
| return w / 1000.0, h / 1000.0 | |
| raise ValueError(f"unknown coord space: {space!r}") | |
| def to_canonical( | |
| raw: Sequence[float], | |
| space: CoordSpace, | |
| size: tuple[int, int], | |
| fmt: str = XYXY, | |
| ) -> BBox: | |
| """Convert a raw 4-tuple in `space`/`fmt` to a canonical pixel-abs xyxy BBox. | |
| size = (W, H) in pixels. `fmt` is XYXY or XYWH. | |
| """ | |
| if len(raw) != 4: | |
| raise ValueError(f"bbox must have 4 values, got {len(raw)}: {raw!r}") | |
| coords = list(map(float, raw)) | |
| if fmt == XYWH: | |
| coords = xywh_to_xyxy(coords) | |
| elif fmt != XYXY: | |
| raise ValueError(f"unknown bbox fmt: {fmt!r}") | |
| sx, sy = _scale_for_space(space, size) | |
| return BBox(coords[0] * sx, coords[1] * sy, coords[2] * sx, coords[3] * sy).clip(size) | |
| def from_canonical(box: BBox, space: CoordSpace, size: tuple[int, int], fmt: str = XYXY) -> list[float]: | |
| """Inverse of to_canonical: canonical BBox → raw coords in `space`/`fmt`.""" | |
| sx, sy = _scale_for_space(space, size) | |
| xyxy = [box.x1 / sx, box.y1 / sy, box.x2 / sx, box.y2 / sy] | |
| return xyxy_to_xywh(xyxy) if fmt == XYWH else xyxy | |
| def detect_space(raw_values: Sequence[float], size: tuple[int, int]) -> CoordSpace: | |
| """Defensive fallback for models that ignore the requested space. | |
| Used ONLY when a model's output space can't be trusted; the caller logs | |
| `coord_space_inferred=True` (itself a robustness signal). Heuristic: | |
| * all values <= 1.0 → NORM_0_1 | |
| * all values <= 1000 and the image is larger than 1000 px on a side → NORM_0_1000 | |
| * otherwise → PIXEL_ABS | |
| """ | |
| vals = [abs(float(v)) for v in raw_values if v is not None] | |
| if not vals: | |
| return CoordSpace.PIXEL_ABS | |
| mx = max(vals) | |
| w, h = size | |
| if mx <= 1.0: | |
| return CoordSpace.NORM_0_1 | |
| if mx <= 1000.0 and max(w, h) > 1000: | |
| return CoordSpace.NORM_0_1000 | |
| if mx <= 1000.0 and max(w, h) <= 1000: | |
| # ambiguous: 0..1000 ints vs small-image pixels. Prefer NORM_0_1000 only | |
| # if values clearly exceed the image dimensions. | |
| if mx > max(w, h): | |
| return CoordSpace.NORM_0_1000 | |
| return CoordSpace.PIXEL_ABS | |
| return CoordSpace.PIXEL_ABS | |
| def prompt_hint_for(space: CoordSpace) -> str: | |
| """A sentence appended to the system prompt telling the model which space to use.""" | |
| if space == CoordSpace.PIXEL_ABS: | |
| return "Report bounding boxes as [x1, y1, x2, y2] in absolute pixel coordinates." | |
| if space == CoordSpace.NORM_0_1: | |
| return "Report bounding boxes as [x1, y1, x2, y2] normalized to 0..1 of the image dimensions." | |
| if space == CoordSpace.NORM_0_1000: | |
| return ( | |
| "Report bounding boxes as [x1, y1, x2, y2] integers in 0..1000, " | |
| "relative to the image width and height." | |
| ) | |
| raise ValueError(f"unknown coord space: {space!r}") | |