Spaces:
Running on Zero
Running on Zero
File size: 5,780 Bytes
fed954e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | """
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]
@dataclass(frozen=True)
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}")
|