File size: 10,403 Bytes
d340bf5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
"""Image inputs for the vision variant: loading, resizing to the ViT token budget, and the patch-grid geometry
that the `point` head works in.

Geometry: the vision tower emits one token per (patch_size * spatial_merge_size)^2 pixel block (32x32 px for
Qwen3.5), row-major over the merged grid (hm, wm) = (h / merge, w / merge) where (t, h, w) = image_grid_thw.
"""
from __future__ import annotations

import base64
import io
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union

DEFAULT_MAX_PIXELS = 1280 * 800  # ~1000 visual tokens at 32 px/token
HIGH_DETAIL_MAX_PIXELS = 1920 * 1200  # ~2280 tokens
DEFAULT_MIN_PIXELS = 64 * 64
MAX_IMAGES_PER_REQUEST = 4
MAX_VISUAL_TOKENS = 8192


def load_image(src: Any):
    """PIL image from: PIL.Image, bytes, data-URI / base64 string, http(s) URL, or a filesystem path."""
    from PIL import Image

    if isinstance(src, Image.Image):
        return src.convert("RGB")
    if isinstance(src, (bytes, bytearray)):
        return Image.open(io.BytesIO(src)).convert("RGB")
    if hasattr(src, "shape"):  # numpy array (H, W, 3)
        return Image.fromarray(src).convert("RGB")
    if not isinstance(src, str):
        raise TypeError(f"unsupported image source {type(src)}")
    if src.startswith("data:"):
        _, _, payload = src.partition(",")
        return Image.open(io.BytesIO(base64.b64decode(payload))).convert("RGB")
    if src.startswith(("http://", "https://")):
        import urllib.request

        req = urllib.request.Request(src, headers={"User-Agent": "openthai-systemone/1.0"})
        with urllib.request.urlopen(req, timeout=20) as r:
            return Image.open(io.BytesIO(r.read())).convert("RGB")
    if len(src) > 512 and "/" not in src[:16] and not src.startswith("."):
        try:  # bare base64
            return Image.open(io.BytesIO(base64.b64decode(src, validate=True))).convert("RGB")
        except Exception:
            pass
    return Image.open(src).convert("RGB")


@dataclass
class ImageRef:
    """One image attached to a request."""

    id: str
    image: Any  # anything load_image accepts
    detail: str = "auto"  # auto | high
    role: Optional[str] = None  # free text tag, shown in the sequence (screenshot, frame, photo, document)

    @classmethod
    def parse(cls, obj: Union["ImageRef", Dict[str, Any]]) -> "ImageRef":
        if isinstance(obj, ImageRef):
            return obj
        d = dict(obj)
        src = d.get("image", d.get("data", d.get("url", d.get("path"))))
        if src is None:
            raise ValueError("image needs one of: image / data / url / path")
        return cls(id=str(d.get("id", "img")), image=src, detail=str(d.get("detail", "auto")), role=d.get("role"))


@dataclass
class ProcessedImages:
    ids: List[str]
    pixel_values: Any  # torch.Tensor (sum_patches, C*T*P*P)
    grid_thw: List[List[int]]  # per image (t, h, w) in patches
    sizes: List[Tuple[int, int]]  # original (W, H)
    n_tokens: List[int] = field(default_factory=list)
    merge: int = 2

    def tokens_for(self, i: int) -> int:
        t, h, w = self.grid_thw[i]
        return t * h * w // (self.merge * self.merge)

    def merged_grid(self, i: int) -> Tuple[int, int]:
        t, h, w = self.grid_thw[i]
        return h // self.merge, w // self.merge


def fast_patchify(im, image_processor, *, min_pixels: int, max_pixels: int, device=None):
    """Qwen2-VL preprocessing without the HF processor's overhead (~3 ms vs ~40 ms per frame on a busy CPU).

    Same maths: smart_resize to multiples of patch*merge within [min_pixels, max_pixels], bicubic resize, rescale,
    normalise with the processor's mean/std, duplicate the frame temporal_patch_size times, and flatten patches in the
    processor's (t, h/merge, w/merge, merge, merge, C, T, p, p) order. tests/test_vision.py checks equality with the HF
    processor output.
    """
    import numpy as np
    import torch
    from PIL import Image
    from transformers.models.qwen2_vl.image_processing_qwen2_vl import smart_resize

    patch = int(getattr(image_processor, "patch_size", 16))
    merge = int(getattr(image_processor, "merge_size", 2))
    tp = int(getattr(image_processor, "temporal_patch_size", 2))
    mean = torch.tensor(image_processor.image_mean, dtype=torch.float32).view(3, 1, 1)
    std = torch.tensor(image_processor.image_std, dtype=torch.float32).view(3, 1, 1)
    w, h = im.size
    rh, rw = smart_resize(h, w, factor=patch * merge, min_pixels=min_pixels, max_pixels=max_pixels)
    if (rw, rh) != (w, h):
        im = im.resize((rw, rh), Image.BICUBIC)
    x = torch.from_numpy(np.asarray(im, dtype=np.uint8))
    if device is not None:  # normalise + patchify on the GPU: the CPU only resizes and uploads 3 bytes/pixel
        x = x.to(device, non_blocking=True)
        mean, std = mean.to(device), std.to(device)
    x = x.permute(2, 0, 1).float() / 255.0  # (C, H, W)
    x = (x - mean) / std
    gh, gw = rh // patch, rw // patch
    x = x.unsqueeze(0).expand(tp, -1, -1, -1)  # (T, C, H, W)
    x = x.reshape(tp, 3, gh // merge, merge, patch, gw // merge, merge, patch)
    # HF order: (grid_t, temporal, C, gh//m, m, p, gw//m, m, p) -> transpose(0, 3, 6, 4, 7, 2, 1, 5, 8)
    x = x.unsqueeze(0)  # (1, T, C, gh/m, m, p, gw/m, m, p)
    x = x.permute(0, 3, 6, 4, 7, 2, 1, 5, 8).reshape(gh * gw, 3 * tp * patch * patch)
    return x, [1, gh, gw]


def process_images(image_processor, refs: Sequence[ImageRef], *, max_pixels: int = DEFAULT_MAX_PIXELS,
                   min_pixels: int = DEFAULT_MIN_PIXELS, fast: bool = True, device=None) -> ProcessedImages:
    """Preprocess each image (own detail cap each) and concatenate. fast=True uses fast_patchify (identical output)."""
    import torch

    if len(refs) > MAX_IMAGES_PER_REQUEST:
        raise ValueError(f"at most {MAX_IMAGES_PER_REQUEST} images per request")
    merge = int(getattr(image_processor, "merge_size", 2))
    pv, grids, sizes, ids, ntoks = [], [], [], [], []
    for r in refs:
        im = load_image(r.image)
        sizes.append(im.size)
        cap = HIGH_DETAIL_MAX_PIXELS if r.detail == "high" else max_pixels
        if fast:
            x, g = fast_patchify(im, image_processor, min_pixels=min_pixels, max_pixels=cap, device=device)
            pv.append(x)
        else:
            out = image_processor(images=[im], size={"shortest_edge": min_pixels, "longest_edge": cap}, return_tensors="pt")
            pv.append(out["pixel_values"])
            g = [int(v) for v in out["image_grid_thw"][0].tolist()]
        grids.append(g)
        ids.append(r.id)
        ntoks.append(g[0] * g[1] * g[2] // (merge * merge))
    if sum(ntoks) > MAX_VISUAL_TOKENS:
        raise ValueError(f"images need {sum(ntoks)} visual tokens > {MAX_VISUAL_TOKENS}; lower detail or send fewer images")
    return ProcessedImages(ids=ids, pixel_values=torch.cat(pv, 0), grid_thw=grids, sizes=sizes, n_tokens=ntoks, merge=merge)


def bbox_to_token_target(bbox: Optional[Sequence[float]], hm: int, wm: int) -> "torch.Tensor":
    """Soft target over the hm*wm tokens (+1 null slot at the end) for a normalised bbox [x0,y0,x1,y1] in 0..1.

    Cells whose area overlaps the box get weight = overlap fraction of the cell (GUI-Actor style multi-patch
    supervision); normalised to sum 1. bbox None -> all mass on the null slot ("not on screen").
    """
    import torch

    tgt = torch.zeros(hm * wm + 1)
    if bbox is None:
        tgt[-1] = 1.0
        return tgt
    x0, y0, x1, y1 = [float(v) for v in bbox]
    x0, x1 = sorted((max(0.0, min(1.0, x0)), max(0.0, min(1.0, x1))))
    y0, y1 = sorted((max(0.0, min(1.0, y0)), max(0.0, min(1.0, y1))))
    # a degenerate (point) box marks the cell that contains it
    if x1 - x0 < 1e-6:
        x0, x1 = max(0.0, x0 - 0.5 / wm), min(1.0, x0 + 0.5 / wm)
    if y1 - y0 < 1e-6:
        y0, y1 = max(0.0, y0 - 0.5 / hm), min(1.0, y0 + 0.5 / hm)
    for r in range(hm):
        cy0, cy1 = r / hm, (r + 1) / hm
        oy = max(0.0, min(cy1, y1) - max(cy0, y0))
        if oy <= 0:
            continue
        for c in range(wm):
            cx0, cx1 = c / wm, (c + 1) / wm
            ox = max(0.0, min(cx1, x1) - max(cx0, x0))
            if ox > 0:
                tgt[r * wm + c] = ox * oy * hm * wm  # fraction of the cell covered
    if tgt[:-1].sum() <= 0:
        tgt[-1] = 1.0
    else:
        tgt = tgt / tgt.sum()
    return tgt


def token_xy(index: int, hm: int, wm: int) -> Tuple[float, float]:
    """Centre of merged token `index` in normalised image coordinates."""
    r, c = divmod(index, wm)
    return (c + 0.5) / wm, (r + 0.5) / hm


def heatmap_to_candidates(probs: Sequence[float], hm: int, wm: int, *, rel_threshold: float = 0.3, max_candidates: int = 5):
    """GUI-Actor decoding: threshold at rel_threshold * max, 8-connected components, mass-weighted centroids.

    probs: hm*wm token probabilities (null slot already removed). Returns list of {x, y, p, bbox} sorted by p.
    """
    n = hm * wm
    p = [float(v) for v in probs[:n]]
    if n == 0 or max(p) <= 0:
        return []
    thr = rel_threshold * max(p)
    keep = {i for i, v in enumerate(p) if v >= thr}
    seen, comps = set(), []
    for start in sorted(keep, key=lambda i: -p[i]):
        if start in seen:
            continue
        comp, stack = [], [start]
        seen.add(start)
        while stack:
            i = stack.pop()
            comp.append(i)
            r, c = divmod(i, wm)
            for dr in (-1, 0, 1):
                for dc in (-1, 0, 1):
                    rr, cc = r + dr, c + dc
                    if 0 <= rr < hm and 0 <= cc < wm:
                        j = rr * wm + cc
                        if j in keep and j not in seen:
                            seen.add(j)
                            stack.append(j)
        mass = sum(p[i] for i in comp)
        xs = [token_xy(i, hm, wm) for i in comp]
        x = sum(px * p[i] for (px, _), i in zip(xs, comp)) / mass
        y = sum(py * p[i] for (_, py), i in zip(xs, comp)) / mass
        rows = [i // wm for i in comp]
        cols = [i % wm for i in comp]
        bbox = [min(cols) / wm, min(rows) / hm, (max(cols) + 1) / wm, (max(rows) + 1) / hm]
        comps.append({"x": round(x, 4), "y": round(y, 4), "p": round(mass, 4), "bbox": [round(v, 4) for v in bbox]})
    comps.sort(key=lambda d: -d["p"])
    return comps[:max_candidates]