kobkrit's picture
OpenThai-SystemOne v0.3 (f3709948) — FP8-Dynamic
d340bf5 verified
Raw History Blame Contribute Delete
10.4 kB
"""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]