Spaces:
Running on Zero
Running on Zero
GUI-Decisions Space
Browse files- .gitattributes +6 -0
- README.md +24 -5
- app.py +167 -0
- examples/agent.tsv +9 -0
- examples/blog.png +0 -0
- examples/box.tsv +8 -0
- examples/coco_armchair.jpg +3 -0
- examples/coco_cow.jpg +3 -0
- examples/coco_desk.jpg +3 -0
- examples/coco_dog.jpg +3 -0
- examples/coco_motorcycle.jpg +3 -0
- examples/flights.png +0 -0
- examples/settings.png +0 -0
- examples/todo.png +0 -0
- examples/wiki_bee.png +3 -0
- how.md +39 -0
- intro.md +14 -0
- requirements.txt +7 -0
- slotread/__init__.py +2 -0
- slotread/agent.py +258 -0
- slotread/client.py +194 -0
- slotread/data/__init__.py +39 -0
- slotread/data/aguvis.py +107 -0
- slotread/data/evalsets.py +70 -0
- slotread/data/refcoco.py +46 -0
- slotread/data/waveui.py +75 -0
- slotread/evaluate.py +188 -0
- slotread/geometry.py +65 -0
- slotread/images.py +62 -0
- slotread/labels.py +87 -0
- slotread/local.py +139 -0
- slotread/numbers.py +86 -0
- slotread/tasks.py +265 -0
- slotread/train.py +243 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
examples/coco_armchair.jpg filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
examples/coco_cow.jpg filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
examples/coco_desk.jpg filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
examples/coco_dog.jpg filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
examples/coco_motorcycle.jpg filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
examples/wiki_bee.png filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,13 +1,32 @@
|
|
| 1 |
---
|
| 2 |
-
title: GUI
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: gray
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.30.0
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: GUI-Decisions
|
| 3 |
+
emoji: 🎯
|
| 4 |
+
colorFrom: blue
|
| 5 |
colorTo: gray
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.30.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
short_description: Computer-use steps read in one forward pass, no decoding
|
| 12 |
+
models:
|
| 13 |
+
- google/gemma-4-31B-it
|
| 14 |
+
- infinitylogesh/GUI-Decisions-31B-LoRA
|
| 15 |
+
- infinitylogesh/GUI-Decisions-31B
|
| 16 |
---
|
| 17 |
|
| 18 |
+
Slot reading on Gemma 4 31B: a computer-use step (action + point) or a box, read from one forward pass
|
| 19 |
+
instead of decoded. See the [blog post](https://logeshumapathi.com/blog/2026/10/06/slot-reading.html).
|
| 20 |
+
|
| 21 |
+
## Example images
|
| 22 |
+
|
| 23 |
+
- `settings.png`, `flights.png`, `todo.png`: mock interfaces made for this demo.
|
| 24 |
+
- `wiki_bee.png`: a screenshot of the Wikipedia article [Honey bee](https://en.wikipedia.org/wiki/Honey_bee)
|
| 25 |
+
(text CC BY-SA 4.0).
|
| 26 |
+
- `blog.png`: a screenshot of [logeshumapathi.com/blog](https://logeshumapathi.com/blog/).
|
| 27 |
+
- COCO val2017 photos from Flickr, CC BY 2.0:
|
| 28 |
+
`coco_dog.jpg` ([source](http://farm1.staticflickr.com/92/260183330_494bd996d8_z.jpg)),
|
| 29 |
+
`coco_desk.jpg` ([source](http://farm4.staticflickr.com/3523/3750770006_56fa24a8f7_z.jpg)),
|
| 30 |
+
`coco_armchair.jpg` ([source](http://farm1.staticflickr.com/169/414738646_adafe60def_z.jpg)),
|
| 31 |
+
`coco_cow.jpg` ([source](http://farm9.staticflickr.com/8303/7825237468_9345ce9a8e_z.jpg)),
|
| 32 |
+
`coco_motorcycle.jpg` ([source](http://farm8.staticflickr.com/7407/9120226816_110da6cb9f_z.jpg)).
|
app.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GUI-Decisions: computer-use decisions read from one forward pass (slot reading on Gemma 4 31B).
|
| 2 |
+
|
| 3 |
+
The adapter is loaded on the base model (not the merged model) so the base model can still write the text
|
| 4 |
+
a type / answer step needs, and run the vanilla comparison, with the adapter switched off.
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
import gradio as gr
|
| 9 |
+
import numpy as np
|
| 10 |
+
import spaces
|
| 11 |
+
import torch
|
| 12 |
+
from PIL import Image, ImageDraw
|
| 13 |
+
|
| 14 |
+
from slotread.client import agent_user
|
| 15 |
+
from slotread.data.aguvis import ACTIONS, KEYS, SDIR
|
| 16 |
+
from slotread.local import LocalSlotModel
|
| 17 |
+
from slotread.numbers import unwarp
|
| 18 |
+
|
| 19 |
+
BASE = os.environ.get("BASE", "google/gemma-4-31B-it")
|
| 20 |
+
ADAPTER = os.environ.get("ADAPTER", "infinitylogesh/GUI-Decisions-31B-LoRA")
|
| 21 |
+
SLOT, VANILLA = (37, 99, 235), (234, 88, 12) # blue: slot reads; orange: the base model writing JSON
|
| 22 |
+
MAX_PX = 3840 * 2160
|
| 23 |
+
|
| 24 |
+
m = LocalSlotModel.from_pretrained(BASE, adapter=ADAPTER, processor=ADAPTER, device_map="cpu")
|
| 25 |
+
m.model.to("cuda")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def fit(im):
|
| 29 |
+
im = im.convert("RGB")
|
| 30 |
+
if im.width * im.height > MAX_PX:
|
| 31 |
+
s = (MAX_PX / (im.width * im.height)) ** 0.5
|
| 32 |
+
im = im.resize((int(im.width * s), int(im.height * s)), Image.LANCZOS)
|
| 33 |
+
return im
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def overlay(im, px, py, color):
|
| 37 |
+
"""The slot distributions as a heatmap: P(x) times P(y), drawn as the colour's opacity."""
|
| 38 |
+
heat = np.outer(py, px)
|
| 39 |
+
heat = (heat / heat.max()) ** 0.5
|
| 40 |
+
a = Image.fromarray((heat * 150).astype(np.uint8)).resize(im.size, Image.BILINEAR)
|
| 41 |
+
layer = Image.new("RGBA", im.size, color + (0,))
|
| 42 |
+
layer.putalpha(a)
|
| 43 |
+
return Image.alpha_composite(im.convert("RGBA"), layer)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def mark(d, x, y, color, r=14):
|
| 47 |
+
d.ellipse([x - r, y - r, x + r, y + r], outline=(255, 255, 255), width=6)
|
| 48 |
+
d.ellipse([x - r, y - r, x + r, y + r], outline=color, width=3)
|
| 49 |
+
d.line([x - 4, y, x + 4, y], fill=color, width=2)
|
| 50 |
+
d.line([x, y - 4, x, y + 4], fill=color, width=2)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def probs(v):
|
| 54 |
+
return torch.softmax(v.float(), -1).numpy()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def describe(a, d, text):
|
| 58 |
+
if a in ("click", "select"):
|
| 59 |
+
return f"{a}(x={d['x']:.3f}, y={d['y']:.3f})"
|
| 60 |
+
if a == "swipe":
|
| 61 |
+
return f"swipe(({d['x']:.3f}, {d['y']:.3f}) → ({d['x2']:.3f}, {d['y2']:.3f}))"
|
| 62 |
+
if a == "press":
|
| 63 |
+
return f"press(key={d['key']})"
|
| 64 |
+
if a == "scroll":
|
| 65 |
+
return f"scroll(direction={d['sdir']}, amount={d['samt']})"
|
| 66 |
+
if text is not None:
|
| 67 |
+
return f'{a}("{text}")'
|
| 68 |
+
return f"{a}()"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@spaces.GPU(size="xlarge", duration=40)
|
| 72 |
+
def decide(image, instruction, previous, compare):
|
| 73 |
+
if image is None or not instruction.strip():
|
| 74 |
+
raise gr.Error("Add a screenshot and an instruction.")
|
| 75 |
+
im = fit(image)
|
| 76 |
+
user_prev = previous.strip() or "None"
|
| 77 |
+
lp, ms = m.read("agent", im, agent_user(instruction, user_prev))
|
| 78 |
+
d = m.tasks["agent"].decode(lp, m.arm)
|
| 79 |
+
d.update(key=KEYS[int(lp["key"].argmax())], sdir=SDIR[int(lp["sdir"].argmax())], samt=int(lp["samt"].argmax()))
|
| 80 |
+
a = d["action"]
|
| 81 |
+
text = m.write_text(im, instruction, a, user_prev) if a in ("type", "answer", "open") else None
|
| 82 |
+
|
| 83 |
+
out = im.convert("RGBA")
|
| 84 |
+
if a in ("click", "select", "swipe"):
|
| 85 |
+
out = overlay(out, probs(lp["x"]), probs(lp["y"]), SLOT)
|
| 86 |
+
dr = ImageDraw.Draw(out)
|
| 87 |
+
W, H = im.size
|
| 88 |
+
if a in ("click", "select", "swipe"):
|
| 89 |
+
mark(dr, d["x"] * W, d["y"] * H, SLOT)
|
| 90 |
+
if a == "swipe":
|
| 91 |
+
dr.line([d["x"] * W, d["y"] * H, d["x2"] * W, d["y2"] * H], fill=SLOT, width=4)
|
| 92 |
+
mark(dr, d["x2"] * W, d["y2"] * H, SLOT, r=8)
|
| 93 |
+
|
| 94 |
+
md = (f"### Slots: `{describe(a, d, text)}`\n\n"
|
| 95 |
+
f"One forward pass: **{ms:.0f} ms** on this GPU (bf16, transformers"
|
| 96 |
+
+ (", plus the base model writing the text" if text is not None else "") + ").")
|
| 97 |
+
if compare:
|
| 98 |
+
va, vpt, vargs, vtext, raw, vms = m.vanilla_decide(im, instruction, user_prev)
|
| 99 |
+
if vpt is not None:
|
| 100 |
+
mark(dr, vpt["x"] * W, vpt["y"] * H, VANILLA)
|
| 101 |
+
vd = dict(vargs, **(vpt or {"x": float("nan"), "y": float("nan")}))
|
| 102 |
+
md += (f"\n\n### Base model writing JSON: `{describe(va, vd, vtext)}`\n\n"
|
| 103 |
+
f"Generating its answer took **{vms:.0f} ms** (Gemma 4's native `\"point\": [y, x]` format):\n\n"
|
| 104 |
+
f"```\n{raw.strip()[:400]}\n```\n\n"
|
| 105 |
+
"Blue: slot reads, with their x × y distribution as the heatmap. Orange: the base model's point.")
|
| 106 |
+
label = {k: float(p) for k, p in zip(ACTIONS, probs(lp["action"]))}
|
| 107 |
+
return out.convert("RGB"), md, label
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@spaces.GPU(size="xlarge", duration=20)
|
| 111 |
+
def box(image, description):
|
| 112 |
+
if image is None or not description.strip():
|
| 113 |
+
raise gr.Error("Add an image and describe the element or object.")
|
| 114 |
+
im = fit(image)
|
| 115 |
+
lp, ms = m.read("box", im, description)
|
| 116 |
+
b = m.tasks["box"].decode(lp, m.arm)
|
| 117 |
+
W, H = im.size
|
| 118 |
+
out = overlay(im, probs(lp["cx"]), probs(lp["cy"]), SLOT)
|
| 119 |
+
dr = ImageDraw.Draw(out)
|
| 120 |
+
xy = [b[0] * W, b[1] * H, b[2] * W, b[3] * H]
|
| 121 |
+
dr.rectangle(xy, outline=(255, 255, 255), width=6)
|
| 122 |
+
dr.rectangle(xy, outline=SLOT, width=3)
|
| 123 |
+
w = unwarp(float(np.argmax(probs(lp["w"])) + 0.5) / m.arm.bins, "log")
|
| 124 |
+
md = (f"### Box: `[{', '.join(f'{v:.3f}' for v in b)}]`\n\n"
|
| 125 |
+
f"Centre and log-scaled size, four slots read in one forward pass: **{ms:.0f} ms** on this GPU. "
|
| 126 |
+
f"The heatmap is the centre's x × y distribution (most likely width {w:.1%} of the image).")
|
| 127 |
+
return out.convert("RGB"), md
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 131 |
+
ex = lambda f: os.path.join(HERE, "examples", f)
|
| 132 |
+
rows = lambda f: [l.split("\t") for l in open(ex(f)).read().splitlines() if l.strip()] # tab-separated examples
|
| 133 |
+
INTRO = open(os.path.join(HERE, "intro.md")).read()
|
| 134 |
+
HOW = open(os.path.join(HERE, "how.md")).read()
|
| 135 |
+
|
| 136 |
+
with gr.Blocks(title="GUI-Decisions") as demo:
|
| 137 |
+
gr.Markdown(INTRO)
|
| 138 |
+
with gr.Tab("Computer use"):
|
| 139 |
+
with gr.Row():
|
| 140 |
+
with gr.Column():
|
| 141 |
+
img = gr.Image(type="pil", label="Screenshot")
|
| 142 |
+
ins = gr.Textbox(label="Instruction", placeholder="open the settings")
|
| 143 |
+
prev = gr.Textbox(label="Previous actions (optional)", placeholder="1. click(...)", lines=2)
|
| 144 |
+
cmp = gr.Checkbox(value=True, label="Also run the base model writing its answer as JSON (slower)")
|
| 145 |
+
go = gr.Button("Decide", variant="primary")
|
| 146 |
+
with gr.Column():
|
| 147 |
+
out_img = gr.Image(label="Decision")
|
| 148 |
+
out_md = gr.Markdown()
|
| 149 |
+
out_lab = gr.Label(label="action slot", num_top_classes=4)
|
| 150 |
+
go.click(decide, [img, ins, prev, cmp], [out_img, out_md, out_lab])
|
| 151 |
+
gr.Examples([[ex(f), i, p.replace("\\n", "\n"), True] for f, i, p in rows("agent.tsv")], [img, ins, prev, cmp])
|
| 152 |
+
with gr.Tab("Boxes"):
|
| 153 |
+
with gr.Row():
|
| 154 |
+
with gr.Column():
|
| 155 |
+
bimg = gr.Image(type="pil", label="Image")
|
| 156 |
+
bdesc = gr.Textbox(label="Element or object", placeholder="the red car on the left")
|
| 157 |
+
bgo = gr.Button("Locate", variant="primary")
|
| 158 |
+
with gr.Column():
|
| 159 |
+
bout = gr.Image(label="Box")
|
| 160 |
+
bmd = gr.Markdown()
|
| 161 |
+
bgo.click(box, [bimg, bdesc], [bout, bmd])
|
| 162 |
+
gr.Examples([[ex(f), t] for f, t in rows("box.tsv")], [bimg, bdesc])
|
| 163 |
+
with gr.Tab("How it works"):
|
| 164 |
+
gr.Markdown(HOW)
|
| 165 |
+
|
| 166 |
+
if __name__ == "__main__":
|
| 167 |
+
demo.queue().launch()
|
examples/agent.tsv
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
settings.png turn on dark mode
|
| 2 |
+
settings.png reset the appearance settings to their defaults
|
| 3 |
+
flights.png find a one-way flight from Chennai to Berlin 1. click(One way)\n2. click(From)
|
| 4 |
+
flights.png show only direct flights
|
| 5 |
+
todo.png mark buy groceries as done
|
| 6 |
+
todo.png add a new task
|
| 7 |
+
wiki_bee.png go to the Beekeeping section
|
| 8 |
+
wiki_bee.png switch Wikipedia to dark mode
|
| 9 |
+
blog.png open the post about serverless GPUs
|
examples/blog.png
ADDED
|
examples/box.tsv
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
coco_dog.jpg the red frisbee
|
| 2 |
+
coco_desk.jpg the computer mouse
|
| 3 |
+
coco_desk.jpg the monitor on the left
|
| 4 |
+
coco_armchair.jpg the painting on the wall
|
| 5 |
+
coco_cow.jpg the cow
|
| 6 |
+
coco_motorcycle.jpg the bag on the back of the motorcycle
|
| 7 |
+
flights.png the Search flights button
|
| 8 |
+
wiki_bee.png the photo of the bee
|
examples/coco_armchair.jpg
ADDED
|
Git LFS Details
|
examples/coco_cow.jpg
ADDED
|
Git LFS Details
|
examples/coco_desk.jpg
ADDED
|
Git LFS Details
|
examples/coco_dog.jpg
ADDED
|
Git LFS Details
|
examples/coco_motorcycle.jpg
ADDED
|
Git LFS Details
|
examples/flights.png
ADDED
|
examples/settings.png
ADDED
|
examples/todo.png
ADDED
|
examples/wiki_bee.png
ADDED
|
Git LFS Details
|
how.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## How it works
|
| 2 |
+
|
| 3 |
+
The assistant turn is a fixed template; each slot ends in a placeholder:
|
| 4 |
+
|
| 5 |
+
```
|
| 6 |
+
action: ___ x: ___ y: ___ x2: ___ y2: ___ key: ___ sdir: ___ samt: ___
|
| 7 |
+
```
|
| 8 |
+
|
| 9 |
+
A slot's answer is the next-token distribution at the token before its placeholder, restricted to that
|
| 10 |
+
slot's labels: the 11 action words for `action`, and 256 bins for each coordinate, every bin a single
|
| 11 |
+
byte-like token. One forward pass over the whole template reads every slot at once. A LoRA trained with
|
| 12 |
+
cross-entropy against soft (Gaussian) targets puts the probability on the right bins, so the distribution
|
| 13 |
+
is meaningful: the heatmap is P(x) × P(y).
|
| 14 |
+
|
| 15 |
+
What to type, or a final answer, is free text: for those steps the base model (adapter switched off)
|
| 16 |
+
writes it.
|
| 17 |
+
|
| 18 |
+
Boxes work the same way: centre x, centre y, and a log-scaled width and height, four slots.
|
| 19 |
+
|
| 20 |
+
### Measured (blog post, Gemma 4 31B NVFP4 on vLLM, one RTX PRO 6000)
|
| 21 |
+
|
| 22 |
+
| how the step is decided | time per step |
|
| 23 |
+
| --- | --- |
|
| 24 |
+
| **slot read** | **146 ms** |
|
| 25 |
+
| vanilla, native `[y, x]` JSON (~20 tokens) | 472–546 ms |
|
| 26 |
+
| vanilla + thinking (130–230 tokens) | 2.6–3.1 s |
|
| 27 |
+
|
| 28 |
+
| this model (E3) | |
|
| 29 |
+
| --- | --- |
|
| 30 |
+
| ScreenSpot clicks (1,272) | 0.866 |
|
| 31 |
+
| computer-use steps, held-out AGUVIS (1,600) | 0.659 |
|
| 32 |
+
| RefCOCO val photo boxes, mean IoU (3,811) | 0.770 |
|
| 33 |
+
| COCO val2017 boxes, mean IoU (1,000) | 0.742 |
|
| 34 |
+
|
| 35 |
+
### Limits
|
| 36 |
+
|
| 37 |
+
Trained on web, desktop and mobile screenshots (AGUVIS, Wave-UI) and RefCOCO photos. It decides one step
|
| 38 |
+
at a time from the screenshot, the instruction and the previous actions you give it; it does not act on
|
| 39 |
+
anything. Expect mistakes on small targets and on interfaces unlike its training data.
|
intro.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GUI-Decisions: computer-use steps read in one forward pass
|
| 2 |
+
|
| 3 |
+
Give it a screenshot and an instruction. Instead of *writing* `{"action": "click", "point": [412, 288]}`
|
| 4 |
+
token by token, the model fills a fixed template of slots, and every slot's answer is read from a single
|
| 5 |
+
forward pass: the action, and the point as a 256-bin distribution over x and over y. Nothing is decoded.
|
| 6 |
+
The heatmap is that distribution.
|
| 7 |
+
|
| 8 |
+
Gemma 4 31B with a LoRA ([GUI-Decisions-31B-LoRA](https://huggingface.co/infinitylogesh/GUI-Decisions-31B-LoRA),
|
| 9 |
+
merged: [GUI-Decisions-31B](https://huggingface.co/infinitylogesh/GUI-Decisions-31B)).
|
| 10 |
+
Blog post: [Stop Decoding Coordinates](https://logeshumapathi.com/blog/2026/10/06/slot-reading.html) ·
|
| 11 |
+
code: [slot-reading](https://github.com/infinitylogesh/slot-reading).
|
| 12 |
+
|
| 13 |
+
*Timings here are this Space's shared GPU running bf16 in plain transformers. Served on vLLM (NVFP4, one
|
| 14 |
+
RTX PRO 6000) a step takes ~146 ms against ~470–550 ms for the same model writing its native JSON.*
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch
|
| 2 |
+
transformers>=5.0
|
| 3 |
+
peft
|
| 4 |
+
accelerate
|
| 5 |
+
safetensors
|
| 6 |
+
pillow
|
| 7 |
+
numpy
|
slotread/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Slot reading: read an action and its coordinates from a fixed slot template in one prefill instead of
|
| 2 |
+
decoding them. See README.md."""
|
slotread/agent.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A computer-use agent in a real browser (Playwright), deciding each step one of two ways:
|
| 2 |
+
|
| 3 |
+
slots a served slot adapter (e.g. j-E3): action + point from slot reads, no decoding. Text steps
|
| 4 |
+
(what to type, the final answer) are written by the base model (TEXT_MODEL, default gemma4).
|
| 5 |
+
vanilla the base model writes the whole step as JSON:
|
| 6 |
+
gemma4-native "point": [y, x] on a 0-1000 grid, y first (Gemma 4's own convention)
|
| 7 |
+
gemma4-native-think the same with thinking on
|
| 8 |
+
gemma4-json pixel x, y (undersells the model: kept for comparison)
|
| 9 |
+
|
| 10 |
+
python -m slotread.agent --url https://en.wikipedia.org --task "..." --model j-E3=runs/j_E3 --steps 12
|
| 11 |
+
python -m slotread.agent --url https://en.wikipedia.org --task "..." --model gemma4-native
|
| 12 |
+
|
| 13 |
+
run() yields one event per step (screenshot, decision, timings), which scripts/race.py records.
|
| 14 |
+
Safety: navigation is confined to the starting site, and anything that looks like a credential field
|
| 15 |
+
is refused.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import argparse, base64, json, os, re, time
|
| 19 |
+
from urllib.parse import urlparse
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
from .client import SlotModel, post
|
| 23 |
+
from .data.aguvis import ACTIONS, KEYS, SDIR
|
| 24 |
+
|
| 25 |
+
TEXT_MODEL = os.environ.get("TEXT_MODEL", "gemma4") # the base model, served beside the adapters
|
| 26 |
+
TEXT_ASK = {"type": "What exact text should be typed for this step? Answer with the text only, no quotes.",
|
| 27 |
+
"answer": "Give the final answer for this task. Answer with the text only.",
|
| 28 |
+
"open": "Which site should be opened? Answer with a domain only."}
|
| 29 |
+
JSON_SYSTEM = (
|
| 30 |
+
"You are a GUI agent. Given a screenshot, the task and the previous actions, answer the next step "
|
| 31 |
+
"as one JSON object and nothing else: {\"action\": one of [click, type, scroll, swipe, press, open, "
|
| 32 |
+
"back, home, wait, answer, select], \"x\": 0-1 from the left, \"y\": 0-1 from the top, \"x2\", \"y2\": "
|
| 33 |
+
"a swipe's end, \"key\": the key to press, \"sdir\": scroll direction, \"samt\": scroll amount 0-9}. "
|
| 34 |
+
"Include only the fields the action needs.")
|
| 35 |
+
TEXT_RULE = ' For type, include "text": the text to type; for answer, include "text": the final answer.'
|
| 36 |
+
SECRET = re.compile(r"pass|card|cvv|ssn|secret|token|otp|pin\b", re.I)
|
| 37 |
+
KEYMAP = {"enter": "Enter", "tab": "Tab", "escape": "Escape", "space": " ", "delete": "Delete",
|
| 38 |
+
"up": "ArrowUp", "down": "ArrowDown", "left": "ArrowLeft", "right": "ArrowRight"}
|
| 39 |
+
VANILLA = ("gemma4-native", "gemma4-native-think", "gemma4-json")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def user_text(task, done):
|
| 43 |
+
history = "\n".join(f"{i + 1}. {d}" for i, d in enumerate(done)) or "None"
|
| 44 |
+
return ("Please generate the next move according to the UI screenshot, instruction and previous actions.\n\n"
|
| 45 |
+
f"Instruction: {task}\n\nPrevious actions:\n{history}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def write_text(img, user, action):
|
| 49 |
+
"""The base model writes a text argument (what to type, the answer, the site to open)."""
|
| 50 |
+
msgs = [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": img}},
|
| 51 |
+
{"type": "text", "text": user + "\n\n" + TEXT_ASK[action]}]}]
|
| 52 |
+
d, _ = post(dict(model=TEXT_MODEL, messages=msgs, max_tokens=24, temperature=0,
|
| 53 |
+
chat_template_kwargs={"enable_thinking": False}))
|
| 54 |
+
t = ((d.get("choices") or [{}])[0].get("message", {}) or {}).get("content") or ""
|
| 55 |
+
return t.strip().strip('"').split("\n")[0][:120]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def decide(model, img, user, size=(1280, 800), lazy=True):
|
| 59 |
+
"""One step: (action, point {x, y} or None, args, text, raw output). `model` is a SlotModel or one
|
| 60 |
+
of VANILLA. Slot models read only the slots the action needs (lazy)."""
|
| 61 |
+
if isinstance(model, SlotModel):
|
| 62 |
+
lp = model.read("agent", img, user, lazy=lazy)
|
| 63 |
+
d = model.tasks["agent"].decode(lp, model.arm)
|
| 64 |
+
top = lambda s, names, default: names[int(lp[s].argmax())] if s in lp else default
|
| 65 |
+
args = {"x2": d.get("x2", d["x"]), "y2": d.get("y2", d["y"]), "key": top("key", KEYS, "enter"),
|
| 66 |
+
"sdir": top("sdir", SDIR, "down"), "samt": int(lp["samt"].argmax()) if "samt" in lp else 5}
|
| 67 |
+
text = write_text(img, user, d["action"]) if d["action"] in TEXT_ASK else None
|
| 68 |
+
return d["action"], {"x": d["x"], "y": d["y"]}, args, text, None
|
| 69 |
+
native, think = model.startswith("gemma4-native"), model.endswith("-think")
|
| 70 |
+
msgs = [{"role": "system", "content": [{"type": "text", "text": json_system(native, size)}]},
|
| 71 |
+
{"role": "user", "content": [{"type": "image_url", "image_url": {"url": img}}, {"type": "text", "text": user}]}]
|
| 72 |
+
d, _ = post(dict(model=TEXT_MODEL, messages=msgs, max_tokens=2048 if think else 96, temperature=0,
|
| 73 |
+
chat_template_kwargs={"enable_thinking": think}))
|
| 74 |
+
txt = ((d.get("choices") or [{}])[0].get("message", {}) or {}).get("content") or ""
|
| 75 |
+
return parse_step(txt, native, size) + (txt,)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def json_system(native, size=(1280, 800)):
|
| 79 |
+
"""The vanilla system prompt: Gemma 4's native [y, x] on 0-1000, or pixels."""
|
| 80 |
+
if native:
|
| 81 |
+
system = JSON_SYSTEM.replace('"x": 0-1 from the left, "y": 0-1 from the top, "x2", "y2": a swipe\'s end',
|
| 82 |
+
'"point": [y, x] normalized to 0-1000 (y first), "point2": a swipe\'s end the same way')
|
| 83 |
+
else: # the base model answers in pixels when asked for fractions, so ask for pixels outright
|
| 84 |
+
system = JSON_SYSTEM.replace('"x": 0-1 from the left, "y": 0-1 from the top',
|
| 85 |
+
f'"x", "y": pixel coordinates in the {size[0]}x{size[1]} screenshot')
|
| 86 |
+
return system + TEXT_RULE
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def parse_step(txt, native, size=(1280, 800)):
|
| 90 |
+
"""A vanilla answer -> (action, point {x, y} in [0, 1] or None, args, text)."""
|
| 91 |
+
o = {}
|
| 92 |
+
for m in reversed(list(re.finditer(r"\{[^{}]*\}", txt))): # the last JSON object (after any thinking)
|
| 93 |
+
try:
|
| 94 |
+
o = json.loads(m.group(0))
|
| 95 |
+
break
|
| 96 |
+
except Exception:
|
| 97 |
+
continue
|
| 98 |
+
num = lambda v: float(v) if isinstance(v, (int, float)) else None
|
| 99 |
+
if native:
|
| 100 |
+
yx = lambda v: ({"x": v[1] / 1000, "y": v[0] / 1000} if isinstance(v, list) and len(v) == 2
|
| 101 |
+
and all(num(c) is not None for c in v) else None)
|
| 102 |
+
pt, end = yx(o.get("point")), yx(o.get("point2")) or {"x": 0.5, "y": 0.5}
|
| 103 |
+
else:
|
| 104 |
+
pt = ({"x": num(o["x"]) / size[0], "y": num(o["y"]) / size[1]}
|
| 105 |
+
if num(o.get("x")) is not None and num(o.get("y")) is not None else None)
|
| 106 |
+
end = {"x": (num(o.get("x2")) or size[0] / 2) / size[0], "y": (num(o.get("y2")) or size[1] / 2) / size[1]}
|
| 107 |
+
action = o.get("action") if o.get("action") in ACTIONS else "wait"
|
| 108 |
+
args = {"x2": end["x"], "y2": end["y"], "key": o.get("key", "enter"), "sdir": o.get("sdir", "down"),
|
| 109 |
+
"samt": int(o["samt"]) if num(o.get("samt")) is not None else 5}
|
| 110 |
+
return action, pt, args, o.get("text")
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def shot_url(page, tries=3):
|
| 114 |
+
"""Chromium occasionally fails a capture right after a click; retry rather than end the run."""
|
| 115 |
+
for k in range(tries):
|
| 116 |
+
try:
|
| 117 |
+
b = page.screenshot(type="jpeg", quality=80, timeout=10000)
|
| 118 |
+
return "data:image/jpeg;base64," + base64.b64encode(b).decode()
|
| 119 |
+
except Exception:
|
| 120 |
+
if k == tries - 1:
|
| 121 |
+
raise
|
| 122 |
+
page.wait_for_timeout(400)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def field_at(page, x, y):
|
| 126 |
+
"""What sits under the point, so typing into a password box can be refused."""
|
| 127 |
+
try:
|
| 128 |
+
return page.evaluate("""([x, y]) => { const e = document.elementFromPoint(x, y); if (!e) return {};
|
| 129 |
+
return { tag: e.tagName, type: e.type || "", name: e.name || "", ph: e.placeholder || "",
|
| 130 |
+
aria: e.getAttribute("aria-label") || "" }; }""", [x, y]) or {}
|
| 131 |
+
except Exception:
|
| 132 |
+
return {}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def act(page, action, pt, args, text, size):
|
| 136 |
+
"""Execute one action. Returns the history line, or raises."""
|
| 137 |
+
W, H = size
|
| 138 |
+
x, y = (pt["x"] * W, pt["y"] * H) if pt else (0, 0)
|
| 139 |
+
if action in ("click", "select"):
|
| 140 |
+
f = field_at(page, x, y)
|
| 141 |
+
if f.get("type") == "password" or SECRET.search(" ".join(str(v) for v in f.values())):
|
| 142 |
+
raise PermissionError("refusing to interact with a credential field")
|
| 143 |
+
page.mouse.click(x, y)
|
| 144 |
+
return f"click(x={pt['x']:.3f}, y={pt['y']:.3f})"
|
| 145 |
+
if action == "type":
|
| 146 |
+
f = field_at(page, x, y) if pt else {}
|
| 147 |
+
if f.get("type") == "password" or SECRET.search(" ".join(str(v) for v in f.values()) + " " + (text or "")):
|
| 148 |
+
raise PermissionError("refusing to type into a credential field")
|
| 149 |
+
# keystrokes go to whatever has focus, and in the training data a click always came first:
|
| 150 |
+
# focus the field under the point unless it already has focus
|
| 151 |
+
if pt and f.get("tag") in ("INPUT", "TEXTAREA") and not page.evaluate(
|
| 152 |
+
"([x, y]) => document.activeElement === document.elementFromPoint(x, y)", [x, y]):
|
| 153 |
+
page.mouse.click(x, y)
|
| 154 |
+
page.wait_for_timeout(120)
|
| 155 |
+
page.keyboard.type(text or "", delay=12)
|
| 156 |
+
return f'type("{text}")'
|
| 157 |
+
if action == "scroll":
|
| 158 |
+
amt = (args.get("samt", 5) + 1) * 220
|
| 159 |
+
dx, dy = {"up": (0, -amt), "down": (0, amt), "left": (-amt, 0), "right": (amt, 0)}.get(args.get("sdir", "down"), (0, amt))
|
| 160 |
+
page.mouse.wheel(dx, dy)
|
| 161 |
+
return f"scroll(direction='{args.get('sdir', 'down')}', amount={args.get('samt', 5)})"
|
| 162 |
+
if action == "swipe":
|
| 163 |
+
page.mouse.move(x, y); page.mouse.down()
|
| 164 |
+
page.mouse.move(args["x2"] * W, args["y2"] * H, steps=12); page.mouse.up()
|
| 165 |
+
return f"swipe(from=({pt['x']:.2f},{pt['y']:.2f}), to=({args['x2']:.2f},{args['y2']:.2f}))"
|
| 166 |
+
if action == "press":
|
| 167 |
+
page.keyboard.press(KEYMAP.get(args.get("key", "enter"), "Enter"))
|
| 168 |
+
return f"press(keys='{args.get('key', 'enter')}')"
|
| 169 |
+
if action == "back":
|
| 170 |
+
page.go_back(wait_until="domcontentloaded")
|
| 171 |
+
return "navigate_back()"
|
| 172 |
+
if action in ("home", "open"):
|
| 173 |
+
page.goto(f"https://{urlparse(page.url).netloc}/", wait_until="domcontentloaded")
|
| 174 |
+
return "navigate_home()"
|
| 175 |
+
if action == "wait":
|
| 176 |
+
page.wait_for_timeout(800)
|
| 177 |
+
return "wait()"
|
| 178 |
+
raise ValueError(action)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def run(task, url, steps, model, size=(1280, 800), pace=0.4, headless=True):
|
| 182 |
+
"""Yields step events with timestamps relative to the run's start (t_shot, t_decided, t_acted)."""
|
| 183 |
+
from playwright.sync_api import sync_playwright
|
| 184 |
+
host = urlparse(url).netloc
|
| 185 |
+
done, last, repeats = [], None, 0
|
| 186 |
+
with sync_playwright() as p:
|
| 187 |
+
browser = p.chromium.launch(headless=headless)
|
| 188 |
+
page = browser.new_page(viewport={"width": size[0], "height": size[1]})
|
| 189 |
+
page.set_default_timeout(15000)
|
| 190 |
+
page.goto(url, wait_until="domcontentloaded")
|
| 191 |
+
t0 = time.perf_counter()
|
| 192 |
+
rel = lambda: round(time.perf_counter() - t0, 3)
|
| 193 |
+
yield {"type": "start", "url": page.url, "task": task, "size": list(size)}
|
| 194 |
+
for i in range(steps):
|
| 195 |
+
page.wait_for_timeout(int(pace * 1000))
|
| 196 |
+
t_shot = rel()
|
| 197 |
+
img = shot_url(page)
|
| 198 |
+
ut = user_text(task, done)
|
| 199 |
+
t = time.perf_counter()
|
| 200 |
+
action, pt, args, text, raw = decide(model, img, ut, size)
|
| 201 |
+
ev = {"type": "step", "i": i + 1, "image": img, "action": action, "point": pt, "args": args, "text": text,
|
| 202 |
+
"ms": round((time.perf_counter() - t) * 1000), "url": page.url, "title": page.title()[:80],
|
| 203 |
+
"t_shot": t_shot, "t_decided": rel(), "raw": raw}
|
| 204 |
+
if action == "answer":
|
| 205 |
+
ev.update(done=True, t_acted=ev["t_decided"])
|
| 206 |
+
yield ev
|
| 207 |
+
break
|
| 208 |
+
# a model stuck on one action gets one nudge: the page not changing is the tell
|
| 209 |
+
sig = (action, page.url, ev["title"])
|
| 210 |
+
repeats = repeats + 1 if sig == last else 0
|
| 211 |
+
last = sig
|
| 212 |
+
if repeats >= 2:
|
| 213 |
+
ev["note"] = "the same action three times with no change: " + ("enter" if action == "type" else "scrolling")
|
| 214 |
+
action, args = ("press", {**args, "key": "enter"}) if action == "type" else ("scroll", {**args, "sdir": "down", "samt": 4})
|
| 215 |
+
try:
|
| 216 |
+
desc = act(page, action, pt, args, text, size)
|
| 217 |
+
except Exception as e:
|
| 218 |
+
ev["error"] = repr(e)[:160]
|
| 219 |
+
yield ev
|
| 220 |
+
break
|
| 221 |
+
if urlparse(page.url).netloc != host: # stay on the site we started on
|
| 222 |
+
ev["note"] = f"navigation to {urlparse(page.url).netloc} blocked; going back"
|
| 223 |
+
page.go_back(wait_until="domcontentloaded")
|
| 224 |
+
done.append(desc)
|
| 225 |
+
ev.update(executed=desc, t_acted=rel())
|
| 226 |
+
yield ev
|
| 227 |
+
else:
|
| 228 |
+
yield {"type": "end", "reason": "step limit", "history": done, "t": rel(), "image": shot_url(page)}
|
| 229 |
+
browser.close()
|
| 230 |
+
return
|
| 231 |
+
yield {"type": "end", "reason": "finished", "history": done, "t": rel(), "image": shot_url(page)}
|
| 232 |
+
browser.close()
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def model_of(spec):
|
| 236 |
+
""""name=run_dir" -> SlotModel; a VANILLA name -> itself."""
|
| 237 |
+
return SlotModel(*spec.split("=", 1)) if "=" in spec else spec
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def main():
|
| 241 |
+
ap = argparse.ArgumentParser()
|
| 242 |
+
ap.add_argument("--url", required=True)
|
| 243 |
+
ap.add_argument("--task", required=True)
|
| 244 |
+
ap.add_argument("--steps", type=int, default=12)
|
| 245 |
+
ap.add_argument("--model", required=True, help="name=run_dir for a slot adapter, or " + " / ".join(VANILLA))
|
| 246 |
+
a = ap.parse_args()
|
| 247 |
+
for ev in run(a.task, a.url, a.steps, model_of(a.model)):
|
| 248 |
+
if ev["type"] == "step":
|
| 249 |
+
pt = ev.get("point")
|
| 250 |
+
where = f"at {pt['x']:.3f},{pt['y']:.3f}" if pt else "(no point)"
|
| 251 |
+
print(f"[{ev['i']}] {ev['action']} {(ev.get('text') or '')[:60]} {where} {ev['ms']} ms -> "
|
| 252 |
+
f"{ev.get('executed') or ev.get('error') or ''} | {ev['title']}", flush=True)
|
| 253 |
+
elif ev["type"] == "end":
|
| 254 |
+
print("end:", ev["reason"], flush=True)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
if __name__ == "__main__":
|
| 258 |
+
main()
|
slotread/client.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reading slots from a served adapter (vLLM; SGLang optional).
|
| 2 |
+
|
| 3 |
+
A served adapter is described by its run's config.json (written by slotread.train): each task's system
|
| 4 |
+
prompt, slot order and label ids. SlotModel turns that into reads.
|
| 5 |
+
|
| 6 |
+
read one request per slot, in parallel: each request's assistant text ends right before that
|
| 7 |
+
slot's placeholder ("action: ___ x:"), max_tokens=1, and vLLM returns the log-probabilities
|
| 8 |
+
of exactly that slot's label ids (logprob_token_ids). The default.
|
| 9 |
+
read(..., lazy=True) computer use only: action, x and y first (all a click needs); key, scroll or
|
| 10 |
+
swipe-end slots only when the action calls for them
|
| 11 |
+
read_one every slot from one request's prompt log-probabilities (top-k per position; slower)
|
| 12 |
+
read_sgl every slot from one SGLang /generate request (logprob_start_len + token_ids_logprob)
|
| 13 |
+
|
| 14 |
+
Serving (serving/serve_vllm.sh): vLLM caps logprob_token_ids at 128 per request; serving/patch_vllm.sh
|
| 15 |
+
raises it to 256 so a 256-bin slot is one request (MAX_LABEL_IDS=256). Run vLLM with
|
| 16 |
+
--api-server-count 8: a single API-server process serializes the parallel slot requests.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import base64, io, json, math, os, time, urllib.error, urllib.request
|
| 20 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
|
| 24 |
+
from .numbers import slots_for
|
| 25 |
+
from .labels import Arm, PLACEHOLDER
|
| 26 |
+
from .tasks import AgentTask, BoxTask, SegTask
|
| 27 |
+
|
| 28 |
+
VLLM_URL = os.environ.get("VLLM_URL", f"http://127.0.0.1:{os.environ.get('PORT', 8001)}")
|
| 29 |
+
MAX_IDS = int(os.environ.get("MAX_LABEL_IDS", 256)) # 128 on stock vLLM (see patch_vllm.sh)
|
| 30 |
+
PL_TOPK = int(os.environ.get("PL_TOPK", 50)) # read_one: prompt log-probabilities kept per position
|
| 31 |
+
EXTRA_BODY = json.loads(os.environ.get("EXTRA_BODY", "{}")) # e.g. {"mm_processor_kwargs": {"max_soft_tokens": 1120}}
|
| 32 |
+
SGL_URL = os.environ.get("SGL_URL", "http://127.0.0.1:30000")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def post(body, url=None, path="/v1/chat/completions", tries=2):
|
| 36 |
+
"""POST JSON; returns (response dict, ms). HTTP errors come back as {"error": ...}."""
|
| 37 |
+
t = time.perf_counter()
|
| 38 |
+
d = {}
|
| 39 |
+
for _ in range(tries):
|
| 40 |
+
r = urllib.request.Request((url or VLLM_URL) + path, json.dumps(body).encode(),
|
| 41 |
+
{"content-type": "application/json"})
|
| 42 |
+
try:
|
| 43 |
+
d = json.load(urllib.request.urlopen(r, timeout=300))
|
| 44 |
+
break
|
| 45 |
+
except urllib.error.HTTPError as e:
|
| 46 |
+
d = {"error": e.read().decode()[:300]}
|
| 47 |
+
if e.code != 500:
|
| 48 |
+
break
|
| 49 |
+
return d, (time.perf_counter() - t) * 1000
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def generate(model, url, text, max_tokens=64, think=False, system=None, **extra):
|
| 53 |
+
"""A plain chat completion with one image (the vanilla baselines). Returns (text, ms, usage)."""
|
| 54 |
+
msgs = ([{"role": "system", "content": [{"type": "text", "text": system}]}] if system else []) + \
|
| 55 |
+
[{"role": "user", "content": [{"type": "image_url", "image_url": {"url": url}},
|
| 56 |
+
{"type": "text", "text": text}]}]
|
| 57 |
+
d, ms = post(dict(model=model, messages=msgs, max_tokens=max_tokens, temperature=0,
|
| 58 |
+
chat_template_kwargs={"enable_thinking": think}, **extra))
|
| 59 |
+
if "error" in d:
|
| 60 |
+
return "", ms, {}
|
| 61 |
+
return d["choices"][0]["message"].get("content") or "", ms, d.get("usage", {})
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class SlotModel:
|
| 65 |
+
"""A served slot adapter: `name` as vLLM serves it, `path` the run folder holding config.json."""
|
| 66 |
+
|
| 67 |
+
def __init__(self, name, path):
|
| 68 |
+
self.name, self.path = name, path
|
| 69 |
+
c = json.load(open(os.path.join(path, "config.json")))
|
| 70 |
+
self.cfg = c
|
| 71 |
+
self.arm = Arm(c["arm"], c["coarse"], c["bins"], c["tokens"], c["fine"])
|
| 72 |
+
self.tasks = {"agent": AgentTask(), "box": BoxTask(), "seg": SegTask(c["rays"])}
|
| 73 |
+
|
| 74 |
+
def task_cfg(self, task):
|
| 75 |
+
return self.cfg["tasks"][task]
|
| 76 |
+
|
| 77 |
+
def body(self, task, url, user, assistant, **extra):
|
| 78 |
+
msgs = [{"role": "system", "content": [{"type": "text", "text": self.task_cfg(task)["system"]}]},
|
| 79 |
+
{"role": "user", "content": [{"type": "image_url", "image_url": {"url": url}},
|
| 80 |
+
{"type": "text", "text": user}]},
|
| 81 |
+
{"role": "assistant", "content": [{"type": "text", "text": assistant}]}]
|
| 82 |
+
return dict(model=self.name, messages=msgs, add_generation_prompt=False, continue_final_message=True,
|
| 83 |
+
chat_template_kwargs={"enable_thinking": False}, max_tokens=1, temperature=0,
|
| 84 |
+
logprobs=True, return_tokens_as_token_ids=True, **EXTRA_BODY, **extra)
|
| 85 |
+
|
| 86 |
+
def read(self, task, url, user, slots=None, lazy=False):
|
| 87 |
+
"""{slot: log-probabilities over its labels} from parallel per-slot requests."""
|
| 88 |
+
if lazy and task == "agent":
|
| 89 |
+
from .data.aguvis import ACTIONS
|
| 90 |
+
lp = self.read(task, url, user, ["action", *slots_for("x", self.arm), *slots_for("y", self.arm)])
|
| 91 |
+
more = {"press": ["key"], "scroll": ["sdir", "samt"],
|
| 92 |
+
"swipe": slots_for("x2", self.arm) + slots_for("y2", self.arm)}.get(ACTIONS[int(lp["action"].argmax())])
|
| 93 |
+
if more:
|
| 94 |
+
lp.update(self.read(task, url, user, more))
|
| 95 |
+
return lp
|
| 96 |
+
c = self.task_cfg(task)
|
| 97 |
+
order, lab = c["slots"], c["labels"]
|
| 98 |
+
slots = slots or order
|
| 99 |
+
jobs = [(s, lab[s][i:i + MAX_IDS]) for s in slots for i in range(0, len(lab[s]), MAX_IDS)]
|
| 100 |
+
|
| 101 |
+
def one(job):
|
| 102 |
+
s, ids = job
|
| 103 |
+
k = order.index(s)
|
| 104 |
+
prefix = " ".join(f"{x}: ___" for x in order[:k]) + (" " if k else "") + f"{s}:"
|
| 105 |
+
d, _ = post(self.body(task, url, user, prefix, top_logprobs=1, logprob_token_ids=ids))
|
| 106 |
+
if "error" in d:
|
| 107 |
+
raise RuntimeError(f"{self.name}/{task}/{s}: {d['error']}")
|
| 108 |
+
top = d["choices"][0]["logprobs"]["content"][0]["top_logprobs"]
|
| 109 |
+
return s, {int(t["token"].split(":")[1]): t["logprob"] for t in top}
|
| 110 |
+
|
| 111 |
+
with ThreadPoolExecutor(len(jobs)) as p:
|
| 112 |
+
res = list(p.map(one, jobs))
|
| 113 |
+
got = {}
|
| 114 |
+
for s, v in res:
|
| 115 |
+
got.setdefault(s, {}).update(v)
|
| 116 |
+
return {s: torch.tensor([got[s].get(i, -1e4) for i in lab[s]]) for s in slots}
|
| 117 |
+
|
| 118 |
+
def read_one(self, task, url, user):
|
| 119 |
+
"""Every slot from one request's prompt log-probabilities: each slot's answer is its own
|
| 120 |
+
placeholder's row. Labels outside the top PL_TOPK get 5 nats below the lowest returned."""
|
| 121 |
+
c = self.task_cfg(task)
|
| 122 |
+
order, lab = c["slots"], c["labels"]
|
| 123 |
+
d, _ = post(self.body(task, url, user, " ".join(f"{s}: ___" for s in order), top_logprobs=1,
|
| 124 |
+
prompt_logprobs=PL_TOPK))
|
| 125 |
+
if "error" in d:
|
| 126 |
+
raise RuntimeError(f"{self.name}/{task}: {d['error']}")
|
| 127 |
+
pl = d.get("prompt_logprobs") or []
|
| 128 |
+
marks = [i for i, p in enumerate(pl) if p and any(v.get("decoded_token") == PLACEHOLDER for v in p.values())]
|
| 129 |
+
marks = marks[-len(order):]
|
| 130 |
+
if len(marks) != len(order):
|
| 131 |
+
raise RuntimeError(f"{self.name}/{task}: {len(marks)} placeholders for {len(order)} slots")
|
| 132 |
+
out = {}
|
| 133 |
+
for s, m in zip(order, marks):
|
| 134 |
+
vals = {int(k): v["logprob"] for k, v in (pl[m] or {}).items()}
|
| 135 |
+
floor = (min(vals.values()) - 5) if vals else -20.0
|
| 136 |
+
out[s] = torch.tensor([vals.get(i, floor) for i in lab[s]])
|
| 137 |
+
return out
|
| 138 |
+
|
| 139 |
+
def read_sgl(self, task, url, user, slots=None, budget=280, processor="nvidia/Gemma-4-31B-IT-NVFP4"):
|
| 140 |
+
"""Every requested slot from ONE SGLang /generate request (serving/sglang): the prompt carries
|
| 141 |
+
the whole layout, and SGLang returns, for the last positions only (logprob_start_len), the
|
| 142 |
+
log-probabilities of our label ids only (token_ids_logprob). Gemma 4 only (its image token
|
| 143 |
+
count is computed client-side to find where the slots start)."""
|
| 144 |
+
from PIL import Image
|
| 145 |
+
proc = _processor(processor)
|
| 146 |
+
c = self.task_cfg(task)
|
| 147 |
+
order, lab = c["slots"], c["labels"]
|
| 148 |
+
slots = slots or order
|
| 149 |
+
layout = " ".join(f"{x}: ___" for x in order)
|
| 150 |
+
msgs = [{"role": "system", "content": [{"type": "text", "text": c["system"]}]},
|
| 151 |
+
{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": user}]},
|
| 152 |
+
{"role": "assistant", "content": [{"type": "text", "text": layout}]}]
|
| 153 |
+
text = proc.apply_chat_template(msgs, tokenize=False, add_generation_prompt=False,
|
| 154 |
+
continue_final_message=True, enable_thinking=False)
|
| 155 |
+
W, H = Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1]))).size
|
| 156 |
+
# Gemma 4 fits the image to the budget in 48 x 48 pixel cells (16 px patches, 3 x 3 pooling),
|
| 157 |
+
# one token per cell, plus 2 boundary tokens
|
| 158 |
+
f = math.sqrt(budget * 9 * 256 / (W * H))
|
| 159 |
+
soft = int(f * H // 48) * int(f * W // 48)
|
| 160 |
+
n_text = len(proc.tokenizer(text, add_special_tokens=False)["input_ids"])
|
| 161 |
+
tail = len(proc.tokenizer(layout, add_special_tokens=False)["input_ids"])
|
| 162 |
+
start = max(0, n_text - 1 + soft + 2 - tail - 4) # a few positions early is harmless
|
| 163 |
+
ph = proc.tokenizer("x: ___", add_special_tokens=False)["input_ids"][-1]
|
| 164 |
+
body = dict(text=text, image_data=[url], lora_path=self.name, return_logprob=True, logprob_start_len=start,
|
| 165 |
+
top_logprobs_num=0, token_ids_logprob=sorted({i for x in slots for i in lab[x]}),
|
| 166 |
+
sampling_params=dict(max_new_tokens=1, temperature=0))
|
| 167 |
+
d, _ = post(body, url=SGL_URL, path="/generate")
|
| 168 |
+
if "error" in d:
|
| 169 |
+
raise RuntimeError(f"{self.name}/{task}: {d['error']}")
|
| 170 |
+
mi = d["meta_info"]
|
| 171 |
+
marks = [i for i, t in enumerate(mi["input_token_logprobs"]) if t[1] == ph][-len(order):]
|
| 172 |
+
if len(marks) != len(order):
|
| 173 |
+
raise RuntimeError(f"{self.name}/{task}: {len(marks)} placeholders for {len(order)} slots")
|
| 174 |
+
out = {}
|
| 175 |
+
for x in slots:
|
| 176 |
+
row = {t[1]: t[0] for t in mi["input_token_ids_logprobs"][marks[order.index(x)]]}
|
| 177 |
+
out[x] = torch.tensor([row.get(i, -1e4) for i in lab[x]])
|
| 178 |
+
return out
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
_PROC = {}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _processor(name):
|
| 185 |
+
if name not in _PROC:
|
| 186 |
+
from transformers import AutoProcessor
|
| 187 |
+
_PROC[name] = AutoProcessor.from_pretrained(name)
|
| 188 |
+
return _PROC[name]
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def agent_user(instruction, previous="None"):
|
| 192 |
+
"""The agent layout's user text (AGUVIS's format)."""
|
| 193 |
+
return ("Please generate the next move according to the UI screenshot, instruction and previous actions.\n\n"
|
| 194 |
+
f"Instruction: {instruction}\n\nPrevious actions:\n{previous}")
|
slotread/data/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Datasets. Parsed examples are cached on disk (JPEG + index.json) under SLOTREAD_CACHE, because
|
| 2 |
+
streaming and filtering the sources is slow (AGUVIS: up to hours) and every arm trains on the same
|
| 3 |
+
examples."""
|
| 4 |
+
|
| 5 |
+
import json, os, resource
|
| 6 |
+
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
CACHE = os.path.expanduser(os.environ.get("SLOTREAD_CACHE", "~/.cache/slotread"))
|
| 10 |
+
|
| 11 |
+
# cached examples are opened lazily (PIL holds one descriptor each); tens of thousands of them need a
|
| 12 |
+
# higher descriptor limit than the default 1024
|
| 13 |
+
_soft, _hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
| 14 |
+
resource.setrlimit(resource.RLIMIT_NOFILE, (max(_soft, min(65536, _hard)), _hard))
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_cache(name):
|
| 18 |
+
path = os.path.join(CACHE, name)
|
| 19 |
+
if not os.path.exists(f"{path}/index.json"):
|
| 20 |
+
return None
|
| 21 |
+
out = []
|
| 22 |
+
for r in json.load(open(f"{path}/index.json")):
|
| 23 |
+
r = dict(r)
|
| 24 |
+
r["image"] = Image.open(f"{path}/{r.pop('file')}")
|
| 25 |
+
out.append(r)
|
| 26 |
+
print(f" cache: {len(out)} examples from {path}", flush=True)
|
| 27 |
+
return out
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def save_cache(name, rows):
|
| 31 |
+
path = os.path.join(CACHE, name)
|
| 32 |
+
os.makedirs(path, exist_ok=True)
|
| 33 |
+
idx = []
|
| 34 |
+
for i, r in enumerate(rows):
|
| 35 |
+
f = f"{i:05d}.jpg"
|
| 36 |
+
r["image"].convert("RGB").save(f"{path}/{f}", "JPEG", quality=88)
|
| 37 |
+
idx.append({k: v for k, v in r.items() if k != "image"} | {"file": f})
|
| 38 |
+
json.dump(idx, open(f"{path}/index.json", "w"))
|
| 39 |
+
print(f" cached {len(rows)} examples to {path}", flush=True)
|
slotread/data/aguvis.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AGUVIS stage 2 (smolagents/aguvis-stage-2): computer-use steps with normalized coordinates, from
|
| 2 |
+
AndroidControl, Mind2Web, GUIAct, GUI-Odyssey, MiniWoB, AMEX and AitW.
|
| 3 |
+
|
| 4 |
+
Each source's stream is read from row `skip` on, `n / 8` usable steps per source, then shuffled with
|
| 5 |
+
`split_seed`. The shuffle depends on n, so two pools of different sizes hold different rows in
|
| 6 |
+
different orders: compare runs on test_steps() (rows 6,000+ of every source, past every training pool),
|
| 7 |
+
not on the first rows of a pool.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import itertools, random, re
|
| 11 |
+
|
| 12 |
+
from . import load_cache, save_cache
|
| 13 |
+
|
| 14 |
+
AGUVIS = ["android_control", "mind2web-l2", "guiact-web-single", "guiact-web-multi-l2", "gui-odyssey-l2",
|
| 15 |
+
"miniwob-l2", "amex-l2", "aitw-l2"]
|
| 16 |
+
ACTIONS = ["click", "type", "scroll", "swipe", "press", "open", "back", "home", "wait", "answer", "select"]
|
| 17 |
+
KEYS = ["enter", "tab", "escape", "space", "delete", "up", "down", "left", "right", "ctrl", "other"] # all single tokens
|
| 18 |
+
KEY_ALIAS = {"backspace": "delete", "esc": "escape", "return": "enter"}
|
| 19 |
+
SDIR = ["up", "down", "left", "right"]
|
| 20 |
+
CALL = re.compile(r"<code>(.*?)</code>", re.S)
|
| 21 |
+
FN = re.compile(r"(\w+)\((.*?)\)\s*$", re.S)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def parse_action(assistant):
|
| 25 |
+
"""The assistant's <code> call as (action, {x, y, x2, y2, key, sdir, samt}), or None."""
|
| 26 |
+
m = CALL.search(assistant or "")
|
| 27 |
+
if not m:
|
| 28 |
+
return None
|
| 29 |
+
body = m.group(1).strip().splitlines()
|
| 30 |
+
call = FN.search(body[-1].strip()) if body else None
|
| 31 |
+
if not call:
|
| 32 |
+
return None
|
| 33 |
+
fn, args = call.group(1), call.group(2)
|
| 34 |
+
|
| 35 |
+
def num(k):
|
| 36 |
+
m = re.search(rf"{k}\s*=\s*([-\d.]+)", args)
|
| 37 |
+
return float(m.group(1)) if m else None
|
| 38 |
+
|
| 39 |
+
def pair(k):
|
| 40 |
+
m = re.search(rf"{k}\s*=\s*\[(.*?)\]", args)
|
| 41 |
+
return [float(v) for v in re.findall(r"[-\d.]+", m.group(1))] if m else None
|
| 42 |
+
|
| 43 |
+
out = {}
|
| 44 |
+
if fn in ("click", "select_option"):
|
| 45 |
+
out.update(x=num("x"), y=num("y"))
|
| 46 |
+
act = "click" if fn == "click" else "select"
|
| 47 |
+
elif fn == "swipe":
|
| 48 |
+
a, b = pair("from_coord"), pair("to_coord")
|
| 49 |
+
if not a or not b:
|
| 50 |
+
return None
|
| 51 |
+
out.update(x=a[0], y=a[1], x2=b[0], y2=b[1])
|
| 52 |
+
act = "swipe"
|
| 53 |
+
elif fn == "scroll":
|
| 54 |
+
d = re.search(r"direction\s*=\s*['\"](\w+)['\"]", args)
|
| 55 |
+
amt = num("amount")
|
| 56 |
+
out.update(sdir=d.group(1) if d else None, samt=min(9, int((amt or 5) / 2)))
|
| 57 |
+
act = "scroll"
|
| 58 |
+
elif fn == "press":
|
| 59 |
+
k = re.search(r"keys?\s*=\s*['\"]([^'\"]+)['\"]", args) or re.search(r"['\"]([^'\"]+)['\"]", args)
|
| 60 |
+
key = k.group(1).lower().split("+")[-1] if k else "other"
|
| 61 |
+
key = KEY_ALIAS.get(key, key)
|
| 62 |
+
out["key"] = key if key in KEYS else "other"
|
| 63 |
+
act = "press"
|
| 64 |
+
else:
|
| 65 |
+
act = {"type": "type", "open_app": "open", "navigate_back": "back", "navigate_home": "home",
|
| 66 |
+
"wait": "wait", "final_answer": "answer", "terminate": "answer"}.get(fn)
|
| 67 |
+
if act is None:
|
| 68 |
+
return None
|
| 69 |
+
if act in ("click", "select", "swipe") and not all(0 <= (out.get(k) or -1) <= 1 for k in ("x", "y")):
|
| 70 |
+
return None
|
| 71 |
+
return act, out
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def steps(n, skip=0, split_seed=0, cache=True):
|
| 75 |
+
"""n AGUVIS steps as dict(image, user, action, args, source)."""
|
| 76 |
+
name = f"aguvis_{n}_{skip}_{split_seed}"
|
| 77 |
+
if cache and (got := load_cache(name)) is not None:
|
| 78 |
+
return got
|
| 79 |
+
from datasets import load_dataset
|
| 80 |
+
out, per = [], max(1, n // len(AGUVIS))
|
| 81 |
+
for cfg in AGUVIS:
|
| 82 |
+
ds = load_dataset("smolagents/aguvis-stage-2", cfg, split="train", streaming=True)
|
| 83 |
+
got = 0
|
| 84 |
+
for r in itertools.islice(ds, skip, skip + per * 6):
|
| 85 |
+
if got >= per: # enough from this source: stop streaming (the rows kept are the same)
|
| 86 |
+
break
|
| 87 |
+
if len(r["images"]) != 1:
|
| 88 |
+
continue
|
| 89 |
+
t = r["texts"][0]
|
| 90 |
+
a = parse_action(t.get("assistant"))
|
| 91 |
+
if not a:
|
| 92 |
+
continue
|
| 93 |
+
out.append(dict(image=r["images"][0], user=(t.get("user") or "").strip(), action=a[0], args=a[1],
|
| 94 |
+
source=cfg))
|
| 95 |
+
got += 1
|
| 96 |
+
print(f" {cfg}: {got}", flush=True)
|
| 97 |
+
random.Random(split_seed).shuffle(out)
|
| 98 |
+
out = out[:n]
|
| 99 |
+
if cache:
|
| 100 |
+
save_cache(name, out)
|
| 101 |
+
return out
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_steps(n=1600):
|
| 105 |
+
"""The clean held-out test set: rows 6,000+ of every source's stream, past every training pool
|
| 106 |
+
(a pool of N reads at most 6 N / 8 rows per source)."""
|
| 107 |
+
return steps(1600, skip=6000, split_seed=1)[: n or None]
|
slotread/data/evalsets.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation sets outside the training data.
|
| 2 |
+
|
| 3 |
+
screenspot ScreenSpot (bevaya/ScreenSpot, test, 1,272), shuffled with seed 0: clicks and element boxes
|
| 4 |
+
coco17 COCO val2017 photos (RefCOCO is all train2014): objects prompted by category name, for
|
| 5 |
+
categories with exactly one instance in the image, at least 1% of it, polygon masks only
|
| 6 |
+
fashion Fashionpedia val: one garment per image, by name (a new domain; boxes only)
|
| 7 |
+
|
| 8 |
+
Point COCO17_DIR at a folder with annotations/instances_val2017.json and val2017/
|
| 9 |
+
(recipes/fetch_coco17.sh).
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json, os, random
|
| 13 |
+
|
| 14 |
+
COCO17 = os.environ.get("COCO17_DIR", "/workspace/data/coco17")
|
| 15 |
+
MIN_AREA = 0.01
|
| 16 |
+
GARMENTS = 27 # Fashionpedia classes 0-26 are whole garments and accessories; the rest are parts
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def screenspot(n=0):
|
| 20 |
+
from datasets import load_dataset
|
| 21 |
+
ds = load_dataset("bevaya/ScreenSpot", split="test").shuffle(seed=0)
|
| 22 |
+
ds = ds.select(range(n)) if n else ds
|
| 23 |
+
return [dict(image=r["image"], instruction=r["instruction"], box=list(r["bbox"]), norm=True,
|
| 24 |
+
group=(r["data_source"], r["data_type"])) for r in ds]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def coco17(n, seed=0):
|
| 28 |
+
d = json.load(open(f"{COCO17}/annotations/instances_val2017.json"))
|
| 29 |
+
cats = {c["id"]: c["name"] for c in d["categories"]}
|
| 30 |
+
imgs = {i["id"]: i for i in d["images"]}
|
| 31 |
+
by = {}
|
| 32 |
+
for a in d["annotations"]:
|
| 33 |
+
by.setdefault((a["image_id"], a["category_id"]), []).append(a)
|
| 34 |
+
out = []
|
| 35 |
+
for (iid, cid), anns in sorted(by.items()):
|
| 36 |
+
if len(anns) != 1:
|
| 37 |
+
continue
|
| 38 |
+
a, im = anns[0], imgs[iid]
|
| 39 |
+
W, H = im["width"], im["height"]
|
| 40 |
+
if a["iscrowd"] or not isinstance(a["segmentation"], list) or a["area"] < MIN_AREA * W * H:
|
| 41 |
+
continue
|
| 42 |
+
x, y, w, h = a["bbox"]
|
| 43 |
+
out.append(dict(image=f"{COCO17}/val2017/{im['file_name']}", size=(W, H), sentence=cats[cid],
|
| 44 |
+
instruction=cats[cid], polys=a["segmentation"], bbox=[x, y, x + w, y + h],
|
| 45 |
+
box=[x / W, y / H, (x + w) / W, (y + h) / H], norm=True, ref_id=a["id"], source="coco17"))
|
| 46 |
+
random.Random(seed).shuffle(out)
|
| 47 |
+
return out[:n] if n else out
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def fashion(n, seed=0):
|
| 51 |
+
from datasets import load_dataset
|
| 52 |
+
ds = load_dataset("detection-datasets/fashionpedia", split="val")
|
| 53 |
+
names = ds.features["objects"]["category"].feature.names
|
| 54 |
+
idx = list(range(len(ds)))
|
| 55 |
+
random.Random(seed).shuffle(idx)
|
| 56 |
+
out = []
|
| 57 |
+
for i in idx:
|
| 58 |
+
r = ds[i]
|
| 59 |
+
W, H, o = r["width"], r["height"], r["objects"]
|
| 60 |
+
counts = {}
|
| 61 |
+
for c in o["category"]:
|
| 62 |
+
counts[c] = counts.get(c, 0) + 1
|
| 63 |
+
for c, (x1, y1, x2, y2) in zip(o["category"], o["bbox"]): # x1, y1, x2, y2 in pixels
|
| 64 |
+
if c < GARMENTS and counts[c] == 1 and (x2 - x1) * (y2 - y1) >= MIN_AREA * W * H:
|
| 65 |
+
out.append(dict(image=r["image"], size=(W, H), instruction=f"the {names[c].split(',')[0]}",
|
| 66 |
+
box=[x1 / W, y1 / H, x2 / W, y2 / H], norm=True, source="fashionpedia"))
|
| 67 |
+
break # one object per image
|
| 68 |
+
if n and len(out) >= n:
|
| 69 |
+
break
|
| 70 |
+
return out
|
slotread/data/refcoco.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RefCOCO (jxu124/refcoco): referring expressions with masks and boxes on COCO train2014 photos.
|
| 2 |
+
|
| 3 |
+
The images are not in the dataset: point COCO_DIR at COCO train2014 (or at the subset that
|
| 4 |
+
recipes/fetch_refcoco.sh downloads: only the ~20k images RefCOCO uses).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json, os, random
|
| 8 |
+
|
| 9 |
+
COCO = os.environ.get("COCO_DIR", "/workspace/data/coco_ref")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def rows(split):
|
| 13 |
+
from datasets import load_dataset
|
| 14 |
+
return load_dataset("jxu124/refcoco", split=split)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def examples(split_rows, n, seed, one_sentence=True):
|
| 18 |
+
"""Refs as dict(image, size, polys, bbox (x1, y1, x2, y2 pixels), ref_id, sentence). Training: one
|
| 19 |
+
random sentence per ref. Evaluation with one_sentence=False: every expression, as the RefCOCO
|
| 20 |
+
literature scores it."""
|
| 21 |
+
idx = list(range(len(split_rows)))
|
| 22 |
+
random.Random(seed).shuffle(idx)
|
| 23 |
+
out = []
|
| 24 |
+
for i in idx:
|
| 25 |
+
r = split_rows[i]
|
| 26 |
+
ann, info = json.loads(r["raw_anns"]), json.loads(r["raw_image_info"])
|
| 27 |
+
sents = [s["sent"] for s in r["sentences"]]
|
| 28 |
+
base = dict(image=os.path.join(COCO, info["file_name"]), size=(info["width"], info["height"]),
|
| 29 |
+
polys=ann["segmentation"], bbox=list(r["bbox"]), ref_id=r["ref_id"])
|
| 30 |
+
if one_sentence:
|
| 31 |
+
out.append(dict(base, sentence=random.Random(seed + i).choice(sents)))
|
| 32 |
+
else:
|
| 33 |
+
out += [dict(base, sentence=s) for s in sents]
|
| 34 |
+
if n and len(out) >= n:
|
| 35 |
+
break
|
| 36 |
+
return out[:n] if n else out
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def photo_boxes(n, split, seed):
|
| 40 |
+
"""Referents as box examples (one expression per object): general objects in photos."""
|
| 41 |
+
out = []
|
| 42 |
+
for ex in examples(rows(split), n, seed, one_sentence=True):
|
| 43 |
+
(W, H), (x1, y1, x2, y2) = ex["size"], ex["bbox"]
|
| 44 |
+
out.append(dict(image=ex["image"], size=ex["size"], instruction=ex["sentence"],
|
| 45 |
+
box=[x1 / W, y1 / H, x2 / W, y2 / H], norm=True, source="refcoco"))
|
| 46 |
+
return out
|
slotread/data/waveui.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wave-UI (agentsea/wave-ui): UI elements with instructions and boxes, used as clicks in the agent
|
| 2 |
+
layout and as element boxes. The ScreenSpot source and every image that also appears in ScreenSpot
|
| 3 |
+
are removed, so ScreenSpot stays a clean test set."""
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
|
| 7 |
+
from . import load_cache, save_cache
|
| 8 |
+
|
| 9 |
+
# sources whose "instruction" is element-type metadata; their element name reads like an instruction
|
| 10 |
+
NAME_SOURCES = ("webui", "roboflow", "mind2web_test_domain", "mind2web_test_task", "mind2web_test_website")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def screenspot_hashes():
|
| 14 |
+
from datasets import load_dataset
|
| 15 |
+
key = lambda im: hashlib.md5(im.convert("L").resize((64, 64)).tobytes()).hexdigest()
|
| 16 |
+
return key, {key(im) for im in load_dataset("bevaya/ScreenSpot", split="test")["image"]}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def clean(split, n, seed, key, banned):
|
| 20 |
+
from datasets import load_dataset
|
| 21 |
+
ds = load_dataset("agentsea/wave-ui", split=split).shuffle(seed=seed)
|
| 22 |
+
ds = ds.filter(lambda src: src != "screenspot", input_columns=["source"])
|
| 23 |
+
out = []
|
| 24 |
+
for r in ds:
|
| 25 |
+
if len(out) >= n:
|
| 26 |
+
break
|
| 27 |
+
x1, y1, x2, y2 = r["bbox"]
|
| 28 |
+
W, H = r["image"].size
|
| 29 |
+
if not r["instruction"] or x2 <= x1 or y2 <= y1 or x2 > W * 1.02 or y2 > H * 1.02:
|
| 30 |
+
continue
|
| 31 |
+
if key(r["image"]) in banned:
|
| 32 |
+
continue
|
| 33 |
+
if r["source"] in NAME_SOURCES:
|
| 34 |
+
r = dict(r, instruction=r["name"])
|
| 35 |
+
if not r["instruction"]:
|
| 36 |
+
continue
|
| 37 |
+
out.append(r)
|
| 38 |
+
return out
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def clicks(n, skip=0, cache=True):
|
| 42 |
+
"""Wave-UI elements as click steps in the agent layout (keeps the pointing sharp)."""
|
| 43 |
+
name = f"waveui_clicks_{n}_{skip}"
|
| 44 |
+
if cache and (got := load_cache(name)) is not None:
|
| 45 |
+
return got
|
| 46 |
+
key, banned = screenspot_hashes()
|
| 47 |
+
out = []
|
| 48 |
+
for r in clean("train", skip + n, 0, key, banned)[skip:]:
|
| 49 |
+
W, H = r["image"].size
|
| 50 |
+
x1, y1, x2, y2 = r["bbox"]
|
| 51 |
+
out.append(dict(image=r["image"], user=f"Instruction: {r['instruction']}\n\nPrevious actions:\nNone",
|
| 52 |
+
action="click", source="wave-ui",
|
| 53 |
+
args=dict(x=(x1 + x2) / 2 / W, y=(y1 + y2) / 2 / H, box=(x2 - x1) / W, boy=(y2 - y1) / H)))
|
| 54 |
+
if cache:
|
| 55 |
+
save_cache(name, out)
|
| 56 |
+
return out
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def boxes(n, skip=2000, cache=True):
|
| 60 |
+
"""Wave-UI element boxes (pixels). Rows [skip, skip + n) are disjoint from the clicks [0, n_click)."""
|
| 61 |
+
name = f"waveui_boxes_{n}_{skip}"
|
| 62 |
+
if cache and (got := load_cache(name)) is not None:
|
| 63 |
+
return got
|
| 64 |
+
key, banned = screenspot_hashes()
|
| 65 |
+
out = [dict(image=r["image"], instruction=r["instruction"], box=list(r["bbox"]), source=r["source"])
|
| 66 |
+
for r in clean("train", skip + n, 0, key, banned)[skip:]]
|
| 67 |
+
if cache:
|
| 68 |
+
save_cache(name, out)
|
| 69 |
+
return out
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def val_boxes(n):
|
| 73 |
+
key, banned = screenspot_hashes()
|
| 74 |
+
return [dict(image=r["image"], instruction=r["instruction"], box=list(r["bbox"]), source=r["source"])
|
| 75 |
+
for r in clean("validation", n, 1, key, banned)]
|
slotread/evaluate.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate served slot adapters on the same cases, side by side, with paired statistics.
|
| 2 |
+
|
| 3 |
+
python -m slotread.evaluate <set> <n> name=run_dir [name=run_dir ...] (n=0: the whole set)
|
| 4 |
+
|
| 5 |
+
agent_test 1,600 AGUVIS steps no run trained on (rows 6,000+ of every source)
|
| 6 |
+
click ScreenSpot clicks in the agent layout (1,272)
|
| 7 |
+
box ScreenSpot element boxes (1,272)
|
| 8 |
+
box_refcoco RefCOCO val referents as boxes (general objects; 3,811)
|
| 9 |
+
box_coco17 COCO val2017 objects by category name (photos RefCOCO never used)
|
| 10 |
+
box_fashion Fashionpedia garments (a new domain)
|
| 11 |
+
seg_val RefCOCO val masks (also seg_testA, seg_testB); SEG_EXPR=one: one expression per object
|
| 12 |
+
|
| 13 |
+
SHARD=0/2 python -m slotread.evaluate seg_val 0 ... every 2nd case from 0 (split across servers)
|
| 14 |
+
python -m slotread.evaluate merge <out.json> <shard.json> ...
|
| 15 |
+
|
| 16 |
+
Every case is read by every adapter (agent, click and box: per-slot requests; seg: one request with
|
| 17 |
+
prompt log-probabilities), scored under three readouts (default, peak, expected), and the adapters
|
| 18 |
+
are compared case by case: a paired bootstrap interval on the difference, and McNemar's exact test for
|
| 19 |
+
right/wrong metrics. A difference counts only when its interval excludes zero.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import itertools, json, math, os, sys, time
|
| 23 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
|
| 27 |
+
from .client import SlotModel, agent_user
|
| 28 |
+
from .data import aguvis, evalsets, refcoco
|
| 29 |
+
from .images import data_url, image_of
|
| 30 |
+
from .numbers import slots_for
|
| 31 |
+
|
| 32 |
+
OUT = os.environ.get("SLOTREAD_RESULTS", "results")
|
| 33 |
+
CASE_WORKERS = int(os.environ.get("CASE_WORKERS", 6))
|
| 34 |
+
HOWS = ("default", "peak", "expected")
|
| 35 |
+
PRIMARY = {"agent": "step", "click": "hit", "box": "iou", "seg": "iou"}
|
| 36 |
+
BOOT = 10000
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def mcnemar(a, b):
|
| 40 |
+
"""Two-sided exact McNemar p-value from paired booleans; also the discordant counts."""
|
| 41 |
+
n01, n10 = int(np.sum(a & ~b)), int(np.sum(~a & b))
|
| 42 |
+
n = n01 + n10
|
| 43 |
+
if n == 0:
|
| 44 |
+
return 1.0, n01, n10
|
| 45 |
+
p = 2 * sum(math.comb(n, i) for i in range(min(n01, n10) + 1)) / 2 ** n
|
| 46 |
+
return min(1.0, p), n01, n10
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def boot(diff, rng):
|
| 50 |
+
"""A paired bootstrap 95% interval on the mean difference."""
|
| 51 |
+
idx = rng.integers(0, len(diff), (BOOT, len(diff)))
|
| 52 |
+
m = diff[idx].mean(1)
|
| 53 |
+
return float(np.percentile(m, 2.5)), float(np.percentile(m, 97.5))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def cases(which, n):
|
| 57 |
+
if which == "agent_test":
|
| 58 |
+
return [dict(kind="agent", ex=ex) for ex in aguvis.test_steps(n)]
|
| 59 |
+
if which in ("click", "box"):
|
| 60 |
+
return [dict(kind=which, ex=ex) for ex in evalsets.screenspot(n)]
|
| 61 |
+
if which == "box_coco17":
|
| 62 |
+
return [dict(kind="box", ex=ex) for ex in evalsets.coco17(n)]
|
| 63 |
+
if which == "box_fashion":
|
| 64 |
+
return [dict(kind="box", ex=ex) for ex in evalsets.fashion(n or 1000)]
|
| 65 |
+
if which == "box_refcoco":
|
| 66 |
+
return [dict(kind="box", ex=ex) for ex in refcoco.photo_boxes(n, "validation", seed=0)]
|
| 67 |
+
split = {"seg_val": "validation", "seg_testA": "test", "seg_testB": "testB"}[which]
|
| 68 |
+
one = os.environ.get("SEG_EXPR", "all") == "one"
|
| 69 |
+
return [dict(kind="seg", ex=ex) for ex in refcoco.examples(refcoco.rows(split), n, seed=0, one_sentence=one)]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# accuracy runs send screenshots as the post measured them: full-size PNG (EVAL_IMG_FMT / EVAL_IMG_MAX_PX to
|
| 73 |
+
# change). Serving and latency use the faster model-sized JPEG (slotread.images); on ScreenSpot clicks one
|
| 74 |
+
# run measured 0.825 with it against 0.860 with full-size PNG, while agent steps were unchanged.
|
| 75 |
+
EVAL_FMT = os.environ.get("EVAL_IMG_FMT", "png")
|
| 76 |
+
EVAL_MAX_PX = int(os.environ.get("EVAL_IMG_MAX_PX", 0))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def evaluate_case(model, case):
|
| 80 |
+
kind, ex = case["kind"], case["ex"]
|
| 81 |
+
url = data_url(image_of(ex), fmt=EVAL_FMT, max_px=EVAL_MAX_PX)
|
| 82 |
+
a, out = model.arm, {}
|
| 83 |
+
if kind in ("agent", "click"):
|
| 84 |
+
task = model.tasks["agent"]
|
| 85 |
+
user = ex["user"] if kind == "agent" else agent_user(ex["instruction"])
|
| 86 |
+
lp = model.read("agent", url, user, ["action"] + slots_for("x", a) + slots_for("y", a))
|
| 87 |
+
for how in HOWS:
|
| 88 |
+
p = task.decode(lp, a, how)
|
| 89 |
+
if kind == "agent":
|
| 90 |
+
out[how] = task.score(p, ex)
|
| 91 |
+
else:
|
| 92 |
+
b = ex["box"]
|
| 93 |
+
out[how] = dict(hit=float(b[0] <= p["x"] <= b[2] and b[1] <= p["y"] <= b[3]),
|
| 94 |
+
action_click=float(p["action"] == "click"))
|
| 95 |
+
elif kind == "box":
|
| 96 |
+
task = model.tasks["box"]
|
| 97 |
+
lp = model.read("box", url, task.user(ex), task.layout(a))
|
| 98 |
+
for how in HOWS:
|
| 99 |
+
out[how] = task.score(task.decode(lp, a, how), ex)
|
| 100 |
+
else:
|
| 101 |
+
task = model.tasks["seg"]
|
| 102 |
+
lp = model.read_one("seg", url, task.user(ex))
|
| 103 |
+
for how in HOWS:
|
| 104 |
+
out[how] = task.score(task.decode(lp, a, how), ex)
|
| 105 |
+
return out
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def summarize(which, rows, names):
|
| 109 |
+
kind = rows[0]["kind"]
|
| 110 |
+
key = PRIMARY[kind]
|
| 111 |
+
cols = [k for k in sorted(rows[0]["arms"][names[0]]["default"]) if k not in ("inter", "union")]
|
| 112 |
+
cols += ["ciou"] if kind == "seg" else []
|
| 113 |
+
print(f"\n{which}: {len(rows)} cases\n\n| adapter | readout | " + " | ".join(cols) + " |\n|---|---|" + "---|" * len(cols))
|
| 114 |
+
summary = {}
|
| 115 |
+
for name in names:
|
| 116 |
+
for how in HOWS:
|
| 117 |
+
vals = [r["arms"][name][how] for r in rows]
|
| 118 |
+
m = {}
|
| 119 |
+
for k in cols:
|
| 120 |
+
if k == "ciou":
|
| 121 |
+
m[k] = sum(v["inter"] for v in vals) / max(1, sum(v["union"] for v in vals))
|
| 122 |
+
continue
|
| 123 |
+
xs = [v[k] for v in vals if v.get(k) is not None]
|
| 124 |
+
m[k] = sum(xs) / len(xs) if xs else None
|
| 125 |
+
summary[f"{name}/{how}"] = m
|
| 126 |
+
print(f"| {name} | {how} | " + " | ".join("" if m[k] is None else f"{m[k]:.3f}" for k in cols) + " |",
|
| 127 |
+
flush=True)
|
| 128 |
+
rng = np.random.default_rng(0)
|
| 129 |
+
print(f"\nPaired, default readouts, on {key}:\n")
|
| 130 |
+
for x, y in itertools.combinations(names, 2):
|
| 131 |
+
vx = np.array([r["arms"][x]["default"][key] or 0.0 for r in rows], dtype=float)
|
| 132 |
+
vy = np.array([r["arms"][y]["default"][key] or 0.0 for r in rows], dtype=float)
|
| 133 |
+
lo, hi = boot(vx - vy, rng)
|
| 134 |
+
extra = ""
|
| 135 |
+
if kind in ("agent", "click"):
|
| 136 |
+
p, n01, n10 = mcnemar(vx > 0.5, vy > 0.5)
|
| 137 |
+
extra = f" | McNemar p {p:.3f} ({n01} / {n10})"
|
| 138 |
+
verdict = "tie" if lo <= 0 <= hi else (f"{x} ahead" if lo > 0 else f"{y} ahead")
|
| 139 |
+
print(f"- {x} {vx.mean():.3f} vs {y} {vy.mean():.3f}: diff {vx.mean() - vy.mean():+.3f} "
|
| 140 |
+
f"[{lo:+.3f}, {hi:+.3f}]{extra} -> {verdict}", flush=True)
|
| 141 |
+
return summary
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def merge(out, parts):
|
| 145 |
+
"""Rejoin shards of one set (every row carries every adapter, so pairing survives the split)."""
|
| 146 |
+
ds = [json.load(open(p)) for p in parts]
|
| 147 |
+
rows = [r for d in ds for r in d["rows"]]
|
| 148 |
+
summary = summarize(ds[0]["which"], rows, list(ds[0]["arms"]))
|
| 149 |
+
json.dump(dict(which=ds[0]["which"], n=len(rows), arms=ds[0]["arms"], summary=summary, rows=rows, shards=parts),
|
| 150 |
+
open(out, "w"))
|
| 151 |
+
print(f"\nmerged {len(parts)} shards, {len(rows)} cases -> {out}", flush=True)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def main(args=None):
|
| 155 |
+
args = args or sys.argv[1:]
|
| 156 |
+
if args[0] == "merge":
|
| 157 |
+
return merge(args[1], args[2:])
|
| 158 |
+
which, n = args[0], int(args[1])
|
| 159 |
+
models = [SlotModel(*a.split("=", 1)) for a in args[2:]]
|
| 160 |
+
cs = cases(which, n)
|
| 161 |
+
shard = os.environ.get("SHARD")
|
| 162 |
+
if shard: # "i/k": every k-th case starting at i
|
| 163 |
+
i, k = map(int, shard.split("/"))
|
| 164 |
+
cs = cs[i::k]
|
| 165 |
+
print(f"{which}: {len(cs)} cases{f' (shard {shard})' if shard else ''}, adapters {', '.join(m.name for m in models)}",
|
| 166 |
+
flush=True)
|
| 167 |
+
|
| 168 |
+
def one(ic):
|
| 169 |
+
i, c = ic
|
| 170 |
+
return dict(id=i, kind=c["kind"], group=list(c["ex"].get("group", [])),
|
| 171 |
+
arms={m.name: evaluate_case(m, c) for m in models})
|
| 172 |
+
|
| 173 |
+
rows, t = [], time.time()
|
| 174 |
+
with ThreadPoolExecutor(CASE_WORKERS) as p:
|
| 175 |
+
for k, row in enumerate(p.map(one, enumerate(cs))):
|
| 176 |
+
rows.append(row)
|
| 177 |
+
if (k + 1) % 200 == 0:
|
| 178 |
+
print(f" {k + 1}/{len(cs)} ({time.time() - t:.0f}s)", flush=True)
|
| 179 |
+
summary = summarize(which, rows, [m.name for m in models])
|
| 180 |
+
os.makedirs(OUT, exist_ok=True)
|
| 181 |
+
path = f"{OUT}/eval_{which}_{n}{'_s' + shard.replace('/', 'of') if shard else ''}.json"
|
| 182 |
+
json.dump(dict(which=which, n=len(rows), arms={m.name: m.path for m in models}, summary=summary, rows=rows),
|
| 183 |
+
open(path, "w"))
|
| 184 |
+
print(f"\nwrote {path}", flush=True)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
if __name__ == "__main__":
|
| 188 |
+
main()
|
slotread/geometry.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Boxes and masks: IoU, COCO polygons, and the radial (centre + rays) mask encoding."""
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
from PIL import Image, ImageDraw
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def box_iou(a, b):
|
| 10 |
+
ix1, iy1, ix2, iy2 = max(a[0], b[0]), max(a[1], b[1]), min(a[2], b[2]), min(a[3], b[3])
|
| 11 |
+
inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
|
| 12 |
+
union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
|
| 13 |
+
return inter / union if union > 0 else 0.0
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def poly_mask(polys, W, H):
|
| 17 |
+
"""COCO polygons (flat lists of x, y) -> boolean mask."""
|
| 18 |
+
im = Image.new("L", (W, H), 0)
|
| 19 |
+
d = ImageDraw.Draw(im)
|
| 20 |
+
for p in polys:
|
| 21 |
+
if len(p) >= 6:
|
| 22 |
+
d.polygon([(p[i], p[i + 1]) for i in range(0, len(p), 2)], fill=1)
|
| 23 |
+
return np.array(im, dtype=bool)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def mask_iou(a, b):
|
| 27 |
+
inter = np.logical_and(a, b).sum()
|
| 28 |
+
union = np.logical_or(a, b).sum()
|
| 29 |
+
return (inter / union if union else 0.0), int(inter), int(union)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def radial_encode(mask, K):
|
| 33 |
+
"""Centre: the point inside the mask farthest from its boundary (a centroid can fall outside a
|
| 34 |
+
non-convex mask). Ray k at angle 2 pi k / K (0 = right, turning clockwise on screen): the distance to
|
| 35 |
+
the farthest mask pixel along it. Returns (cx, cy, d) in pixels."""
|
| 36 |
+
from scipy.ndimage import distance_transform_edt
|
| 37 |
+
H, W = mask.shape
|
| 38 |
+
dt = distance_transform_edt(mask)
|
| 39 |
+
cy, cx = np.unravel_index(int(dt.argmax()), dt.shape)
|
| 40 |
+
diag = math.hypot(W, H)
|
| 41 |
+
t = np.arange(0, diag, 0.5)
|
| 42 |
+
ang = 2 * np.pi * np.arange(K) / K
|
| 43 |
+
xs = np.rint(cx + np.outer(np.cos(ang), t)).astype(int)
|
| 44 |
+
ys = np.rint(cy + np.outer(np.sin(ang), t)).astype(int)
|
| 45 |
+
inb = (xs >= 0) & (xs < W) & (ys >= 0) & (ys < H)
|
| 46 |
+
inside = np.zeros_like(inb)
|
| 47 |
+
inside[inb] = mask[ys[inb], xs[inb]]
|
| 48 |
+
last = np.where(inside.any(1), inside.shape[1] - 1 - np.argmax(inside[:, ::-1], 1), 0)
|
| 49 |
+
return float(cx), float(cy), t[last]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def radial_decode(cx, cy, d, W, H):
|
| 53 |
+
"""Ray endpoints -> polygon -> boolean mask (all in pixels). Returns (mask, polygon points)."""
|
| 54 |
+
K = len(d)
|
| 55 |
+
ang = 2 * np.pi * np.arange(K) / K
|
| 56 |
+
pts = [(cx + float(r) * math.cos(a), cy + float(r) * math.sin(a)) for r, a in zip(d, ang)]
|
| 57 |
+
im = Image.new("L", (W, H), 0)
|
| 58 |
+
ImageDraw.Draw(im).polygon(pts, fill=1)
|
| 59 |
+
return np.array(im, dtype=bool), pts
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def grow(b, g, W, H):
|
| 63 |
+
"""A box (x1, y1, x2, y2, pixels) grown by factor g about its centre, clipped to the image."""
|
| 64 |
+
gx, gy = (b[2] - b[0]) * (g - 1) / 2, (b[3] - b[1]) * (g - 1) / 2
|
| 65 |
+
return [max(0, b[0] - gx), max(0, b[1] - gy), min(W - 1, b[2] + gx), min(H - 1, b[3] + gy)]
|
slotread/images.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Images: opening examples, capping their size, and encoding screenshots for the server."""
|
| 2 |
+
|
| 3 |
+
import base64, io, math, os
|
| 4 |
+
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
# max pixels per image for training and serving (0: as given). GUI-Owl uses 1,048,576 so training and
|
| 8 |
+
# serving see the same image; Gemma 4 resizes to its own fixed token budget either way.
|
| 9 |
+
IMAGE_CAP = int(os.environ.get("IMAGE_CAP", 0))
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def cap_image(im, cap=None, factor=32):
|
| 13 |
+
"""Downscale to at most `cap` pixels, sides multiples of `factor` (Qwen3-VL: patch 16 x merge 2),
|
| 14 |
+
so the processor keeps the size."""
|
| 15 |
+
cap = IMAGE_CAP if cap is None else cap
|
| 16 |
+
W, H = im.size
|
| 17 |
+
if not cap or W * H <= cap:
|
| 18 |
+
return im
|
| 19 |
+
s = math.sqrt(cap / (W * H))
|
| 20 |
+
w, h = max(factor, int(W * s) // factor * factor), max(factor, int(H * s) // factor * factor)
|
| 21 |
+
return im.resize((w, h), Image.BICUBIC)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def image_of(ex):
|
| 25 |
+
"""An example's image as RGB; a path is opened only when needed (keeps thousands of examples cheap)."""
|
| 26 |
+
im = ex["image"]
|
| 27 |
+
if isinstance(im, str):
|
| 28 |
+
im = Image.open(im)
|
| 29 |
+
return cap_image(im.convert("RGB") if im.mode != "RGB" else im)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def image_size(ex):
|
| 33 |
+
if "size" in ex:
|
| 34 |
+
return ex["size"]
|
| 35 |
+
im = ex["image"]
|
| 36 |
+
if isinstance(im, str):
|
| 37 |
+
with Image.open(im) as f:
|
| 38 |
+
return f.size
|
| 39 |
+
return im.size
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# How screenshots travel to the server. The fast path, and the default for serving: JPEG at the model's
|
| 43 |
+
# input size (Gemma 4's encoder sees about 896 x 896 at its default budget): ~8 ms to build instead of
|
| 44 |
+
# ~118 ms for a full-size PNG, and cheaper for the server to decode. IMG_FMT=png IMG_MAX_PX=0 sends the
|
| 45 |
+
# lossless full-size image instead.
|
| 46 |
+
IMG_FMT = os.environ.get("IMG_FMT", "jpeg")
|
| 47 |
+
IMG_MAX_PX = int(os.environ.get("IMG_MAX_PX", 896 * 896))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def data_url(im, fmt=None, max_px=None):
|
| 51 |
+
fmt = fmt or IMG_FMT
|
| 52 |
+
max_px = IMG_MAX_PX if max_px is None else max_px
|
| 53 |
+
im = im.convert("RGB")
|
| 54 |
+
if max_px and im.width * im.height > max_px:
|
| 55 |
+
s = (max_px / (im.width * im.height)) ** 0.5
|
| 56 |
+
im = im.resize((max(1, round(im.width * s)), max(1, round(im.height * s))), Image.BICUBIC)
|
| 57 |
+
b = io.BytesIO()
|
| 58 |
+
if fmt == "jpeg":
|
| 59 |
+
im.save(b, "JPEG", quality=90)
|
| 60 |
+
return "data:image/jpeg;base64," + base64.b64encode(b.getvalue()).decode()
|
| 61 |
+
im.save(b, "PNG")
|
| 62 |
+
return "data:image/png;base64," + base64.b64encode(b.getvalue()).decode()
|
slotread/labels.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Slot labels: the placeholder token, the digit labels, and the bin labels each arm uses.
|
| 2 |
+
|
| 3 |
+
An arm decides how every number is written:
|
| 4 |
+
|
| 5 |
+
arm coarse slot bins bin labels
|
| 6 |
+
A yes 256 ordinary words
|
| 7 |
+
F yes 256 bytes
|
| 8 |
+
E no 256 bytes (the blog's main arm)
|
| 9 |
+
Bplus no 101 single characters (GPC-1's grid)
|
| 10 |
+
C no 256 ordinary words (never learns without a coarse slot)
|
| 11 |
+
Bplusw no 101 ordinary words
|
| 12 |
+
|
| 13 |
+
"Bytes" are ASCII characters followed by two-letter tokens (`ab` ... `zz`); the first 101 are exactly
|
| 14 |
+
B+'s single-character labels. Every label is one token right after "x: ".
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
|
| 19 |
+
PLACEHOLDER = " ___"
|
| 20 |
+
# Gemma 4's ids. configure_tokenizer() replaces them for another model's tokenizer.
|
| 21 |
+
PH = 52551 # " ___"
|
| 22 |
+
DIGITS = [236771, 236770, 236778, 236800, 236812, 236810, 236825, 236832, 236828, 236819] # "0".."9"
|
| 23 |
+
|
| 24 |
+
ARMS = {"A": (True, 256, "words"), "F": (True, 256, "bytes"), "E": (False, 256, "bytes"),
|
| 25 |
+
"Bplus": (False, 101, "bytes"), "C": (False, 256, "words"), "Bplusw": (False, 101, "words")}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class Arm:
|
| 30 |
+
name: str
|
| 31 |
+
coarse: bool
|
| 32 |
+
bins: int
|
| 33 |
+
tokens: str
|
| 34 |
+
fine: list = field(default_factory=list) # label ids of every fine (number) slot
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def configure_tokenizer(tok):
|
| 38 |
+
"""Derive the placeholder and digit ids from a non-Gemma tokenizer (e.g. GUI-Owl / Qwen3-VL):
|
| 39 |
+
the placeholder is the last token of "x: ___", each digit the last token of "x: <d>"."""
|
| 40 |
+
global PH, DIGITS
|
| 41 |
+
PH = tok("x: ___", add_special_tokens=False)["input_ids"][-1]
|
| 42 |
+
assert tok.decode([PH]) == PLACEHOLDER, f"placeholder is not one token: {tok.decode([PH])!r}"
|
| 43 |
+
DIGITS = [tok(f"x: {d}", add_special_tokens=False)["input_ids"][-1] for d in "0123456789"]
|
| 44 |
+
assert len(set(DIGITS)) == 10 and [tok.decode([i]).strip() for i in DIGITS] == list("0123456789")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def byte_labels(tok, n=256):
|
| 48 |
+
"""ASCII characters, then two-letter tokens, each a single token after "x: " (GPC-1-style)."""
|
| 49 |
+
ids, seen = [], set()
|
| 50 |
+
ctx = tok("x: ___", add_special_tokens=False)["input_ids"][:-1]
|
| 51 |
+
codes = [chr(c) for c in range(33, 127)] + [f"{chr(a)}{chr(b)}" for a in range(97, 123) for b in range(97, 123)]
|
| 52 |
+
for code in codes:
|
| 53 |
+
t = tok("x: " + code, add_special_tokens=False)["input_ids"]
|
| 54 |
+
if len(t) == len(ctx) + 1 and t[-1] not in seen:
|
| 55 |
+
seen.add(t[-1])
|
| 56 |
+
ids.append(t[-1])
|
| 57 |
+
if len(ids) >= n:
|
| 58 |
+
break
|
| 59 |
+
return ids
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def word_labels(tok, n=256):
|
| 63 |
+
"""n distinct ordinary lowercase words (3-7 letters), each one token after "x: ", spread evenly
|
| 64 |
+
over the vocabulary."""
|
| 65 |
+
ctx = tok("x: ___", add_special_tokens=False)["input_ids"][:-1]
|
| 66 |
+
cands = []
|
| 67 |
+
for t, i in sorted(tok.get_vocab().items(), key=lambda kv: kv[1]):
|
| 68 |
+
w = t[1:]
|
| 69 |
+
if 3000 < i < 60000 and t.startswith("▁") and w.isascii() and w.isalpha() and w.islower() and 3 <= len(w) <= 7:
|
| 70 |
+
if tok("x: " + w, add_special_tokens=False)["input_ids"] == ctx + [i]:
|
| 71 |
+
cands.append(i)
|
| 72 |
+
step = max(1, len(cands) // n)
|
| 73 |
+
return [cands[k * step] for k in range(n)]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def make_arm(name, tok):
|
| 77 |
+
coarse, bins, tokens = ARMS[name]
|
| 78 |
+
ids = word_labels(tok, bins) if tokens == "words" else byte_labels(tok, bins)[:bins]
|
| 79 |
+
assert len(set(ids)) == bins, "bin labels are not distinct"
|
| 80 |
+
if coarse:
|
| 81 |
+
assert not set(ids) & set(DIGITS), "a bin label is also a digit label"
|
| 82 |
+
return Arm(name, coarse, bins, tokens, list(ids))
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def word_label_ids(tok, prefix, words):
|
| 86 |
+
"""The label id of each word as the next token after "<prefix>: " (categorical slots)."""
|
| 87 |
+
return [tok(f"{prefix}: {w}", add_special_tokens=False)["input_ids"][-1] for w in words]
|
slotread/local.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reading slots in-process with transformers: no server. One forward pass computes every slot of a layout
|
| 2 |
+
(only the logits at the slot positions, logits_to_keep), exactly as training reads them. Used for the merged
|
| 3 |
+
models and the Hugging Face Space (ZeroGPU runs PyTorch, not vLLM).
|
| 4 |
+
|
| 5 |
+
from slotread.local import LocalSlotModel
|
| 6 |
+
m = LocalSlotModel.from_pretrained("infinitylogesh/GUI-Decisions-31B") # merged model
|
| 7 |
+
m = LocalSlotModel.from_pretrained("google/gemma-4-31B-it", adapter="infinitylogesh/GUI-Decisions-31B-LoRA")
|
| 8 |
+
pred = m.decide(Image.open("shot.png"), "open the settings") # {'action': 'click', 'x': ..., 'y': ...}
|
| 9 |
+
|
| 10 |
+
The slot template and label ids come from slotread_config.json in the model (or adapter) repo: the
|
| 11 |
+
config.json that slotread.train writes, renamed so it does not collide with the model's own config.json.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import json, os, time
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
|
| 18 |
+
from . import labels as L
|
| 19 |
+
from .client import agent_user
|
| 20 |
+
from .images import cap_image
|
| 21 |
+
from .labels import Arm
|
| 22 |
+
from .tasks import AgentTask, BoxTask, SegTask
|
| 23 |
+
|
| 24 |
+
CONFIG_NAME = "slotread_config.json"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _config(repo_or_dir):
|
| 28 |
+
if os.path.isdir(repo_or_dir):
|
| 29 |
+
for name in (CONFIG_NAME, "config.json"):
|
| 30 |
+
p = os.path.join(repo_or_dir, name)
|
| 31 |
+
if os.path.exists(p) and "tasks" in json.load(open(p)):
|
| 32 |
+
return json.load(open(p))
|
| 33 |
+
raise FileNotFoundError(f"no {CONFIG_NAME} in {repo_or_dir}")
|
| 34 |
+
from huggingface_hub import hf_hub_download
|
| 35 |
+
return json.load(open(hf_hub_download(repo_or_dir, CONFIG_NAME)))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class LocalSlotModel:
|
| 39 |
+
def __init__(self, model, processor, cfg):
|
| 40 |
+
self.model, self.proc, self.cfg = model, processor, cfg
|
| 41 |
+
self.arm = Arm(cfg["arm"], cfg["coarse"], cfg["bins"], cfg["tokens"], cfg["fine"])
|
| 42 |
+
self.tasks = {"agent": AgentTask(), "box": BoxTask(), "seg": SegTask(cfg.get("rays", 24))}
|
| 43 |
+
L.PH = cfg["placeholder"] # the placeholder id of this model's tokenizer
|
| 44 |
+
|
| 45 |
+
@classmethod
|
| 46 |
+
def from_pretrained(cls, model_id, adapter=None, processor=None, config=None, dtype=torch.bfloat16, device_map=None):
|
| 47 |
+
"""config: a slotread config file; default: slotread_config.json in the adapter or model repo."""
|
| 48 |
+
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 49 |
+
model = AutoModelForImageTextToText.from_pretrained(model_id, dtype=dtype, device_map=device_map or {"": 0},
|
| 50 |
+
attn_implementation="sdpa")
|
| 51 |
+
if adapter:
|
| 52 |
+
from peft import PeftModel
|
| 53 |
+
model = PeftModel.from_pretrained(model, adapter)
|
| 54 |
+
model.eval()
|
| 55 |
+
cfg = json.load(open(config)) if config else _config(adapter or model_id)
|
| 56 |
+
return cls(model, AutoProcessor.from_pretrained(processor or model_id), cfg)
|
| 57 |
+
|
| 58 |
+
@torch.no_grad()
|
| 59 |
+
def read(self, task, image, user):
|
| 60 |
+
"""{slot: log-probabilities over its labels} for the task's whole layout, one forward pass;
|
| 61 |
+
also the forward's milliseconds."""
|
| 62 |
+
t = self.tasks[task]
|
| 63 |
+
c = self.cfg["tasks"][task]
|
| 64 |
+
image = cap_image(image.convert("RGB"), self.cfg.get("image_cap") or 0) # as trained (GUI-Owl: 1 MP)
|
| 65 |
+
ex = {"agent": dict(image=image, user=user), "box": dict(image=image, instruction=user),
|
| 66 |
+
"seg": dict(image=image, sentence=user)}[task]
|
| 67 |
+
msgs = [{"role": "system", "content": [{"type": "text", "text": c["system"]}]},
|
| 68 |
+
{"role": "user", "content": [{"type": "image", "image": image}, {"type": "text", "text": t.user(ex)}]},
|
| 69 |
+
{"role": "assistant", "content": [{"type": "text", "text": " ".join(f"{s}: ___" for s in c["slots"])}]}]
|
| 70 |
+
enc = self.proc.apply_chat_template(msgs, tokenize=True, return_dict=True, return_tensors="pt",
|
| 71 |
+
add_generation_prompt=False, continue_final_message=True, enable_thinking=False)
|
| 72 |
+
ids = enc["input_ids"][0]
|
| 73 |
+
keep = (ids == self.cfg["placeholder"]).nonzero().flatten()[-len(c["slots"]):] - 1
|
| 74 |
+
dev = next(self.model.parameters()).device
|
| 75 |
+
if dev.type == "cuda":
|
| 76 |
+
torch.cuda.synchronize()
|
| 77 |
+
t0 = time.perf_counter()
|
| 78 |
+
out = self.model(**{k: v.to(dev) for k, v in enc.items()}, logits_to_keep=keep.to(dev), use_cache=False)
|
| 79 |
+
lv = torch.log_softmax(out.logits[0].float(), -1)
|
| 80 |
+
if dev.type == "cuda":
|
| 81 |
+
torch.cuda.synchronize()
|
| 82 |
+
ms = (time.perf_counter() - t0) * 1000
|
| 83 |
+
return {s: lv[i, c["labels"][s]].cpu() for i, s in enumerate(c["slots"])}, ms
|
| 84 |
+
|
| 85 |
+
def decide(self, image, instruction, previous="None"):
|
| 86 |
+
"""A computer-use step from one forward pass: action and point, plus the swipe end, key and scroll
|
| 87 |
+
slots (meaningful only for their actions); `ms` is the forward pass."""
|
| 88 |
+
from .data.aguvis import KEYS, SDIR
|
| 89 |
+
lp, ms = self.read("agent", image, agent_user(instruction, previous))
|
| 90 |
+
d = self.tasks["agent"].decode(lp, self.arm)
|
| 91 |
+
d.update(key=KEYS[int(lp["key"].argmax())], sdir=SDIR[int(lp["sdir"].argmax())], samt=int(lp["samt"].argmax()))
|
| 92 |
+
return dict(d, ms=round(ms, 1))
|
| 93 |
+
|
| 94 |
+
def box(self, image, description):
|
| 95 |
+
lp, ms = self.read("box", image, description)
|
| 96 |
+
return dict(box=self.tasks["box"].decode(lp, self.arm), ms=round(ms, 1))
|
| 97 |
+
|
| 98 |
+
@torch.no_grad()
|
| 99 |
+
def write_text(self, image, instruction, action, previous="None", max_new_tokens=24):
|
| 100 |
+
"""The text a type / answer / open step needs, written by the base model: the adapter is switched
|
| 101 |
+
off, so this needs base + adapter (a merged model cannot switch it off)."""
|
| 102 |
+
from .agent import TEXT_ASK
|
| 103 |
+
if not hasattr(self.model, "disable_adapter"):
|
| 104 |
+
raise RuntimeError("text steps need the base model: load with adapter=...")
|
| 105 |
+
image = cap_image(image.convert("RGB"), self.cfg.get("image_cap") or 0)
|
| 106 |
+
msgs = [{"role": "user", "content": [{"type": "image", "image": image},
|
| 107 |
+
{"type": "text", "text": agent_user(instruction, previous) + "\n\n" + TEXT_ASK[action]}]}]
|
| 108 |
+
enc = self.proc.apply_chat_template(msgs, tokenize=True, return_dict=True, return_tensors="pt",
|
| 109 |
+
add_generation_prompt=True, enable_thinking=False)
|
| 110 |
+
dev = next(self.model.parameters()).device
|
| 111 |
+
enc = {k: v.to(dev) for k, v in enc.items()}
|
| 112 |
+
with self.model.disable_adapter():
|
| 113 |
+
out = self.model.generate(**enc, max_new_tokens=max_new_tokens, do_sample=False)
|
| 114 |
+
t = self.proc.decode(out[0, enc["input_ids"].shape[1]:], skip_special_tokens=True)
|
| 115 |
+
return t.strip().strip('"').split("\n")[0][:120]
|
| 116 |
+
|
| 117 |
+
@torch.no_grad()
|
| 118 |
+
def vanilla_decide(self, image, instruction, previous="None", max_new_tokens=96):
|
| 119 |
+
"""The base model (adapter off) writing the step as JSON in Gemma 4's native [y, x] format, as in
|
| 120 |
+
the post's comparisons: (action, point, args, text, raw output, ms for the whole generation)."""
|
| 121 |
+
from .agent import json_system, parse_step
|
| 122 |
+
if not hasattr(self.model, "disable_adapter"):
|
| 123 |
+
raise RuntimeError("the vanilla comparison needs the base model: load with adapter=...")
|
| 124 |
+
image = cap_image(image.convert("RGB"), self.cfg.get("image_cap") or 0)
|
| 125 |
+
msgs = [{"role": "system", "content": [{"type": "text", "text": json_system(True)}]},
|
| 126 |
+
{"role": "user", "content": [{"type": "image", "image": image},
|
| 127 |
+
{"type": "text", "text": agent_user(instruction, previous)}]}]
|
| 128 |
+
enc = self.proc.apply_chat_template(msgs, tokenize=True, return_dict=True, return_tensors="pt",
|
| 129 |
+
add_generation_prompt=True, enable_thinking=False)
|
| 130 |
+
dev = next(self.model.parameters()).device
|
| 131 |
+
enc = {k: v.to(dev) for k, v in enc.items()}
|
| 132 |
+
torch.cuda.synchronize()
|
| 133 |
+
t0 = time.perf_counter()
|
| 134 |
+
with self.model.disable_adapter():
|
| 135 |
+
out = self.model.generate(**enc, max_new_tokens=max_new_tokens, do_sample=False)
|
| 136 |
+
torch.cuda.synchronize()
|
| 137 |
+
ms = (time.perf_counter() - t0) * 1000
|
| 138 |
+
txt = self.proc.decode(out[0, enc["input_ids"].shape[1]:], skip_special_tokens=True)
|
| 139 |
+
return parse_step(txt, True) + (txt, round(ms, 1))
|
slotread/numbers.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Numbers as bins: soft ordinal targets for training, and readouts from a slot's label log-probs.
|
| 2 |
+
|
| 3 |
+
A number lives in [0, 1]. Positions are linear; sizes and radii are log-warped first, because UI
|
| 4 |
+
elements and objects span three orders of magnitude and a uniform grid would spend its resolution
|
| 5 |
+
where nothing lives.
|
| 6 |
+
|
| 7 |
+
A number is one fine slot over `arm.bins` labels, plus (coarse arms only) a coarse slot over the ten
|
| 8 |
+
digits: the tenth of the range the number falls in.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import math
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
|
| 15 |
+
LOG_MIN = 1 / 512 # the smallest size / radius a log-warped number can say, as a fraction
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def warp(v, kind):
|
| 19 |
+
if kind == "lin":
|
| 20 |
+
return min(max(v, 0.0), 1.0)
|
| 21 |
+
return min(max(math.log(max(v, LOG_MIN) / LOG_MIN) / math.log(1 / LOG_MIN), 0.0), 1.0)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def unwarp(u, kind):
|
| 25 |
+
return u if kind == "lin" else LOG_MIN * (1 / LOG_MIN) ** u
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def slots_for(number, arm):
|
| 29 |
+
"""The slots that write one number: [x] or, in a coarse arm, [xc, x]."""
|
| 30 |
+
return [number + "c", number] if arm.coarse else [number]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def centers(n):
|
| 34 |
+
return (torch.arange(n) + 0.5) / n
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def gauss(c, mu, sigma):
|
| 38 |
+
"""A normalized Gaussian over bin centres c: the soft ordinal target (one bin off costs little)."""
|
| 39 |
+
w = torch.exp(-((c - mu) ** 2) / (2 * sigma ** 2))
|
| 40 |
+
return w / w.sum()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def number_targets(number, u, sigma, arm):
|
| 44 |
+
"""Soft targets for one number at warped value u; sigma in warped units, floored at a bin."""
|
| 45 |
+
out = {number: gauss(centers(arm.bins), u, max(0.6 / arm.bins, sigma))}
|
| 46 |
+
if arm.coarse:
|
| 47 |
+
out[number + "c"] = gauss(centers(10), u, max(0.6 / 10, sigma))
|
| 48 |
+
return out
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def local_peak(p):
|
| 52 |
+
"""The argmax bin refined by the probability-weighted mean of its +-2 neighbours, in [0, 1]."""
|
| 53 |
+
p = p.detach().float().cpu()
|
| 54 |
+
k, n = int(p.argmax()), len(p)
|
| 55 |
+
lo, hi = max(0, k - 2), min(n, k + 3)
|
| 56 |
+
w = p[lo:hi]
|
| 57 |
+
return float(((torch.arange(lo, hi, dtype=torch.float32) + 0.5) * w).sum() / w.sum()) / n
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def read_number(lp, number, arm, how="default"):
|
| 61 |
+
"""A number's warped value from its slots' label log-probabilities.
|
| 62 |
+
|
| 63 |
+
peak the argmax bin
|
| 64 |
+
local2 the peak refined by +-2 bins (the default without a coarse slot)
|
| 65 |
+
fused the fine distribution times the coarse slot's probability for the tenth each bin falls
|
| 66 |
+
in, then local2 (the default with a coarse slot)
|
| 67 |
+
expected the mean over all bins
|
| 68 |
+
"""
|
| 69 |
+
p = torch.softmax(lp[number].detach().float().cpu(), -1)
|
| 70 |
+
c = centers(len(p))
|
| 71 |
+
if how == "default":
|
| 72 |
+
how = "fused" if arm.coarse else "local2"
|
| 73 |
+
if how == "peak":
|
| 74 |
+
return float(c[int(p.argmax())])
|
| 75 |
+
if how == "expected":
|
| 76 |
+
return float((c * p).sum())
|
| 77 |
+
if how == "fused" and number + "c" in lp:
|
| 78 |
+
prior = torch.softmax(lp[number + "c"].detach().float().cpu(), -1)[(c * 10).long().clamp(max=9)]
|
| 79 |
+
p = p * prior
|
| 80 |
+
return local_peak(p)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def one_hot(n, i):
|
| 84 |
+
t = torch.zeros(n)
|
| 85 |
+
t[i] = 1.0
|
| 86 |
+
return t
|
slotread/tasks.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tasks: what the model sees and answers. A task gives its examples, its slot layout for an arm,
|
| 2 |
+
the soft targets, and how a read turns back into a prediction and a score.
|
| 3 |
+
|
| 4 |
+
agent computer use: action + point (+ swipe end, key, scroll), AGUVIS steps + Wave-UI clicks
|
| 5 |
+
box an element's or object's box as centre + log size, Wave-UI elements + RefCOCO referents
|
| 6 |
+
seg a referred object's mask as a centre + K ray lengths, RefCOCO
|
| 7 |
+
|
| 8 |
+
The assistant turn is the slot layout, each slot ending in the placeholder:
|
| 9 |
+
|
| 10 |
+
action: ___ x: ___ y: ___ x2: ___ y2: ___ key: ___ sdir: ___ samt: ___
|
| 11 |
+
|
| 12 |
+
and a slot's answer is the next-token distribution at the token before its placeholder.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import math
|
| 16 |
+
|
| 17 |
+
from . import labels as L
|
| 18 |
+
from .data import aguvis, refcoco, waveui
|
| 19 |
+
from .geometry import box_iou, mask_iou, poly_mask, radial_decode, radial_encode
|
| 20 |
+
from .images import image_of, image_size
|
| 21 |
+
from .numbers import LOG_MIN, number_targets, one_hot, read_number, slots_for, unwarp, warp
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def coarse_desc(arm, what):
|
| 25 |
+
"""How a number's slots are described in a system prompt."""
|
| 26 |
+
return f"the tens digit (00-99) and then the fine code of {what}" if arm.coarse else f"the code of {what}"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class Task:
|
| 30 |
+
name = ""
|
| 31 |
+
numbers = {} # number -> "lin" | "log"
|
| 32 |
+
|
| 33 |
+
def layout(self, arm):
|
| 34 |
+
raise NotImplementedError
|
| 35 |
+
|
| 36 |
+
def system(self, arm):
|
| 37 |
+
raise NotImplementedError
|
| 38 |
+
|
| 39 |
+
def user(self, ex):
|
| 40 |
+
raise NotImplementedError
|
| 41 |
+
|
| 42 |
+
def number_of(self, slot):
|
| 43 |
+
return slot[:-1] if slot.endswith("c") and slot[:-1] in self.numbers else slot
|
| 44 |
+
|
| 45 |
+
def messages(self, ex, arm, assistant=None):
|
| 46 |
+
return [{"role": "system", "content": [{"type": "text", "text": self.system(arm)}]},
|
| 47 |
+
{"role": "user", "content": [{"type": "image", "image": image_of(ex)},
|
| 48 |
+
{"type": "text", "text": self.user(ex)}]},
|
| 49 |
+
{"role": "assistant", "content": [{"type": "text", "text": assistant if assistant is not None
|
| 50 |
+
else " ".join(f"{s}: ___" for s in self.layout(arm))}]}]
|
| 51 |
+
|
| 52 |
+
def labels(self, arm, tok):
|
| 53 |
+
out = {}
|
| 54 |
+
for s in self.layout(arm):
|
| 55 |
+
n = self.number_of(s)
|
| 56 |
+
out[s] = L.DIGITS if (n in self.numbers and s.endswith("c") and n != s) else (
|
| 57 |
+
arm.fine if n in self.numbers else self.categorical_labels(s, tok))
|
| 58 |
+
return out
|
| 59 |
+
|
| 60 |
+
def categorical_labels(self, slot, tok):
|
| 61 |
+
raise KeyError(slot)
|
| 62 |
+
|
| 63 |
+
def config(self, arm, tok):
|
| 64 |
+
"""Everything a reader needs to rebuild this task's prompt and labels (saved in config.json)."""
|
| 65 |
+
return dict(slots=self.layout(arm), system=self.system(arm), numbers=self.numbers,
|
| 66 |
+
labels=self.labels(arm, tok))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class AgentTask(Task):
|
| 70 |
+
"""Computer use. Only the slots an action uses are trained on that example (MASK), so x2 is free
|
| 71 |
+
to mean nothing on a click. Text arguments (what to type, the final answer) are not slots: the base
|
| 72 |
+
model writes them."""
|
| 73 |
+
name = "agent"
|
| 74 |
+
numbers = {"x": "lin", "y": "lin", "x2": "lin", "y2": "lin"}
|
| 75 |
+
MASK = {"click": ["x", "y"], "select": ["x", "y"], "swipe": ["x", "y", "x2", "y2"],
|
| 76 |
+
"scroll": ["sdir", "samt"], "press": ["key"]}
|
| 77 |
+
|
| 78 |
+
def layout(self, arm):
|
| 79 |
+
return (["action"] + slots_for("x", arm) + slots_for("y", arm) + slots_for("x2", arm)
|
| 80 |
+
+ slots_for("y2", arm) + ["key", "sdir", "samt"])
|
| 81 |
+
|
| 82 |
+
def system(self, arm):
|
| 83 |
+
return ("You are a GUI agent. Given a screenshot, the task and the previous actions, answer the next step. "
|
| 84 |
+
"action: " + ", ".join(aguvis.ACTIONS) + ". "
|
| 85 |
+
f"For x, {coarse_desc(arm, 'the horizontal position of the point, from the left edge to the right edge')}; "
|
| 86 |
+
f"for y, the same vertically from the top to the bottom. x2 and y2: the end point of a swipe, written "
|
| 87 |
+
f"the same way. key: the key to press. sdir and samt: scroll direction and amount.")
|
| 88 |
+
|
| 89 |
+
def user(self, ex):
|
| 90 |
+
return ex["user"]
|
| 91 |
+
|
| 92 |
+
def categorical_labels(self, slot, tok):
|
| 93 |
+
return {"action": L.word_label_ids(tok, "action", aguvis.ACTIONS),
|
| 94 |
+
"key": L.word_label_ids(tok, "key", aguvis.KEYS),
|
| 95 |
+
"sdir": L.word_label_ids(tok, "sdir", aguvis.SDIR), "samt": L.DIGITS}[slot]
|
| 96 |
+
|
| 97 |
+
def targets(self, ex, arm):
|
| 98 |
+
a, g = ex["action"], ex["args"]
|
| 99 |
+
tg = {"action": one_hot(len(aguvis.ACTIONS), aguvis.ACTIONS.index(a))}
|
| 100 |
+
for n in self.MASK.get(a, []):
|
| 101 |
+
if n in self.numbers:
|
| 102 |
+
if g.get(n) is None:
|
| 103 |
+
continue
|
| 104 |
+
w = g.get("box" if n.startswith("x") else "boy", 0.02) # the target's width / height, if known
|
| 105 |
+
tg.update(number_targets(n, g[n], 0.25 * w, arm))
|
| 106 |
+
elif n == "key":
|
| 107 |
+
tg["key"] = one_hot(len(aguvis.KEYS), aguvis.KEYS.index(g.get("key", "other")))
|
| 108 |
+
elif n == "sdir" and g.get("sdir") in aguvis.SDIR:
|
| 109 |
+
tg["sdir"] = one_hot(len(aguvis.SDIR), aguvis.SDIR.index(g["sdir"]))
|
| 110 |
+
elif n == "samt" and g.get("samt") is not None:
|
| 111 |
+
tg["samt"] = one_hot(10, int(g["samt"]))
|
| 112 |
+
return tg
|
| 113 |
+
|
| 114 |
+
def decode(self, lp, arm, how="default"):
|
| 115 |
+
out = dict(action=aguvis.ACTIONS[int(lp["action"].argmax())],
|
| 116 |
+
x=read_number(lp, "x", arm, how), y=read_number(lp, "y", arm, how))
|
| 117 |
+
if "x2" in lp:
|
| 118 |
+
out.update(x2=read_number(lp, "x2", arm, how), y2=read_number(lp, "y2", arm, how))
|
| 119 |
+
return out
|
| 120 |
+
|
| 121 |
+
def score(self, pred, ex, thresh=0.14):
|
| 122 |
+
"""A step is right when the action is, and (for a click, select or swipe) the point is within
|
| 123 |
+
0.14 of the target on each axis."""
|
| 124 |
+
ok_a = pred["action"] == ex["action"]
|
| 125 |
+
ok_p = True
|
| 126 |
+
if ex["action"] in ("click", "select", "swipe"):
|
| 127 |
+
ok_p = abs(pred["x"] - ex["args"]["x"]) <= thresh and abs(pred["y"] - ex["args"]["y"]) <= thresh
|
| 128 |
+
return dict(action=float(ok_a), point=float(ok_p) if ex["action"] in ("click", "select", "swipe") else None,
|
| 129 |
+
step=float(ok_a and ok_p))
|
| 130 |
+
|
| 131 |
+
def examples(self, n_agent, n_click, n_val):
|
| 132 |
+
"""(train, val): AGUVIS steps + Wave-UI clicks; val is the first n_val steps of the same pool
|
| 133 |
+
(held out from this run; use aguvis.test_steps() to compare runs)."""
|
| 134 |
+
pool = aguvis.steps(n_agent + n_val)
|
| 135 |
+
val, train = pool[:n_val], pool[n_val:]
|
| 136 |
+
return train + (waveui.clicks(n_click) if n_click else []), val
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class BoxTask(Task):
|
| 140 |
+
"""An element's or object's box as centre + size; sizes log-warped."""
|
| 141 |
+
name = "box"
|
| 142 |
+
numbers = {"cx": "lin", "cy": "lin", "w": "log", "h": "log"}
|
| 143 |
+
|
| 144 |
+
def layout(self, arm):
|
| 145 |
+
return slots_for("cx", arm) + slots_for("cy", arm) + slots_for("w", arm) + slots_for("h", arm)
|
| 146 |
+
|
| 147 |
+
def system(self, arm):
|
| 148 |
+
return ("You locate an element or object in an image and give its bounding box as a centre and a size. "
|
| 149 |
+
f"For cx, {coarse_desc(arm, 'the horizontal position of the centre, from the left edge to the right edge')}; "
|
| 150 |
+
"for cy, the same vertically from the top to the bottom. "
|
| 151 |
+
f"For w, {coarse_desc(arm, 'the box width on a scale from tiny to the whole image')}; "
|
| 152 |
+
"for h, the same for the height.")
|
| 153 |
+
|
| 154 |
+
def user(self, ex):
|
| 155 |
+
return f"Element: {ex['instruction']}"
|
| 156 |
+
|
| 157 |
+
def norm_box(self, ex):
|
| 158 |
+
W, H = image_size(ex)
|
| 159 |
+
x1, y1, x2, y2 = ex["box"]
|
| 160 |
+
return [x1 / W, y1 / H, x2 / W, y2 / H] if not ex.get("norm") else list(ex["box"])
|
| 161 |
+
|
| 162 |
+
def targets(self, ex, arm):
|
| 163 |
+
x1, y1, x2, y2 = self.norm_box(ex)
|
| 164 |
+
w, h = max(x2 - x1, LOG_MIN), max(y2 - y1, LOG_MIN)
|
| 165 |
+
tg = {}
|
| 166 |
+
tg.update(number_targets("cx", (x1 + x2) / 2, 0.12 * w, arm))
|
| 167 |
+
tg.update(number_targets("cy", (y1 + y2) / 2, 0.12 * h, arm))
|
| 168 |
+
tg.update(number_targets("w", warp(w, "log"), 0.02, arm))
|
| 169 |
+
tg.update(number_targets("h", warp(h, "log"), 0.02, arm))
|
| 170 |
+
return tg
|
| 171 |
+
|
| 172 |
+
def decode(self, lp, arm, how="default"):
|
| 173 |
+
cx, cy = read_number(lp, "cx", arm, how), read_number(lp, "cy", arm, how)
|
| 174 |
+
w, h = unwarp(read_number(lp, "w", arm, how), "log"), unwarp(read_number(lp, "h", arm, how), "log")
|
| 175 |
+
return [max(0.0, cx - w / 2), max(0.0, cy - h / 2), min(1.0, cx + w / 2), min(1.0, cy + h / 2)]
|
| 176 |
+
|
| 177 |
+
def score(self, pred, ex):
|
| 178 |
+
b = self.norm_box(ex)
|
| 179 |
+
i = box_iou(pred, b)
|
| 180 |
+
cx, cy = (pred[0] + pred[2]) / 2, (pred[1] + pred[3]) / 2
|
| 181 |
+
return dict(iou=i, iou50=float(i >= 0.5), iou75=float(i >= 0.75),
|
| 182 |
+
center_in=float(b[0] <= cx <= b[2] and b[1] <= cy <= b[3]))
|
| 183 |
+
|
| 184 |
+
def examples(self, n_train, n_val, n_photo=0, seed=0):
|
| 185 |
+
"""(train, val): Wave-UI element boxes, plus n_photo RefCOCO referent boxes (general objects).
|
| 186 |
+
val: Wave-UI validation boxes, and val_photo: RefCOCO val referents (None without photos)."""
|
| 187 |
+
train = waveui.boxes(n_train)
|
| 188 |
+
val = waveui.val_boxes(n_val)
|
| 189 |
+
val_photo = None
|
| 190 |
+
if n_photo:
|
| 191 |
+
train += refcoco.photo_boxes(n_photo, "train", seed + 100)
|
| 192 |
+
val_photo = refcoco.photo_boxes(n_val, "validation", seed + 100)
|
| 193 |
+
return train, val, val_photo
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
class SegTask(Task):
|
| 197 |
+
"""A referred object's mask as a centre + K ray lengths; rays are fractions of the image diagonal,
|
| 198 |
+
log-warped."""
|
| 199 |
+
name = "seg"
|
| 200 |
+
|
| 201 |
+
def __init__(self, K=24):
|
| 202 |
+
self.K = K
|
| 203 |
+
self.numbers = {"cx": "lin", "cy": "lin", **{f"r{k}": "log" for k in range(K)}}
|
| 204 |
+
|
| 205 |
+
def layout(self, arm):
|
| 206 |
+
out = slots_for("cx", arm) + slots_for("cy", arm)
|
| 207 |
+
for k in range(self.K):
|
| 208 |
+
out += slots_for(f"r{k}", arm)
|
| 209 |
+
return out
|
| 210 |
+
|
| 211 |
+
def system(self, arm):
|
| 212 |
+
K = self.K
|
| 213 |
+
return ("You outline an object in an image. cx and cy: a point well inside the object; "
|
| 214 |
+
f"r0 to r{K - 1}: how far the object reaches from that point along {K} directions, r0 pointing right "
|
| 215 |
+
f"and the directions turning clockwise in equal steps. For cx, "
|
| 216 |
+
f"{coarse_desc(arm, 'the horizontal position of the point, from the left edge to the right edge')}; "
|
| 217 |
+
"for cy, the same vertically from the top to the bottom. For each r, "
|
| 218 |
+
f"{coarse_desc(arm, 'the distance on a scale from tiny to the whole image diagonal')}.")
|
| 219 |
+
|
| 220 |
+
def user(self, ex):
|
| 221 |
+
return f"Object: {ex['sentence']}"
|
| 222 |
+
|
| 223 |
+
def encoded(self, ex):
|
| 224 |
+
"""(cx, cy, d) in pixels, computed once per example."""
|
| 225 |
+
if "radial" not in ex:
|
| 226 |
+
W, H = ex["size"]
|
| 227 |
+
ex["radial"] = radial_encode(poly_mask(ex["polys"], W, H), self.K)
|
| 228 |
+
return ex["radial"]
|
| 229 |
+
|
| 230 |
+
def targets(self, ex, arm):
|
| 231 |
+
W, H = ex["size"]
|
| 232 |
+
cx, cy, d = self.encoded(ex)
|
| 233 |
+
bx1, by1, bx2, by2 = ex["bbox"]
|
| 234 |
+
diag = math.hypot(W, H)
|
| 235 |
+
tg = {}
|
| 236 |
+
tg.update(number_targets("cx", cx / W, 0.12 * (bx2 - bx1) / W, arm))
|
| 237 |
+
tg.update(number_targets("cy", cy / H, 0.12 * (by2 - by1) / H, arm))
|
| 238 |
+
for k in range(self.K):
|
| 239 |
+
tg.update(number_targets(f"r{k}", warp(d[k] / diag, "log"), 0.03, arm))
|
| 240 |
+
return tg
|
| 241 |
+
|
| 242 |
+
def decode(self, lp, arm, how="default"):
|
| 243 |
+
cx, cy = read_number(lp, "cx", arm, how), read_number(lp, "cy", arm, how)
|
| 244 |
+
r = [unwarp(read_number(lp, f"r{k}", arm, how), "log") for k in range(self.K)]
|
| 245 |
+
return dict(cx=cx, cy=cy, r=r)
|
| 246 |
+
|
| 247 |
+
def score(self, pred, ex):
|
| 248 |
+
W, H = ex["size"]
|
| 249 |
+
diag = math.hypot(W, H)
|
| 250 |
+
m, pts = radial_decode(pred["cx"] * W, pred["cy"] * H, [r * diag for r in pred["r"]], W, H)
|
| 251 |
+
gt = poly_mask(ex["polys"], W, H)
|
| 252 |
+
iou, inter, union = mask_iou(m, gt)
|
| 253 |
+
xs, ys = [p[0] for p in pts], [p[1] for p in pts]
|
| 254 |
+
pb = [max(0, min(xs)), max(0, min(ys)), min(W, max(xs)), min(H, max(ys))]
|
| 255 |
+
rec = box_iou([pb[0] / W, pb[1] / H, pb[2] / W, pb[3] / H],
|
| 256 |
+
[ex["bbox"][0] / W, ex["bbox"][1] / H, ex["bbox"][2] / W, ex["bbox"][3] / H])
|
| 257 |
+
return dict(iou=float(iou), inter=inter, union=union, rec50=float(rec >= 0.5))
|
| 258 |
+
|
| 259 |
+
def examples(self, n_train, n_val, seed=0):
|
| 260 |
+
train = refcoco.examples(refcoco.rows("train"), n_train, seed)
|
| 261 |
+
val = refcoco.examples(refcoco.rows("validation"), n_val, seed + 1, one_sentence=False)
|
| 262 |
+
return train, val
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
TASKS = {"agent": AgentTask, "box": BoxTask, "seg": SegTask}
|
slotread/train.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train one LoRA adapter for slot reading, jointly across tasks.
|
| 2 |
+
|
| 3 |
+
Every slot's answer is the full-vocabulary log-probability of its labels at the token before its
|
| 4 |
+
placeholder; the loss is cross-entropy against soft ordinal targets, normalized per number (a coarse
|
| 5 |
+
slot adds to its number's loss rather than diluting it). Only the logits at the slot positions are
|
| 6 |
+
computed (logits_to_keep), so a whole layout costs one forward pass.
|
| 7 |
+
|
| 8 |
+
# the blog's arms (Gemma 4 31B, all three tasks)
|
| 9 |
+
python -m slotread.train --arm E --out runs/j_E --run-name j-E
|
| 10 |
+
python -m slotread.train --arm A --out runs/j_A # also F, Bplus (and C, Bplusw)
|
| 11 |
+
# E3: general-object boxes, warm-started from E
|
| 12 |
+
python -m slotread.train --arm E --init runs/j_E/adapter --lr 1e-4 --n-agent 5000 --n-click 1000 \\
|
| 13 |
+
--n-box 4000 --n-box-photo 5000 --n-seg 6000 --out runs/j_E3
|
| 14 |
+
# a smaller model, computer use only
|
| 15 |
+
python -m slotread.train --arm E --model mPLUG/GUI-Owl-1.5-2B-Instruct --processor mPLUG/GUI-Owl-1.5-2B-Instruct \\
|
| 16 |
+
--tasks agent --n-agent 5000 --n-click 2000 --image-cap 1048576 --out runs/owl_E
|
| 17 |
+
|
| 18 |
+
Writes <out>/config.json (each task's system prompt, slot order and label ids: what a reader needs),
|
| 19 |
+
<out>/adapter and <out>/adapter-<n> checkpoints.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import argparse, json, math, os, random, time
|
| 23 |
+
from multiprocessing import Pool
|
| 24 |
+
|
| 25 |
+
import torch
|
| 26 |
+
|
| 27 |
+
from . import images
|
| 28 |
+
from . import labels as L
|
| 29 |
+
from .geometry import poly_mask, radial_encode
|
| 30 |
+
from .numbers import LOG_MIN
|
| 31 |
+
from .tasks import AgentTask, BoxTask, SegTask
|
| 32 |
+
|
| 33 |
+
LORA_TARGETS = r".*language_model.*\.(q_proj|k_proj|v_proj|o_proj|gate_proj|up_proj|down_proj)"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def encode(proc, task, ex, arm):
|
| 37 |
+
"""The chat-templated input and the positions whose logits predict each slot (the token before
|
| 38 |
+
each placeholder)."""
|
| 39 |
+
enc = proc.apply_chat_template(task.messages(ex, arm), tokenize=True, return_dict=True, return_tensors="pt",
|
| 40 |
+
add_generation_prompt=False, continue_final_message=True, enable_thinking=False)
|
| 41 |
+
ids = enc["input_ids"][0]
|
| 42 |
+
n = len(task.layout(arm))
|
| 43 |
+
pos = (ids == L.PH).nonzero().flatten()[-n:]
|
| 44 |
+
assert len(pos) == n, f"{task.name}: {len(pos)} placeholders for {n} slots"
|
| 45 |
+
return enc, pos - 1
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def read(model, enc, keep, slots, labels):
|
| 49 |
+
"""Full-vocabulary log-probabilities of each slot's labels (a loss on them also moves mass onto
|
| 50 |
+
the labels; readouts renormalize over the labels)."""
|
| 51 |
+
out = model(**{k: v.to(model.device) for k, v in enc.items()}, logits_to_keep=keep.to(model.device),
|
| 52 |
+
use_cache=False)
|
| 53 |
+
lv = torch.log_softmax(out.logits[0].float(), -1)
|
| 54 |
+
return {s: lv[i, labels[s]] for i, s in enumerate(slots)}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _radial(args):
|
| 58 |
+
polys, size, K = args
|
| 59 |
+
return radial_encode(poly_mask(polys, *size), K)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def aggregate(name, rows):
|
| 63 |
+
keys = {k for r in rows for k, v in r.items() if v is not None}
|
| 64 |
+
m = {k: sum(r[k] for r in rows if r.get(k) is not None) / max(1, sum(r.get(k) is not None for r in rows))
|
| 65 |
+
for k in keys if k not in ("inter", "union")}
|
| 66 |
+
if name == "seg":
|
| 67 |
+
m["ciou"] = sum(r["inter"] for r in rows) / max(1, sum(r["union"] for r in rows))
|
| 68 |
+
m["miou"] = m.pop("iou")
|
| 69 |
+
return m
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def main():
|
| 73 |
+
ap = argparse.ArgumentParser()
|
| 74 |
+
ap.add_argument("--arm", choices=list(L.ARMS), required=True)
|
| 75 |
+
ap.add_argument("--tasks", default="agent,box,seg")
|
| 76 |
+
ap.add_argument("--n-agent", type=int, default=7000, help="AGUVIS training steps")
|
| 77 |
+
ap.add_argument("--n-click", type=int, default=2000, help="Wave-UI clicks in the agent layout")
|
| 78 |
+
ap.add_argument("--n-box", type=int, default=6000, help="Wave-UI element boxes")
|
| 79 |
+
ap.add_argument("--n-box-photo", type=int, default=0, help="RefCOCO referent boxes (general objects)")
|
| 80 |
+
ap.add_argument("--n-seg", type=int, default=9000, help="RefCOCO masks")
|
| 81 |
+
ap.add_argument("--n-val", type=int, default=100, help="per task, for the in-run evals")
|
| 82 |
+
ap.add_argument("--rays", type=int, default=24)
|
| 83 |
+
ap.add_argument("--init", default="", help="continue this adapter (warm start) instead of a fresh LoRA")
|
| 84 |
+
ap.add_argument("--max-train", type=int, default=0, help="cap the shuffled training list (pilots)")
|
| 85 |
+
ap.add_argument("--model", default="google/gemma-4-31B-it")
|
| 86 |
+
ap.add_argument("--processor", default="nvidia/Gemma-4-31B-IT-NVFP4", help="the served model's processor (template)")
|
| 87 |
+
ap.add_argument("--image-cap", type=int, default=0, help="max pixels per image, as served; 0: as given")
|
| 88 |
+
ap.add_argument("--out", required=True)
|
| 89 |
+
ap.add_argument("--lr", type=float, default=2e-4)
|
| 90 |
+
ap.add_argument("--accum", type=int, default=8)
|
| 91 |
+
ap.add_argument("--r", type=int, default=16)
|
| 92 |
+
ap.add_argument("--eval-every", type=int, default=4000)
|
| 93 |
+
ap.add_argument("--clip", type=float, default=1.0, help="gradient-norm clip (the norm before clipping is logged)")
|
| 94 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 95 |
+
ap.add_argument("--wandb", default="", help="wandb project (needs WANDB_API_KEY); empty: stdout only")
|
| 96 |
+
ap.add_argument("--run-name", default="")
|
| 97 |
+
a = ap.parse_args()
|
| 98 |
+
os.makedirs(a.out, exist_ok=True)
|
| 99 |
+
random.seed(a.seed); torch.manual_seed(a.seed)
|
| 100 |
+
|
| 101 |
+
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 102 |
+
from peft import LoraConfig, PeftModel, get_peft_model
|
| 103 |
+
|
| 104 |
+
proc = AutoProcessor.from_pretrained(a.processor)
|
| 105 |
+
tok = proc.tokenizer
|
| 106 |
+
if "gemma" not in a.model.lower(): # placeholder and digit ids from this tokenizer
|
| 107 |
+
L.configure_tokenizer(tok)
|
| 108 |
+
images.IMAGE_CAP = a.image_cap
|
| 109 |
+
arm = L.make_arm(a.arm, tok)
|
| 110 |
+
make = {"agent": AgentTask, "box": BoxTask, "seg": lambda: SegTask(a.rays)}
|
| 111 |
+
tasks = {n: make[n]() for n in a.tasks.split(",")}
|
| 112 |
+
labels = {n: t.labels(arm, tok) for n, t in tasks.items()}
|
| 113 |
+
for n, t in tasks.items():
|
| 114 |
+
print(f"{n}: {len(t.layout(arm))} slots | {' '.join(t.layout(arm))[:160]}", flush=True)
|
| 115 |
+
json.dump(dict(arm=arm.name, coarse=arm.coarse, bins=arm.bins, tokens=arm.tokens, fine=arm.fine,
|
| 116 |
+
log_min=LOG_MIN, rays=a.rays, placeholder=L.PH, seed=a.seed, run_name=a.run_name,
|
| 117 |
+
model=a.model, image_cap=a.image_cap, init=a.init,
|
| 118 |
+
counts=dict(agent=a.n_agent, click=a.n_click, box=a.n_box, box_photo=a.n_box_photo, seg=a.n_seg),
|
| 119 |
+
tasks={n: t.config(arm, tok) for n, t in tasks.items()}),
|
| 120 |
+
open(f"{a.out}/config.json", "w"), indent=1)
|
| 121 |
+
|
| 122 |
+
t0 = time.time()
|
| 123 |
+
train, val = [], {}
|
| 124 |
+
if "agent" in tasks:
|
| 125 |
+
tr, va = tasks["agent"].examples(a.n_agent, a.n_click, 200)
|
| 126 |
+
train += [("agent", ex) for ex in tr]
|
| 127 |
+
val["agent"] = va[:a.n_val]
|
| 128 |
+
if "box" in tasks:
|
| 129 |
+
tr, va, va_photo = tasks["box"].examples(a.n_box, a.n_val, a.n_box_photo, a.seed)
|
| 130 |
+
train += [("box", ex) for ex in tr]
|
| 131 |
+
val["box"] = va
|
| 132 |
+
if va_photo:
|
| 133 |
+
val["box_photo"] = va_photo
|
| 134 |
+
if "seg" in tasks:
|
| 135 |
+
tr, va = tasks["seg"].examples(a.n_seg, a.n_val, seed=a.seed)
|
| 136 |
+
with Pool(16) as p: # the radial encodings, once, off the training loop
|
| 137 |
+
for ex, enc in zip(tr + va, p.map(_radial, [(e["polys"], e["size"], a.rays) for e in tr + va], chunksize=16)):
|
| 138 |
+
ex["radial"] = enc
|
| 139 |
+
train += [("seg", ex) for ex in tr]
|
| 140 |
+
val["seg"] = va
|
| 141 |
+
random.Random(a.seed).shuffle(train)
|
| 142 |
+
if a.max_train:
|
| 143 |
+
train = train[:a.max_train]
|
| 144 |
+
counts = {n: sum(t == n for t, _ in train) for n in tasks}
|
| 145 |
+
if a.n_box_photo:
|
| 146 |
+
counts["box_photo"] = sum(t == "box" and ex.get("source") == "refcoco" for t, ex in train)
|
| 147 |
+
print(f"data: {len(train)} train {counts}, val {({n: len(v) for n, v in val.items()})} in {time.time() - t0:.0f}s",
|
| 148 |
+
flush=True)
|
| 149 |
+
|
| 150 |
+
model = AutoModelForImageTextToText.from_pretrained(a.model, dtype=torch.bfloat16, device_map={"": 0},
|
| 151 |
+
attn_implementation="sdpa")
|
| 152 |
+
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
|
| 153 |
+
model.enable_input_require_grads()
|
| 154 |
+
if a.init:
|
| 155 |
+
model = PeftModel.from_pretrained(model, a.init, is_trainable=True)
|
| 156 |
+
else:
|
| 157 |
+
model = get_peft_model(model, LoraConfig(r=a.r, lora_alpha=2 * a.r, lora_dropout=0.05, task_type="CAUSAL_LM",
|
| 158 |
+
target_modules=LORA_TARGETS))
|
| 159 |
+
model.print_trainable_parameters()
|
| 160 |
+
params = [p for p in model.parameters() if p.requires_grad]
|
| 161 |
+
opt = torch.optim.AdamW(params, lr=a.lr, weight_decay=0.0)
|
| 162 |
+
steps = math.ceil(len(train) / a.accum)
|
| 163 |
+
sched = torch.optim.lr_scheduler.LambdaLR( # 30 warm-up steps, then cosine to zero
|
| 164 |
+
opt, lambda s: min(1, (s + 1) / 30) * 0.5 * (1 + math.cos(math.pi * min(s, steps) / steps)))
|
| 165 |
+
|
| 166 |
+
wb = None
|
| 167 |
+
if a.wandb and os.environ.get("WANDB_API_KEY"):
|
| 168 |
+
import wandb
|
| 169 |
+
wb = wandb.init(project=a.wandb, name=a.run_name or os.path.basename(a.out), dir=a.out,
|
| 170 |
+
config=dict(vars(a), bins=arm.bins, coarse=arm.coarse, tokens=arm.tokens, counts=counts))
|
| 171 |
+
elif a.wandb:
|
| 172 |
+
print("no WANDB_API_KEY: logging to stdout only", flush=True)
|
| 173 |
+
|
| 174 |
+
def evaluate(tag, step):
|
| 175 |
+
model.eval()
|
| 176 |
+
out = {}
|
| 177 |
+
with torch.no_grad():
|
| 178 |
+
for n, exs in val.items():
|
| 179 |
+
tn = "box" if n == "box_photo" else n
|
| 180 |
+
task, slots = tasks[tn], tasks[tn].layout(arm)
|
| 181 |
+
rows = []
|
| 182 |
+
for ex in exs:
|
| 183 |
+
enc, keep = encode(proc, task, ex, arm)
|
| 184 |
+
rows.append(task.score(task.decode(read(model, enc, keep, slots, labels[tn]), arm), ex))
|
| 185 |
+
out[n] = aggregate(n, rows)
|
| 186 |
+
model.train()
|
| 187 |
+
show = {"agent": ("step", "action", "point"), "box": ("iou", "iou75", "center_in"),
|
| 188 |
+
"box_photo": ("iou", "iou50", "iou75"), "seg": ("miou", "ciou", "rec50")}
|
| 189 |
+
print(f"EVAL {tag}: " + " | ".join(f"{n} " + " ".join(f"{k}={out[n][k]:.3f}" for k in show[n]
|
| 190 |
+
if out[n].get(k) is not None) for n in out), flush=True)
|
| 191 |
+
if wb:
|
| 192 |
+
wb.log({f"val/{n}/{k}": v for n, m in out.items() for k, v in m.items()}, step=step)
|
| 193 |
+
return out
|
| 194 |
+
|
| 195 |
+
evaluate("start", 0)
|
| 196 |
+
model.train()
|
| 197 |
+
t1, run, seen, norms, skipped = time.time(), {}, 0, [], 0
|
| 198 |
+
for n, ex in train:
|
| 199 |
+
task = tasks[n]
|
| 200 |
+
try:
|
| 201 |
+
enc, keep = encode(proc, task, ex, arm)
|
| 202 |
+
except (OSError, ValueError) as e: # an unreadable image: skip it rather than lose the run
|
| 203 |
+
skipped += 1
|
| 204 |
+
print(f"SKIP {n} example ({skipped} so far): {repr(e)[:160]}", flush=True)
|
| 205 |
+
enc = None
|
| 206 |
+
if enc is not None:
|
| 207 |
+
lp = read(model, enc, keep, task.layout(arm), labels[n])
|
| 208 |
+
tg = task.targets(ex, arm)
|
| 209 |
+
losses = {s: -(t.to(lp[s].device) * lp[s]).sum() for s, t in tg.items()}
|
| 210 |
+
numbers = {task.number_of(s) for s in tg}
|
| 211 |
+
loss = sum(losses.values()) / len(numbers) # per number: coarse slots add, they do not dilute
|
| 212 |
+
(loss / a.accum).backward()
|
| 213 |
+
mass = sum(float(lp[s].detach().exp().sum()) for s in tg) / len(tg) # probability on the labels
|
| 214 |
+
run[f"loss_{n}"] = 0.98 * run.get(f"loss_{n}", float(loss.detach())) + 0.02 * float(loss.detach())
|
| 215 |
+
run[f"mass_{n}"] = 0.98 * run.get(f"mass_{n}", mass) + 0.02 * mass
|
| 216 |
+
seen += 1
|
| 217 |
+
if seen % a.accum == 0 or seen == len(train):
|
| 218 |
+
gn = float(torch.nn.utils.clip_grad_norm_(params, a.clip))
|
| 219 |
+
norms.append(gn)
|
| 220 |
+
run["grad_norm"] = 0.9 * run.get("grad_norm", gn) + 0.1 * gn # before clipping
|
| 221 |
+
opt.step(); sched.step(); opt.zero_grad(set_to_none=True)
|
| 222 |
+
if seen % 50 == 0:
|
| 223 |
+
el = time.time() - t1
|
| 224 |
+
print(f"[{seen}/{len(train)}] {el / seen:.2f}s/ex eta {el / seen * (len(train) - seen) / 60:.0f}min | "
|
| 225 |
+
+ " ".join(f"{k}={v:.3f}" for k, v in sorted(run.items())), flush=True)
|
| 226 |
+
if wb:
|
| 227 |
+
wb.log({**{f"train/{k}": v for k, v in run.items()}, "train/sec_per_example": el / seen,
|
| 228 |
+
"train/lr": sched.get_last_lr()[0]}, step=seen)
|
| 229 |
+
if seen % a.eval_every == 0 and seen < len(train):
|
| 230 |
+
evaluate(f"after {seen}", seen)
|
| 231 |
+
model.save_pretrained(f"{a.out}/adapter-{seen}")
|
| 232 |
+
model.save_pretrained(f"{a.out}/adapter")
|
| 233 |
+
print(f"skipped {skipped} unreadable examples", flush=True)
|
| 234 |
+
if norms:
|
| 235 |
+
json.dump(norms, open(f"{a.out}/grad_norms.json", "w"))
|
| 236 |
+
evaluate("end", seen)
|
| 237 |
+
if wb:
|
| 238 |
+
wb.finish()
|
| 239 |
+
print("TRAIN_DONE", flush=True)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
if __name__ == "__main__":
|
| 243 |
+
main()
|