File size: 11,055 Bytes
d0e7c02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8056215
d0e7c02
85ad04b
 
 
 
 
 
 
d0e7c02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85ad04b
d0e7c02
 
 
 
 
 
 
 
8056215
d0e7c02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85ad04b
d0e7c02
 
 
 
 
 
8056215
 
d0e7c02
 
 
8056215
 
 
 
d0e7c02
 
 
 
 
 
 
 
 
 
8056215
 
d0e7c02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8056215
 
 
af940d0
8056215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af940d0
8056215
 
 
 
 
 
 
d0e7c02
8056215
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
"""GUI-Decisions: computer-use decisions read from one forward pass (slot reading on Gemma 4 31B).

The adapter is loaded on the base model (not the merged model) so the base model can still write the text
a type / answer step needs, and run the vanilla comparison, with the adapter switched off.
"""
import os

import gradio as gr
import numpy as np
import spaces
import torch
from PIL import Image, ImageDraw

from slotread.client import agent_user
from slotread.data.aguvis import ACTIONS, KEYS, SDIR
from slotread.local import LocalSlotModel
from slotread.numbers import unwarp

BASE = os.environ.get("BASE", "google/gemma-4-31B-it")
ADAPTER = os.environ.get("ADAPTER", "infinitylogesh/GUI-Decisions-31B-LoRA")
SLOT, VANILLA = (255, 90, 20), (58, 32, 19)  # the Ambient theme: orange the slot read, dark brown the base model writing
MAX_PX = 3840 * 2160
WARM = set()  # functions that have run once in this process: the first call includes CUDA warm-up


def warm_note(name):
    first = name not in WARM
    WARM.add(name)
    return " The first request after a restart includes GPU warm-up; run it again for a warm time." if first else ""

m = LocalSlotModel.from_pretrained(BASE, adapter=ADAPTER, processor=ADAPTER, device_map="cpu")
m.model.to("cuda")


def fit(im):
    im = im.convert("RGB")
    if im.width * im.height > MAX_PX:
        s = (MAX_PX / (im.width * im.height)) ** 0.5
        im = im.resize((int(im.width * s), int(im.height * s)), Image.LANCZOS)
    return im


def overlay(im, px, py, color):
    """The slot distributions as a heatmap: P(x) times P(y), drawn as the colour's opacity."""
    heat = np.outer(py, px)
    heat = (heat / heat.max()) ** 0.5
    a = Image.fromarray((heat * 150).astype(np.uint8)).resize(im.size, Image.BILINEAR)
    layer = Image.new("RGBA", im.size, color + (0,))
    layer.putalpha(a)
    return Image.alpha_composite(im.convert("RGBA"), layer)


def mark(d, x, y, color, r=14):
    d.ellipse([x - r, y - r, x + r, y + r], outline=(255, 255, 255), width=6)
    d.ellipse([x - r, y - r, x + r, y + r], outline=color, width=3)
    d.line([x - 4, y, x + 4, y], fill=color, width=2)
    d.line([x, y - 4, x, y + 4], fill=color, width=2)


def probs(v):
    return torch.softmax(v.float(), -1).numpy()


def describe(a, d, text):
    if a in ("click", "select"):
        return f"{a}(x={d['x']:.3f}, y={d['y']:.3f})"
    if a == "swipe":
        return f"swipe(({d['x']:.3f}, {d['y']:.3f}) → ({d['x2']:.3f}, {d['y2']:.3f}))"
    if a == "press":
        return f"press(key={d['key']})"
    if a == "scroll":
        return f"scroll(direction={d['sdir']}, amount={d['samt']})"
    if text is not None:
        return f'{a}("{text}")'
    return f"{a}()"


@spaces.GPU(size="xlarge", duration=40)
def decide(image, instruction, previous, compare):
    if image is None or not instruction.strip():
        raise gr.Error("Add a screenshot and an instruction.")
    im = fit(image)
    user_prev = previous.strip() or "None"
    lp, ms = m.read("agent", im, agent_user(instruction, user_prev))
    d = m.tasks["agent"].decode(lp, m.arm)
    d.update(key=KEYS[int(lp["key"].argmax())], sdir=SDIR[int(lp["sdir"].argmax())], samt=int(lp["samt"].argmax()))
    a = d["action"]
    text = m.write_text(im, instruction, a, user_prev) if a in ("type", "answer", "open") else None

    out = im.convert("RGBA")
    if a in ("click", "select", "swipe"):
        out = overlay(out, probs(lp["x"]), probs(lp["y"]), SLOT)
    dr = ImageDraw.Draw(out)
    W, H = im.size
    if a in ("click", "select", "swipe"):
        mark(dr, d["x"] * W, d["y"] * H, SLOT)
    if a == "swipe":
        dr.line([d["x"] * W, d["y"] * H, d["x2"] * W, d["y2"] * H], fill=SLOT, width=4)
        mark(dr, d["x2"] * W, d["y2"] * H, SLOT, r=8)

    md = (f"### Slots: `{describe(a, d, text)}`\n\n"
          f"One forward pass: **{ms:.0f} ms** on this GPU (bf16, transformers"
          + (", plus the base model writing the text" if text is not None else "") + ")." + warm_note("decide"))
    if compare:
        va, vpt, vargs, vtext, raw, vms = m.vanilla_decide(im, instruction, user_prev)
        if vpt is not None:
            mark(dr, vpt["x"] * W, vpt["y"] * H, VANILLA)
        vd = dict(vargs, **(vpt or {"x": float("nan"), "y": float("nan")}))
        md += (f"\n\n### Base model writing JSON: `{describe(va, vd, vtext)}`\n\n"
               f"Generating its answer took **{vms:.0f} ms** (Gemma 4's native `\"point\": [y, x]` format):\n\n"
               f"```\n{raw.strip()[:400]}\n```\n\n"
               "Orange: the slot read, with its x × y distribution as the heatmap. Dark brown: the base model's point.")
    label = {k: float(p) for k, p in zip(ACTIONS, probs(lp["action"]))}
    return out.convert("RGB"), md, label


@spaces.GPU(size="xlarge", duration=20)
def box(image, description):
    if image is None or not description.strip():
        raise gr.Error("Add an image and describe the element or object.")
    im = fit(image)
    lp, ms = m.read("box", im, description)
    b = m.tasks["box"].decode(lp, m.arm)
    W, H = im.size
    out = overlay(im, probs(lp["cx"]), probs(lp["cy"]), SLOT)
    dr = ImageDraw.Draw(out)
    xy = [b[0] * W, b[1] * H, b[2] * W, b[3] * H]
    dr.rectangle(xy, outline=(255, 255, 255), width=6)
    dr.rectangle(xy, outline=SLOT, width=3)
    w = unwarp(float(np.argmax(probs(lp["w"])) + 0.5) / m.arm.bins, "log")
    md = (f"### Box: `[{', '.join(f'{v:.3f}' for v in b)}]`\n\n"
          f"Centre and log-scaled size, four slots read in one forward pass: **{ms:.0f} ms** on this GPU. "
          f"The heatmap is the centre's x × y distribution (most likely width {w:.1%} of the image)." + warm_note("box"))
    return out.convert("RGB"), md


HERE = os.path.dirname(os.path.abspath(__file__))
ex = lambda f: os.path.join(HERE, "examples", f)
rows = lambda f: [l.split("\t") for l in open(ex(f)).read().splitlines() if l.strip()]  # tab-separated examples
INTRO = open(os.path.join(HERE, "intro.html")).read()
CSS = open(os.path.join(HERE, "ambient.css")).read()
HOW = open(os.path.join(HERE, "how.md")).read()

with gr.Blocks(title="GUI-Decisions") as demo:
    gr.HTML(INTRO)
    gr.HTML('<p class="amb-note">Timings here are this Space\'s shared GPU running bf16 in plain transformers. '
            'Served on vLLM (NVFP4, one RTX PRO 6000) a step takes ~150 ms, against ~470–550 ms for the same model '
            'writing its native JSON.</p>')
    with gr.Tab("Computer use"):
        with gr.Row():
            with gr.Column():
                img = gr.Image(type="pil", label="Screenshot")
                ins = gr.Textbox(label="Instruction", placeholder="open the settings")
                prev = gr.Textbox(label="Previous actions (optional)", placeholder="1. click(...)", lines=2)
                cmp = gr.Checkbox(value=True, label="Also run the base model writing its answer as JSON (slower)")
                go = gr.Button("Decide", variant="primary")
            with gr.Column():
                out_img = gr.Image(label="Decision")
                gr.HTML('<div class="amb-legend"><span><i style="background:#ff5a14"></i>slot read · P(x) × P(y)</span>'
                        '<span><i style="background:#3a2013"></i>base model writing JSON</span></div>')
                out_md = gr.Markdown()
                out_lab = gr.Label(label="action slot", num_top_classes=4)
        go.click(decide, [img, ins, prev, cmp], [out_img, out_md, out_lab])
        gr.Examples([[ex(f), i, p.replace("\\n", "\n"), True] for f, i, p in rows("agent.tsv")], [img, ins, prev, cmp])
    with gr.Tab("Boxes"):
        with gr.Row():
            with gr.Column():
                bimg = gr.Image(type="pil", label="Image")
                bdesc = gr.Textbox(label="Element or object", placeholder="the red car on the left")
                bgo = gr.Button("Locate", variant="primary")
            with gr.Column():
                bout = gr.Image(label="Box")
                bmd = gr.Markdown()
        bgo.click(box, [bimg, bdesc], [bout, bmd])
        gr.Examples([[ex(f), t] for f, t in rows("box.tsv")], [bimg, bdesc])
    with gr.Tab("How it works"):
        gr.Markdown(HOW)

# the Ambient theme: Inter / Inter Tight / JetBrains Mono, cream canvas, dark-brown ink, orange accent (light only)
ORANGE = gr.themes.Color(c50="#fff4ee", c100="#ffe5d8", c200="#ffc9ad", c300="#ffa77d", c400="#ff7a2e", c500="#ff5a14",
                         c600="#e64a08", c700="#bf3c06", c800="#8f2e07", c900="#5e1f06", c950="#3a1304")
TAUPE = gr.themes.Color(c50="#fdf6f1", c100="#f8ece4", c200="#efe2d9", c300="#d9c6ba", c400="#9a877e", c500="#6b5850",
                       c600="#4f3d35", c700="#3a2013", c800="#2a170e", c900="#1c100b", c950="#1a110d")
LIGHT = dict(
    body_background_fill="#fdf6f1", body_text_color="#1a110d", body_text_color_subdued="#6b5850",
    background_fill_primary="#ffffff", background_fill_secondary="#fbf1ea",
    block_background_fill="#ffffff", block_border_color="rgba(58,28,12,0.11)", block_border_width="1px",
    block_label_background_fill="#fbf1ea", block_label_text_color="#6b5850", block_title_text_color="#6b5850",
    block_shadow="0 1px 2px rgba(58,28,12,0.05), 0 12px 32px -14px rgba(58,28,12,0.18)",
    border_color_primary="rgba(58,28,12,0.11)", border_color_accent="#ff5a14",
    input_background_fill="#fdf6f1", input_border_color="rgba(58,28,12,0.11)", input_border_color_focus="#ff7a2e",
    button_primary_background_fill="#ff5a14", button_primary_background_fill_hover="#ff7a2e",
    button_primary_text_color="#ffffff", button_primary_border_color="#ff5a14",
    button_secondary_background_fill="#ffffff", button_secondary_background_fill_hover="#fbf1ea",
    button_secondary_text_color="#1a110d", button_secondary_border_color="rgba(58,28,12,0.11)",
    color_accent="#ff5a14", color_accent_soft="#ffe5d8", link_text_color="#ff5a14", link_text_color_hover="#e64a08",
    checkbox_background_color_selected="#ff5a14", checkbox_border_color_selected="#ff5a14", slider_color="#ff5a14",
    loader_color="#ff5a14", code_background_fill="#f3e7df", table_even_background_fill="#ffffff",
    table_odd_background_fill="#fbf1ea", table_border_color="rgba(58,28,12,0.11)",
)
THEME = gr.themes.Base(primary_hue=ORANGE, secondary_hue=TAUPE, neutral_hue=TAUPE, radius_size=gr.themes.sizes.radius_lg,
                       font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
                       font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"])
THEME.set(block_radius="16px", **LIGHT, **{k + "_dark": v for k, v in LIGHT.items() if hasattr(THEME, k + "_dark")})
HEAD = ('<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700'
        '&family=Inter+Tight:wght@700;800&family=JetBrains+Mono:wght@400;500;600&display=swap">')
LIGHT_ONLY = "() => { document.body.classList.remove('dark'); document.documentElement.classList.remove('dark'); }"

if __name__ == "__main__":
    demo.queue().launch(theme=THEME, css=CSS, head=HEAD, js=LIGHT_ONLY)