infinitylogesh commited on
Commit
d0e7c02
·
verified ·
1 Parent(s): 79ec4f5

GUI-Decisions Space

Browse files
.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 Decisions
3
- emoji: 🚀
4
- colorFrom: gray
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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: 5be429c41a46270ced8ea13212b6f02302e00158d9a1040ea336ec5630237ed3
  • Pointer size: 131 Bytes
  • Size of remote file: 110 kB
examples/coco_cow.jpg ADDED

Git LFS Details

  • SHA256: 1ef6feb0a8d8886de8dc4db6e6c379cdd8fad15111a8edc570eeda66bcc17dba
  • Pointer size: 131 Bytes
  • Size of remote file: 209 kB
examples/coco_desk.jpg ADDED

Git LFS Details

  • SHA256: 23e5ecf15fc6651e6ec95dbc096565ad3e0acaa6434b9bca8d7c6e832c1041b9
  • Pointer size: 131 Bytes
  • Size of remote file: 132 kB
examples/coco_dog.jpg ADDED

Git LFS Details

  • SHA256: 1004f177853f43360869c733685f0994ded85ad297d92a3b82809d2cd70a6669
  • Pointer size: 131 Bytes
  • Size of remote file: 231 kB
examples/coco_motorcycle.jpg ADDED

Git LFS Details

  • SHA256: b6d82fb6e1594d9c22bb2c045e2ea3611158cdaf11a3ab41ec8874198c206f8e
  • Pointer size: 131 Bytes
  • Size of remote file: 310 kB
examples/flights.png ADDED
examples/settings.png ADDED
examples/todo.png ADDED
examples/wiki_bee.png ADDED

Git LFS Details

  • SHA256: deee7675ca372df18b03536fff5cbb138cf1a344529495afe59046e29f88576f
  • Pointer size: 131 Bytes
  • Size of remote file: 371 kB
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()