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