Spaces:
Running on Zero
Running on Zero
File size: 13,836 Bytes
d0e7c02 0c5d1aa d0e7c02 0c5d1aa d0e7c02 0c5d1aa d0e7c02 0c5d1aa d0e7c02 | 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 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | """Reading slots from a served adapter (vLLM; SGLang optional).
A served adapter is described by its run's config.json (written by slotread.train): each task's system
prompt, slot order and label ids. SlotModel turns that into reads.
read one request per slot, in parallel: each request's assistant text ends right before that
slot's placeholder ("action: ___ x:"), max_tokens=1, and vLLM returns the log-probabilities
of exactly that slot's label ids (logprob_token_ids). The default.
read(..., lazy=True) computer use only: action, x and y first (all a click needs); key, scroll or
swipe-end slots only when the action calls for them
read_one every slot from one request's prompt log-probabilities (top-k per position; slower)
read_sgl every slot from one SGLang /generate request (logprob_start_len + token_ids_logprob)
Serving (serving/serve_vllm.sh): vLLM caps logprob_token_ids at 128 per request; serving/patch_vllm.sh
raises it to 256 so a 256-bin slot is one request (MAX_LABEL_IDS=256). Run vLLM with
--api-server-count 8: a single API-server process serializes the parallel slot requests.
"""
import base64, io, json, math, os, time, urllib.error, urllib.request
from concurrent.futures import ThreadPoolExecutor
import torch
from .numbers import slots_for
from .labels import Arm, PLACEHOLDER
from .tasks import AgentTask, BoxTask, SegTask
VLLM_URL = os.environ.get("VLLM_URL", f"http://127.0.0.1:{os.environ.get('PORT', 8001)}")
MAX_IDS = int(os.environ.get("MAX_LABEL_IDS", 256)) # 128 on stock vLLM (see patch_vllm.sh)
PL_TOPK = int(os.environ.get("PL_TOPK", 50)) # read_one: prompt log-probabilities kept per position
EXTRA_BODY = json.loads(os.environ.get("EXTRA_BODY", "{}")) # e.g. {"mm_processor_kwargs": {"max_soft_tokens": 1120}}
SGL_URL = os.environ.get("SGL_URL", "http://127.0.0.1:30000")
def post(body, url=None, path="/v1/chat/completions", tries=2):
"""POST JSON; returns (response dict, ms). HTTP errors come back as {"error": ...}."""
t = time.perf_counter()
d = {}
for _ in range(tries):
r = urllib.request.Request((url or VLLM_URL) + path, json.dumps(body).encode(),
{"content-type": "application/json"})
try:
d = json.load(urllib.request.urlopen(r, timeout=300))
break
except urllib.error.HTTPError as e:
d = {"error": e.read().decode()[:300]}
if e.code != 500:
break
return d, (time.perf_counter() - t) * 1000
def generate(model, url, text, max_tokens=64, think=False, system=None, **extra):
"""A plain chat completion with one image (the vanilla baselines). Returns (text, ms, usage)."""
msgs = ([{"role": "system", "content": [{"type": "text", "text": system}]}] if system else []) + \
[{"role": "user", "content": [{"type": "image_url", "image_url": {"url": url}},
{"type": "text", "text": text}]}]
d, ms = post(dict(model=model, messages=msgs, max_tokens=max_tokens, temperature=0,
chat_template_kwargs={"enable_thinking": think}, **extra))
if "error" in d:
return "", ms, {}
return d["choices"][0]["message"].get("content") or "", ms, d.get("usage", {})
CONFIG_NAME = "slotread_config.json"
def load_config(path):
"""A model's slot config: a run folder (config.json, as slotread.train writes it), a folder with
slotread_config.json (the released models), or a Hugging Face repo id holding slotread_config.json."""
if os.path.isdir(path):
for name in (CONFIG_NAME, "config.json"):
p = os.path.join(path, name)
if os.path.exists(p):
c = json.load(open(p))
if "tasks" in c:
return c
raise FileNotFoundError(f"no {CONFIG_NAME} in {path}")
from huggingface_hub import hf_hub_download
return json.load(open(hf_hub_download(path, CONFIG_NAME)))
class SlotModel:
"""A served slot model: `name` as vLLM serves it (the adapter's --lora-modules name, or the merged
model's --served-model-name), `path` its slot config (run folder, released model folder or HF repo id).
`text_model`: the served base model that writes text steps (what to type, the answer)."""
def __init__(self, name, path, text_model=None):
self.name, self.path, self.text_model = name, path, text_model
c = load_config(path)
self.cfg = c
self.arm = Arm(c["arm"], c["coarse"], c["bins"], c["tokens"], c["fine"])
self.tasks = {"agent": AgentTask(), "box": BoxTask(), "seg": SegTask(c["rays"])}
def task_cfg(self, task):
return self.cfg["tasks"][task]
def body(self, task, url, user, assistant, **extra):
msgs = [{"role": "system", "content": [{"type": "text", "text": self.task_cfg(task)["system"]}]},
{"role": "user", "content": [{"type": "image_url", "image_url": {"url": url}},
{"type": "text", "text": user}]},
{"role": "assistant", "content": [{"type": "text", "text": assistant}]}]
return dict(model=self.name, messages=msgs, add_generation_prompt=False, continue_final_message=True,
chat_template_kwargs={"enable_thinking": False}, max_tokens=1, temperature=0,
logprobs=True, return_tokens_as_token_ids=True, **EXTRA_BODY, **extra)
def read(self, task, url, user, slots=None, lazy=False):
"""{slot: log-probabilities over its labels} from parallel per-slot requests."""
if lazy and task == "agent":
from .data.aguvis import ACTIONS
lp = self.read(task, url, user, ["action", *slots_for("x", self.arm), *slots_for("y", self.arm)])
more = {"press": ["key"], "scroll": ["sdir", "samt"],
"swipe": slots_for("x2", self.arm) + slots_for("y2", self.arm)}.get(ACTIONS[int(lp["action"].argmax())])
if more:
lp.update(self.read(task, url, user, more))
return lp
c = self.task_cfg(task)
order, lab = c["slots"], c["labels"]
slots = slots or order
jobs = [(s, lab[s][i:i + MAX_IDS]) for s in slots for i in range(0, len(lab[s]), MAX_IDS)]
def one(job):
s, ids = job
k = order.index(s)
prefix = " ".join(f"{x}: ___" for x in order[:k]) + (" " if k else "") + f"{s}:"
d, _ = post(self.body(task, url, user, prefix, top_logprobs=1, logprob_token_ids=ids))
if "error" in d:
raise RuntimeError(f"{self.name}/{task}/{s}: {d['error']}")
top = d["choices"][0]["logprobs"]["content"][0]["top_logprobs"]
return s, {int(t["token"].split(":")[1]): t["logprob"] for t in top}
with ThreadPoolExecutor(len(jobs)) as p:
res = list(p.map(one, jobs))
got = {}
for s, v in res:
got.setdefault(s, {}).update(v)
return {s: torch.tensor([got[s].get(i, -1e4) for i in lab[s]]) for s in slots}
def read_one(self, task, url, user):
"""Every slot from one request's prompt log-probabilities: each slot's answer is its own
placeholder's row. Labels outside the top PL_TOPK get 5 nats below the lowest returned."""
c = self.task_cfg(task)
order, lab = c["slots"], c["labels"]
d, _ = post(self.body(task, url, user, " ".join(f"{s}: ___" for s in order), top_logprobs=1,
prompt_logprobs=PL_TOPK))
if "error" in d:
raise RuntimeError(f"{self.name}/{task}: {d['error']}")
pl = d.get("prompt_logprobs") or []
marks = [i for i, p in enumerate(pl) if p and any(v.get("decoded_token") == PLACEHOLDER for v in p.values())]
marks = marks[-len(order):]
if len(marks) != len(order):
raise RuntimeError(f"{self.name}/{task}: {len(marks)} placeholders for {len(order)} slots")
out = {}
for s, m in zip(order, marks):
vals = {int(k): v["logprob"] for k, v in (pl[m] or {}).items()}
floor = (min(vals.values()) - 5) if vals else -20.0
out[s] = torch.tensor([vals.get(i, floor) for i in lab[s]])
return out
def read_sgl(self, task, url, user, slots=None, budget=280, processor="nvidia/Gemma-4-31B-IT-NVFP4"):
"""Every requested slot from ONE SGLang /generate request (serving/sglang): the prompt carries
the whole layout, and SGLang returns, for the last positions only (logprob_start_len), the
log-probabilities of our label ids only (token_ids_logprob). Gemma 4 only (its image token
count is computed client-side to find where the slots start)."""
from PIL import Image
proc = _processor(processor)
c = self.task_cfg(task)
order, lab = c["slots"], c["labels"]
slots = slots or order
layout = " ".join(f"{x}: ___" for x in order)
msgs = [{"role": "system", "content": [{"type": "text", "text": c["system"]}]},
{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": user}]},
{"role": "assistant", "content": [{"type": "text", "text": layout}]}]
text = proc.apply_chat_template(msgs, tokenize=False, add_generation_prompt=False,
continue_final_message=True, enable_thinking=False)
W, H = Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1]))).size
# Gemma 4 fits the image to the budget in 48 x 48 pixel cells (16 px patches, 3 x 3 pooling),
# one token per cell, plus 2 boundary tokens
f = math.sqrt(budget * 9 * 256 / (W * H))
soft = int(f * H // 48) * int(f * W // 48)
n_text = len(proc.tokenizer(text, add_special_tokens=False)["input_ids"])
tail = len(proc.tokenizer(layout, add_special_tokens=False)["input_ids"])
start = max(0, n_text - 1 + soft + 2 - tail - 4) # a few positions early is harmless
ph = proc.tokenizer("x: ___", add_special_tokens=False)["input_ids"][-1]
body = dict(text=text, image_data=[url], lora_path=self.name, return_logprob=True, logprob_start_len=start,
top_logprobs_num=0, token_ids_logprob=sorted({i for x in slots for i in lab[x]}),
sampling_params=dict(max_new_tokens=1, temperature=0))
d, _ = post(body, url=SGL_URL, path="/generate")
if "error" in d:
raise RuntimeError(f"{self.name}/{task}: {d['error']}")
mi = d["meta_info"]
marks = [i for i, t in enumerate(mi["input_token_logprobs"]) if t[1] == ph][-len(order):]
if len(marks) != len(order):
raise RuntimeError(f"{self.name}/{task}: {len(marks)} placeholders for {len(order)} slots")
out = {}
for x in slots:
row = {t[1]: t[0] for t in mi["input_token_ids_logprobs"][marks[order.index(x)]]}
out[x] = torch.tensor([row.get(i, -1e4) for i in lab[x]])
return out
def image_url(self, image):
"""A PIL image as the data URL sent to the server: JPEG at the model's input size by default (the
fast path; IMG_FMT / IMG_MAX_PX), capped as in training when the config says so (GUI-Owl: 1 MP).
A string is taken as a URL already."""
if isinstance(image, str):
return image
from .images import cap_image, data_url
cap = self.cfg.get("image_cap")
return data_url(cap_image(image.convert("RGB"), cap), max_px=cap) if cap else data_url(image)
def decide(self, image, instruction, previous="None"):
"""A computer-use step: action and point; the key, scroll or swipe end when the action needs them.
Reads action, x and y first, then the action's other slots (lazy)."""
from .data.aguvis import KEYS, SDIR
t = time.perf_counter()
lp = self.read("agent", self.image_url(image), agent_user(instruction, previous), lazy=True)
d = self.tasks["agent"].decode(lp, self.arm)
if "key" in lp:
d["key"] = KEYS[int(lp["key"].argmax())]
if "sdir" in lp:
d.update(sdir=SDIR[int(lp["sdir"].argmax())], samt=int(lp["samt"].argmax()))
return dict(d, ms=round((time.perf_counter() - t) * 1000, 1))
def box(self, image, description):
"""An element's or object's box [x1, y1, x2, y2] in [0, 1]."""
t = time.perf_counter()
lp = self.read("box", self.image_url(image), self.tasks["box"].user({"instruction": description}))
return dict(box=self.tasks["box"].decode(lp, self.arm), ms=round((time.perf_counter() - t) * 1000, 1))
def write_text(self, image, instruction, action, previous="None", max_tokens=24):
"""The text a type / answer / open step needs, written by the served base model (text_model)."""
from .agent import TEXT_ASK
if not self.text_model:
raise RuntimeError("text steps need the base model: serve it and pass text_model=<its served name>")
text, _, _ = generate(self.text_model, self.image_url(image),
agent_user(instruction, previous) + "\n\n" + TEXT_ASK[action], max_tokens=max_tokens)
return text.strip().strip('"').split("\n")[0][:120]
_PROC = {}
def _processor(name):
if name not in _PROC:
from transformers import AutoProcessor
_PROC[name] = AutoProcessor.from_pretrained(name)
return _PROC[name]
def agent_user(instruction, previous="None"):
"""The agent layout's user text (AGUVIS's format)."""
return ("Please generate the next move according to the UI screenshot, instruction and previous actions.\n\n"
f"Instruction: {instruction}\n\nPrevious actions:\n{previous}")
|