multimodalart HF Staff commited on
Commit
df7ef19
·
verified ·
1 Parent(s): 85107ff

LBS-27B grounding demo

Browse files
.gitattributes CHANGED
@@ -33,3 +33,8 @@ 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/blender.png filter=lfs diff=lfs merge=lfs -text
37
+ examples/calc.png filter=lfs diff=lfs merge=lfs -text
38
+ examples/excel.png filter=lfs diff=lfs merge=lfs -text
39
+ examples/note_style.png filter=lfs diff=lfs merge=lfs -text
40
+ examples/quartus.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,25 @@
1
  ---
2
- title: Lbs Gui Grounding
3
- emoji: 🌍
4
- colorFrom: purple
5
- colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.30.0
8
- python_version: '3.12'
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: LBS-27B GUI Grounding
3
+ emoji: 🔍
4
+ colorFrom: yellow
5
+ colorTo: red
6
  sdk: gradio
7
  sdk_version: 6.30.0
 
8
  app_file: app.py
9
+ short_description: Agentic GUI grounding that zooms in with crops
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 1h
12
+ models:
13
+ - inclusionAI/LBS-27B
14
  ---
15
 
16
+ # LBS-27B GUI grounding
17
+
18
+ Demo of [inclusionAI/LBS-27B](https://huggingface.co/inclusionAI/LBS-27B): screenshot + instruction →
19
+ click point. The model can request up to three `crop_image` zoom-ins; the app executes them with the exact
20
+ crop geometry, prompts and coordinate mapping of the authors' evaluation client
21
+ ([UI-Venus/LBS](https://github.com/inclusionAI/UI-Venus/tree/LBS), Apache-2.0), running generation locally
22
+ with `transformers` on ZeroGPU (`xlarge`, bf16).
23
+
24
+ Example screenshots: [ScreenSpot-Pro](https://huggingface.co/datasets/likaixin/ScreenSpot-Pro) (MIT) and
25
+ [OSWorld-G](https://github.com/xlang-ai/OSWorld-G) (Apache-2.0).
app.py ADDED
@@ -0,0 +1,878 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LBS-27B — agentic GUI grounding with zoom-in crops.
2
+
3
+ Give the model a screenshot and an instruction. It looks at the full image,
4
+ then asks to crop up to three regions (`crop_image` text actions). The demo
5
+ runs each crop, sends the zoomed image back with its mapping to the original
6
+ image, and repeats until the model answers with a click point normalized to
7
+ [0,1000] on the original screenshot.
8
+
9
+ The prompts, action parsing, crop geometry and coordinate mapping come from
10
+ the authors' evaluation client: `evaluation/common/agentic_pipeline.py` in
11
+ https://github.com/inclusionAI/UI-Venus/tree/LBS (Apache-2.0). The only
12
+ change is the transport: generation runs locally with `transformers` on
13
+ ZeroGPU, not through a vLLM OpenAI endpoint.
14
+ """
15
+
16
+ import os
17
+
18
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
19
+
20
+ import spaces # noqa: E402 must precede torch
21
+
22
+ import ast # noqa: E402
23
+ import json # noqa: E402
24
+ import math # noqa: E402
25
+ import re # noqa: E402
26
+ import secrets # noqa: E402
27
+ import threading # noqa: E402
28
+ import time # noqa: E402
29
+ from io import BytesIO # noqa: E402
30
+ from pathlib import Path # noqa: E402
31
+
32
+ import gradio as gr # noqa: E402
33
+ import torch # noqa: E402
34
+ from PIL import Image, ImageDraw, ImageFont # noqa: E402
35
+ from transformers import ( # noqa: E402
36
+ AutoModelForImageTextToText,
37
+ AutoProcessor,
38
+ TextIteratorStreamer,
39
+ )
40
+
41
+ MODEL_ID = "inclusionAI/LBS-27B"
42
+ HERE = Path(__file__).parent
43
+ EX = HERE / "examples"
44
+
45
+ print(f"Loading {MODEL_ID} ...", flush=True)
46
+ processor = AutoProcessor.from_pretrained(MODEL_ID)
47
+ model = AutoModelForImageTextToText.from_pretrained(
48
+ MODEL_ID, dtype=torch.bfloat16, attn_implementation="sdpa"
49
+ ).to("cuda")
50
+ model.eval()
51
+ # The checkpoint ships `use_cache: false` (a training leftover); turn the cache on
52
+ # so decoding is usable.
53
+ model.config.use_cache = True
54
+ model.config.text_config.use_cache = True
55
+ model.generation_config.use_cache = True
56
+ print("Model loaded.", flush=True)
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Protocol constants and prompts, copied from evaluation/common/agentic_pipeline.py
60
+ # ---------------------------------------------------------------------------
61
+ CROP_UPSCALE_FACTOR = 4.0
62
+ CROP_MAX_PIXELS = 4_000_000
63
+ CROP_MAX_DIMENSION = 4096
64
+ MAX_CROP_IMAGE_CALLS = 3
65
+ MAX_CROP_IMAGE_CALLS_PER_TURN = 2
66
+ MAX_ACTION_CORRECTIONS = 2
67
+ API_IMAGE_QUALITY = 90
68
+ TRUNCATED_ASSISTANT_CONTENT_CHARS = 2_000
69
+ DEGENERATE_RESCUE_MAX_TOKENS = 512
70
+
71
+ COORDINATE_RE = re.compile(
72
+ r"\[\s*(-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*,\s*"
73
+ r"(-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*\]"
74
+ )
75
+ ACTION_BLOCK_RE = re.compile(r"<action>\s*(.*?)\s*</action>", re.DOTALL | re.IGNORECASE)
76
+
77
+ CROP_IMAGE_DESCRIPTION = (
78
+ "Crop an image that has already been supplied in this conversation. image_path "
79
+ "must be the exact original path or a path returned by an earlier crop_image call. "
80
+ "x1, y1, x2, y2 are absolute pixel coordinates in that referenced image's LOCAL "
81
+ "coordinate system, with (x1,y1) at the top-left and (x2,y2) at the bottom-right. "
82
+ "The result provides a new image path, dimensions, and the crop region mapped into "
83
+ "the ORIGINAL image. Returned crops are resized for inspection, so nested crops must "
84
+ "use the returned width and height. Crops may be nested. The final answer must always "
85
+ "use relative coordinates of the ORIGINAL image normalized to the [0,1000] range, "
86
+ "never crop-local coordinates. When writing a crop_image action, format each of x1, "
87
+ "y1, x2, and y2 with exactly two digits after the decimal point."
88
+ )
89
+
90
+ SYSTEM_PROMPT_TEMPLATE = """You are an agentic GUI grounding model.
91
+
92
+ Task instruction:
93
+ {instruction}
94
+
95
+ Original image metadata:
96
+ - image_path: {image_path}
97
+ - resolution: {width}x{height} pixels (width x height)
98
+ - origin: [0,0] at the top-left
99
+
100
+ Goal:
101
+ Output the center point of the position corresponding to the task instruction. The final
102
+ coordinate MUST be relative to the ORIGINAL image and normalized to [0.00,1000.00], where
103
+ [0.00,0.00] is the original image's top-left and [1000.00,1000.00] is its bottom-right.
104
+ Never output absolute pixels or coordinates local to a crop. Preserve sub-unit precision:
105
+ the two final coordinate values MUST each have exactly two digits after the decimal point
106
+ (for example, [512.34,76.50]). If the task is infeasible (for example, the requested UI
107
+ element or action is absent from the image), use [-1.00,-1.00].
108
+
109
+ Available local action:
110
+ - crop_image: {crop_description}
111
+
112
+ Text-action protocol:
113
+ Every turn must first emit visible reasoning inside <think>...</think>, then end with
114
+ exactly one of these choices:
115
+ - To inspect one region:
116
+ <think>brief reasoning for the crop</think>
117
+ <action>crop_image(image_path="exact path", x1=100.00, y1=200.00, x2=500.00, y2=600.00)</action>
118
+ - To inspect two independent regions in the same turn:
119
+ <think>brief reasoning for both crops</think>
120
+ <action>crop_image(image_path="exact path", x1=100.00, y1=200.00, x2=500.00, y2=600.00)</action>
121
+ <action>crop_image(image_path="exact path", x1=600.00, y1=200.00, x2=900.00, y2=600.00)</action>
122
+ - To return the final point:
123
+ <think>brief reasoning for the final point</think>
124
+ [x.xx,y.yy]
125
+
126
+ crop_image is a textual action interpreted locally after your answer. It is NOT an API
127
+ function call. Do not emit JSON, <tool_call> XML, or native tool_calls. Keep x1, y1, x2,
128
+ and y2 as four separately named numeric arguments.
129
+
130
+ Tool rules:
131
+ 1. Before returning the final coordinate, you SHOULD make at least one successful
132
+ crop_image call to inspect the target more precisely. A direct first-turn coordinate is
133
+ discouraged. Skip cropping only when the task is clearly infeasible or cropping cannot
134
+ reasonably improve localization.
135
+ 2. You may make at most {max_tool_calls} crop_image calls in total. Never exceed this
136
+ budget; use each crop purposefully and return the final coordinate as soon as the target
137
+ is localized. A single assistant turn may contain one or two crop_image actions, and
138
+ every action counts separately toward the total budget. Never emit more than two
139
+ actions in one turn or mix crop actions with a final coordinate.
140
+ 3. Tool arguments are local absolute pixels in the image_path passed to that call. Write
141
+ x1, y1, x2, and y2 with exactly two digits after the decimal point, including trailing
142
+ zeros (for example, x1=100.00). Two actions emitted in the same turn must refer only
143
+ to image paths already available before that turn; the second action cannot depend on
144
+ the result of the first action from the same turn.
145
+ 4. Each result states the crop's region both in original-image pixels and original-image
146
+ normalized [0,1000] coordinates. Use that mapping to convert any point found in a crop
147
+ back to the ORIGINAL image's normalized [0,1000] coordinate system.
148
+ 5. After crop_image actions, the following single user message contains all result
149
+ metadata and all successful crop images in action order.
150
+
151
+ Final-answer rule:
152
+ When ready, output exactly <think>...</think> followed by a newline and [x.xx,y.yy], with
153
+ no prose, Markdown, or action wrapper after the coordinate. Both values must contain
154
+ exactly two digits after the decimal point, including trailing zeros. The point must be in
155
+ the ORIGINAL image's normalized [0,1000] coordinate system. If the task is infeasible, use
156
+ [-1.00,-1.00].
157
+ """
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Parsing (point answer mode of the reference parser)
162
+ # ---------------------------------------------------------------------------
163
+ def _detect_degenerate(text, *, max_unit=64, min_reps=10):
164
+ if not text:
165
+ return None
166
+ n = len(text)
167
+ for unit in range(1, min(max_unit, n // min_reps) + 1):
168
+ if n % unit == 0 and text[:unit] * (n // unit) == text:
169
+ return {"unit_chars": unit, "reps": n // unit}
170
+ return None
171
+
172
+
173
+ def _coordinate_after_think(text):
174
+ value = text or ""
175
+ closes = list(re.finditer(r"</think\s*>", value, re.IGNORECASE))
176
+ if closes:
177
+ matches = list(COORDINATE_RE.finditer(value[closes[-1].end():]))
178
+ if matches:
179
+ return matches[-1]
180
+ return None
181
+
182
+
183
+ def _final_coordinate_match(text):
184
+ after = _coordinate_after_think(text)
185
+ if after:
186
+ return after
187
+ matches = list(COORDINATE_RE.finditer(text or ""))
188
+ return matches[-1] if matches else None
189
+
190
+
191
+ def _extract_call_source(text):
192
+ match = re.search(r"\b(?:crop_image|crop|click|finished)\s*\(", text, re.IGNORECASE)
193
+ if not match:
194
+ return None
195
+ start = match.start()
196
+ open_index = text.find("(", start)
197
+ depth, quote, escaped = 0, None, False
198
+ for index in range(open_index, len(text)):
199
+ char = text[index]
200
+ if quote is not None:
201
+ if escaped:
202
+ escaped = False
203
+ elif char == "\\":
204
+ escaped = True
205
+ elif char == quote:
206
+ quote = None
207
+ continue
208
+ if char in {"'", '"'}:
209
+ quote = char
210
+ elif char == "(":
211
+ depth += 1
212
+ elif char == ")":
213
+ depth -= 1
214
+ if depth == 0:
215
+ return text[start: index + 1]
216
+ return None
217
+
218
+
219
+ def _literal_value(node):
220
+ if isinstance(node, ast.Constant) and node.value is Ellipsis:
221
+ return "..."
222
+ try:
223
+ return ast.literal_eval(node)
224
+ except (ValueError, TypeError, SyntaxError) as exc:
225
+ raise ValueError("Action arguments must be Python literals") from exc
226
+
227
+
228
+ def _parse_function_action(source):
229
+ try:
230
+ expression = ast.parse(source.strip(), mode="eval").body
231
+ except SyntaxError as exc:
232
+ raise ValueError(f"Invalid action syntax: {source}") from exc
233
+ if not isinstance(expression, ast.Call) or not isinstance(expression.func, ast.Name):
234
+ raise ValueError("Expected a function-style action")
235
+ if any(k.arg is None for k in expression.keywords):
236
+ raise ValueError("Action does not support **kwargs")
237
+ arguments = {str(k.arg): _literal_value(k.value) for k in expression.keywords}
238
+ positional = [_literal_value(item) for item in expression.args]
239
+ name = expression.func.id
240
+ if positional:
241
+ keys = ("image_path", "x1", "y1", "x2", "y2") if name.lower() in {"crop", "crop_image"} else ("box",)
242
+ if len(positional) > len(keys):
243
+ raise ValueError("Too many positional action arguments")
244
+ for key, value in zip(keys, positional):
245
+ arguments.setdefault(key, value)
246
+ return name, arguments
247
+
248
+
249
+ def _repair_malformed_crop_call(source):
250
+ if not re.match(r"\s*(?:crop_image|crop)\s*\(", source, re.IGNORECASE):
251
+ return None
252
+ path_match = re.search(r"image_path\s*=\s*(['\"])(.*?)\1", source, re.DOTALL | re.IGNORECASE)
253
+ number = r"(-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)"
254
+ pair_match = re.search(
255
+ rf"x1\s*=\s*{number}\s*,\s*{number}\s*,\s*x2\s*=\s*{number}\s*,\s*{number}",
256
+ source, re.IGNORECASE,
257
+ )
258
+ if not pair_match:
259
+ return None
260
+ arguments = dict(zip(("x1", "y1", "x2", "y2"), [float(v) for v in pair_match.groups()]))
261
+ if path_match:
262
+ arguments["image_path"] = path_match.group(2)
263
+ return arguments
264
+
265
+
266
+ def _normalize_crop_arguments(arguments):
267
+ normalized = dict(arguments)
268
+ box = normalized.pop("box", None)
269
+ if box is not None and not all(k in normalized for k in ("x1", "y1", "x2", "y2")):
270
+ if not isinstance(box, (list, tuple)) or len(box) != 4:
271
+ raise ValueError("crop_image box must contain x1, y1, x2, y2")
272
+ normalized.update(dict(zip(("x1", "y1", "x2", "y2"), box)))
273
+ first, second = normalized.get("x1"), normalized.get("x2")
274
+ if "y1" not in normalized and isinstance(first, (list, tuple)) and len(first) == 2:
275
+ normalized["x1"], normalized["y1"] = first
276
+ if "y2" not in normalized and isinstance(second, (list, tuple)) and len(second) == 2:
277
+ normalized["x2"], normalized["y2"] = second
278
+ return normalized
279
+
280
+
281
+ def _parse_single_text_action(text):
282
+ value = text or ""
283
+ after = _coordinate_after_think(value)
284
+ if after:
285
+ return {"type": "final", "point": [float(after.group(1)), float(after.group(2))]}
286
+ trailing = _final_coordinate_match(value)
287
+ if trailing and not value[trailing.end():].strip():
288
+ return {"type": "final", "point": [float(trailing.group(1)), float(trailing.group(2))]}
289
+
290
+ blocks = ACTION_BLOCK_RE.findall(value)
291
+ candidate = blocks[-1].strip() if blocks else value.strip()
292
+ call_source = _extract_call_source(candidate)
293
+ if call_source:
294
+ try:
295
+ name, arguments = _parse_function_action(call_source)
296
+ except ValueError:
297
+ repaired = _repair_malformed_crop_call(call_source)
298
+ name = "crop_image" if repaired else ""
299
+ arguments = repaired or {}
300
+ lowered = name.lower()
301
+ if lowered in {"crop", "crop_image"}:
302
+ try:
303
+ return {"type": "crop", "arguments": _normalize_crop_arguments(arguments),
304
+ "source": call_source}
305
+ except ValueError:
306
+ return None
307
+ if lowered in {"click", "finished"}:
308
+ point = arguments.get("box", arguments.get("point"))
309
+ if point is None and "x" in arguments and "y" in arguments:
310
+ point = (arguments["x"], arguments["y"])
311
+ if isinstance(point, (list, tuple)) and len(point) == 2:
312
+ try:
313
+ return {"type": "final", "point": [float(point[0]), float(point[1])]}
314
+ except (TypeError, ValueError):
315
+ return None
316
+
317
+ try:
318
+ json_action = json.loads(candidate)
319
+ except (json.JSONDecodeError, TypeError):
320
+ json_action = None
321
+ if isinstance(json_action, dict):
322
+ name = str(json_action.get("name") or json_action.get("action") or "")
323
+ arguments = json_action.get("arguments") or {}
324
+ if name.lower() in {"crop", "crop_image"} and isinstance(arguments, dict):
325
+ try:
326
+ return {"type": "crop", "arguments": _normalize_crop_arguments(arguments),
327
+ "source": candidate}
328
+ except ValueError:
329
+ return None
330
+
331
+ coordinate = _final_coordinate_match(value)
332
+ if coordinate:
333
+ return {"type": "final", "point": [float(coordinate.group(1)), float(coordinate.group(2))]}
334
+ return None
335
+
336
+
337
+ def parse_text_actions(text):
338
+ """Return one final answer or up to two crop actions, or None if invalid."""
339
+ value = text or ""
340
+ matches = list(ACTION_BLOCK_RE.finditer(value))
341
+ if matches:
342
+ if len(matches) > MAX_CROP_IMAGE_CALLS_PER_TURN:
343
+ return None
344
+ if _final_coordinate_match(value):
345
+ return None
346
+ actions = [_parse_single_text_action(m.group(1).strip()) for m in matches]
347
+ if any(a is None for a in actions):
348
+ return None
349
+ if len(actions) > 1 and any(a["type"] != "crop" for a in actions):
350
+ return None
351
+ return actions
352
+ action = _parse_single_text_action(value)
353
+ return [action] if action is not None else None
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # Images and crops
358
+ # ---------------------------------------------------------------------------
359
+ def jpeg_roundtrip(image, quality=API_IMAGE_QUALITY):
360
+ """The reference client sends every image to the model as JPEG q90."""
361
+ if image.mode in {"RGBA", "LA"} or "transparency" in image.info:
362
+ rgba = image.convert("RGBA")
363
+ rgb = Image.new("RGB", rgba.size, "white")
364
+ rgb.paste(rgba, mask=rgba.getchannel("A"))
365
+ else:
366
+ rgb = image.convert("RGB")
367
+ buf = BytesIO()
368
+ rgb.save(buf, format="JPEG", quality=quality, optimize=True, subsampling=2)
369
+ buf.seek(0)
370
+ out = Image.open(buf)
371
+ out.load()
372
+ return out.convert("RGB")
373
+
374
+
375
+ def _json_two_decimals(value):
376
+ if isinstance(value, float) and math.isfinite(value):
377
+ return f"{value:.2f}"
378
+ if isinstance(value, dict):
379
+ return "{" + ", ".join(
380
+ f"{json.dumps(k, ensure_ascii=False)}: {_json_two_decimals(v)}" for k, v in value.items()
381
+ ) + "}"
382
+ if isinstance(value, (list, tuple)):
383
+ return "[" + ", ".join(_json_two_decimals(v) for v in value) + "]"
384
+ return json.dumps(value, ensure_ascii=False)
385
+
386
+
387
+ def execute_crop(arguments, registry, crop_dir, crop_index):
388
+ """Port of AgenticGroundingPipeline._execute_crop, with crops kept in memory."""
389
+ try:
390
+ source_path = str(arguments["image_path"]).strip()
391
+ if source_path not in registry:
392
+ raise ValueError("image_path is not the original image or a path returned by crop_image")
393
+ source = registry[source_path]
394
+ values = [float(arguments[k]) for k in ("x1", "y1", "x2", "y2")]
395
+ if not all(math.isfinite(v) for v in values):
396
+ raise ValueError("Crop coordinates must be finite")
397
+ x1, y1, x2, y2 = values
398
+ if x2 <= x1 or y2 <= y1:
399
+ raise ValueError("Expected x1 < x2 and y1 < y2")
400
+ left = max(0, min(source["width"], math.floor(x1)))
401
+ top = max(0, min(source["height"], math.floor(y1)))
402
+ right = max(0, min(source["width"], math.ceil(x2)))
403
+ bottom = max(0, min(source["height"], math.ceil(y2)))
404
+ if right - left < 2 or bottom - top < 2:
405
+ raise ValueError("Clamped crop must be at least 2x2 pixels")
406
+
407
+ cropped = source["image"].crop((left, top, right, bottom))
408
+ cw, ch = cropped.size
409
+ scale = min(
410
+ CROP_UPSCALE_FACTOR,
411
+ math.sqrt(CROP_MAX_PIXELS / (cw * ch)),
412
+ CROP_MAX_DIMENSION / cw,
413
+ CROP_MAX_DIMENSION / ch,
414
+ )
415
+ ow = max(1, min(CROP_MAX_DIMENSION, math.floor(cw * scale)))
416
+ oh = max(1, min(CROP_MAX_DIMENSION, math.floor(ch * scale)))
417
+ if ow * oh > CROP_MAX_PIXELS:
418
+ if ow >= oh:
419
+ ow = max(1, CROP_MAX_PIXELS // oh)
420
+ else:
421
+ oh = max(1, CROP_MAX_PIXELS // ow)
422
+ if (ow, oh) != cropped.size:
423
+ cropped = cropped.resize((ow, oh), resample=Image.Resampling.LANCZOS)
424
+
425
+ original = source["original_region"]
426
+ psx = (original[2] - original[0]) / source["width"]
427
+ psy = (original[3] - original[1]) / source["height"]
428
+ region = [
429
+ original[0] + left * psx,
430
+ original[1] + top * psy,
431
+ original[0] + right * psx,
432
+ original[1] + bottom * psy,
433
+ ]
434
+ osx = (region[2] - region[0]) / ow
435
+ osy = (region[3] - region[1]) / oh
436
+ W, H = source["original_width"], source["original_height"]
437
+ out_path = f"{crop_dir}/crop_{crop_index:02d}.png"
438
+ registry[out_path] = {
439
+ "image": cropped,
440
+ "width": ow,
441
+ "height": oh,
442
+ "original_region": region,
443
+ "original_width": W,
444
+ "original_height": H,
445
+ }
446
+ normalized = [region[0] * 1000 / W, region[1] * 1000 / H, region[2] * 1000 / W, region[3] * 1000 / H]
447
+ return {
448
+ "ok": True,
449
+ "image_path": out_path,
450
+ "width": ow,
451
+ "height": oh,
452
+ "crop_in_parent": [left, top, right, bottom],
453
+ "crop_size_before_resize": [cw, ch],
454
+ "resize_scale": round(scale, 2),
455
+ "original_image_region_pixels": [round(v, 2) for v in region],
456
+ "original_image_region_0_1000": [round(v, 2) for v in normalized],
457
+ "mapping_to_original_0_1000": {
458
+ "x": f"({region[0]:.2f} + crop_x * {osx:.2f}) * 1000 / {W}",
459
+ "y": f"({region[1]:.2f} + crop_y * {osy:.2f}) * 1000 / {H}",
460
+ },
461
+ "reminder": "Final coordinates must be normalized [0,1000] in the original image.",
462
+ }
463
+ except Exception as exc:
464
+ return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
465
+
466
+
467
+ def crop_results_message(events, registry):
468
+ """One user message with every crop result and successful crop image."""
469
+ content, images = [], []
470
+ for position, event in enumerate(events, start=1):
471
+ result = event["result"]
472
+ if len(events) == 1:
473
+ heading = "crop_image action result:"
474
+ else:
475
+ heading = (f"crop_image action result {position}/{len(events)} "
476
+ f"(tool_call_index={event['tool_call_index']}):")
477
+ lines = [heading, _json_two_decimals(result)]
478
+ path = result.get("image_path") if result.get("ok") else None
479
+ if path:
480
+ lines.append(f"image_path: {path}")
481
+ content.append({"type": "text", "text": "\n".join(lines)})
482
+ if path:
483
+ content.append({"type": "image"})
484
+ images.append(jpeg_roundtrip(registry[path]["image"]))
485
+ content.append({
486
+ "type": "text",
487
+ "text": (
488
+ "Inspect all successful crops and account for any failed actions. "
489
+ "Reply with <think>...</think>, then either one or two independent "
490
+ "crop_image <action> blocks whose coordinates have exactly two "
491
+ "decimal places, or the final [x.xx,y.yy]. Do not mix crop actions "
492
+ "with a final answer."
493
+ ),
494
+ })
495
+ return {"role": "user", "content": content}, images
496
+
497
+
498
+ def visible_content(generated):
499
+ """The template pre-fills `<think>\\n`; put it back like the reference client does."""
500
+ content = generated or ""
501
+ has_open = re.search(r"<think\b[^>]*>", content, re.IGNORECASE)
502
+ has_close = re.search(r"</think\s*>", content, re.IGNORECASE)
503
+ if has_close and (not has_open or has_close.start() < has_open.start()):
504
+ return f"<think>\n{content.lstrip()}"
505
+ return content
506
+
507
+
508
+ # ---------------------------------------------------------------------------
509
+ # Drawing
510
+ # ---------------------------------------------------------------------------
511
+ CROP_COLORS = [(33, 150, 243), (255, 152, 0), (156, 39, 176)]
512
+ POINT_COLOR = (229, 57, 53)
513
+
514
+
515
+ def _font(size):
516
+ for path in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
517
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"):
518
+ try:
519
+ return ImageFont.truetype(path, size)
520
+ except OSError:
521
+ continue
522
+ try:
523
+ return ImageFont.load_default(size=size)
524
+ except TypeError:
525
+ return ImageFont.load_default()
526
+
527
+
528
+ def _label(draw, xy, text, color, font, bounds):
529
+ l, t, r, b = draw.textbbox((0, 0), text, font=font)
530
+ w, h = r - l, b - t
531
+ x = min(max(xy[0], 4), bounds[0] - w - 10)
532
+ y = min(max(xy[1], 4), bounds[1] - h - 10)
533
+ draw.rectangle([x, y, x + w + 8, y + h + 6], fill=color)
534
+ draw.text((x + 4 - l, y + 3 - t), text, fill=(255, 255, 255), font=font)
535
+
536
+
537
+ def annotate(image, crop_regions, point_px):
538
+ canvas = image.convert("RGB").copy()
539
+ draw = ImageDraw.Draw(canvas)
540
+ W, H = canvas.size
541
+ unit = max(2, round(max(W, H) / 640))
542
+ font = _font(max(14, unit * 8))
543
+ for i, region in enumerate(crop_regions):
544
+ color = CROP_COLORS[i % len(CROP_COLORS)]
545
+ draw.rectangle(region, outline=color, width=unit * 2)
546
+ _label(draw, (region[0], region[1] - unit * 12), f"crop {i + 1}", color, font, (W, H))
547
+ if point_px is not None:
548
+ x, y = point_px
549
+ r = unit * 10
550
+ draw.ellipse([x - r, y - r, x + r, y + r], outline=POINT_COLOR, width=unit * 2)
551
+ draw.line([x - 2 * r, y, x + 2 * r, y], fill=POINT_COLOR, width=unit)
552
+ draw.line([x, y - 2 * r, x, y + 2 * r], fill=POINT_COLOR, width=unit)
553
+ _label(draw, (x + r + unit * 3, y - r), f"click ({round(x)}, {round(y)})", POINT_COLOR, font, (W, H))
554
+ return canvas
555
+
556
+
557
+ def render_transcript(turns):
558
+ parts = []
559
+ for turn in turns:
560
+ if turn["role"] == "model":
561
+ parts.append(f"**Turn {turn['turn']} · LBS-27B**\n\n```text\n{turn['text'].strip()}\n```")
562
+ else:
563
+ parts.append(f"**Client →** {turn['text']}")
564
+ return "\n\n".join(parts) if parts else "_Waiting for the model…_"
565
+
566
+
567
+ # ---------------------------------------------------------------------------
568
+ # Inference
569
+ # ---------------------------------------------------------------------------
570
+ def _stream_generate(messages, images, *, thinking, effort, max_new_tokens, temperature):
571
+ template_kwargs = {"thinking": thinking, "enable_thinking": thinking}
572
+ if thinking:
573
+ template_kwargs["reasoning_effort"] = effort
574
+ prompt = processor.apply_chat_template(
575
+ messages, tokenize=False, add_generation_prompt=True, **template_kwargs
576
+ )
577
+ inputs = processor(text=[prompt], images=images or None, return_tensors="pt").to(model.device)
578
+ streamer = TextIteratorStreamer(processor.tokenizer, skip_prompt=True, skip_special_tokens=True)
579
+ sample = float(temperature) > 0.0
580
+ holder = {"error": None, "n_tokens": 0}
581
+
582
+ def worker():
583
+ try:
584
+ with torch.inference_mode():
585
+ out = model.generate(
586
+ **inputs,
587
+ streamer=streamer,
588
+ max_new_tokens=int(max_new_tokens),
589
+ do_sample=sample,
590
+ temperature=float(temperature) if sample else None,
591
+ top_p=0.95 if sample else None,
592
+ top_k=20 if sample else None,
593
+ use_cache=True,
594
+ )
595
+ holder["n_tokens"] = int(out.shape[1] - inputs["input_ids"].shape[1])
596
+ except BaseException as exc: # surfaced after the stream drains
597
+ holder["error"] = exc
598
+ streamer.end()
599
+
600
+ thread = threading.Thread(target=worker, daemon=True)
601
+ thread.start()
602
+ text = ""
603
+ for chunk in streamer:
604
+ text += chunk
605
+ yield text, None
606
+ thread.join()
607
+ if holder["error"] is not None:
608
+ raise gr.Error(f"Generation failed: {holder['error']}")
609
+ finish = "length" if holder["n_tokens"] >= int(max_new_tokens) else "stop"
610
+ yield text, finish
611
+
612
+
613
+ def _duration(image=None, instruction="", reasoning_effort="xhigh", max_new_tokens=4096,
614
+ temperature=0.0, *args, **kwargs):
615
+ return 150
616
+
617
+
618
+ @spaces.GPU(duration=_duration, size="xlarge")
619
+ def ground(image, instruction, reasoning_effort="xhigh", max_new_tokens=4096, temperature=0.0):
620
+ """Locate the UI element described by an instruction on a screenshot, zooming in with crops.
621
+
622
+ Args:
623
+ image: the screenshot (PIL image).
624
+ instruction: what to click, e.g. "open the sort dialog".
625
+ reasoning_effort: chat-template reasoning effort, "xhigh" (default), "medium" or "low".
626
+ max_new_tokens: generation budget per model turn.
627
+ temperature: 0 for greedy decoding (the authors' evaluation setting).
628
+
629
+ Returns:
630
+ (annotated screenshot, list of crops, markdown summary, JSON result, transcript markdown)
631
+ """
632
+ if image is None:
633
+ raise gr.Error("Upload a screenshot first.")
634
+ if not instruction or not instruction.strip():
635
+ raise gr.Error("Describe the element to locate.")
636
+ if isinstance(image, str):
637
+ image = Image.open(image)
638
+ original = image.convert("RGB")
639
+ W, H = original.size
640
+ started = time.perf_counter()
641
+
642
+ conv_id = secrets.token_hex(8)
643
+ crop_dir = f"/tmp/lbs/conversation_{conv_id}"
644
+ orig_path = f"/tmp/lbs/conversation_{conv_id}/original.png"
645
+ registry = {orig_path: {
646
+ "image": original, "width": W, "height": H,
647
+ "original_region": [0.0, 0.0, float(W), float(H)],
648
+ "original_width": W, "original_height": H,
649
+ }}
650
+ system_prompt = SYSTEM_PROMPT_TEMPLATE.format(
651
+ instruction=instruction.strip().rstrip("."),
652
+ image_path=orig_path, width=W, height=H,
653
+ crop_description=CROP_IMAGE_DESCRIPTION,
654
+ max_tool_calls=MAX_CROP_IMAGE_CALLS,
655
+ )
656
+ messages = [
657
+ {"role": "system", "content": system_prompt},
658
+ {"role": "user", "content": [{"type": "text", "text": f"image_path: {orig_path}"},
659
+ {"type": "image"}]},
660
+ ]
661
+ images = [jpeg_roundtrip(original)]
662
+
663
+ turns, crops_gallery, crop_regions = [], [], []
664
+ tool_count = tool_attempts = corrections = 0
665
+ current_path = orig_path
666
+ final_point = None
667
+ error = None
668
+ thinking_next, max_tokens_next = True, None
669
+
670
+ def snapshot(status, point_px=None):
671
+ return (
672
+ annotate(original, crop_regions, point_px),
673
+ list(crops_gallery),
674
+ status,
675
+ {},
676
+ render_transcript(turns),
677
+ )
678
+
679
+ yield snapshot("Looking at the full screenshot…")
680
+
681
+ max_turns = MAX_CROP_IMAGE_CALLS + MAX_ACTION_CORRECTIONS + 1
682
+ for turn in range(1, max_turns + 1):
683
+ thinking = thinking_next
684
+ budget = max_tokens_next or int(max_new_tokens)
685
+ thinking_next, max_tokens_next = True, None
686
+ turns.append({"role": "model", "turn": turn, "text": ""})
687
+ last_emit = 0.0
688
+ generated, finish = "", None
689
+ for generated, finish in _stream_generate(
690
+ messages, images, thinking=thinking, effort=reasoning_effort,
691
+ max_new_tokens=budget, temperature=temperature,
692
+ ):
693
+ now = time.perf_counter()
694
+ if finish is None and now - last_emit < 0.5:
695
+ continue
696
+ last_emit = now
697
+ turns[-1]["text"] = (visible_content(generated) if finish else
698
+ (("<think>\n" if thinking else "") + generated))
699
+ if finish is None:
700
+ yield snapshot(f"Turn {turn}: model is thinking…")
701
+ content = visible_content(generated) if thinking else generated
702
+ turns[-1]["text"] = content
703
+ degenerate = _detect_degenerate(generated) or _detect_degenerate(content)
704
+ actions = parse_text_actions(content)
705
+
706
+ if actions is None:
707
+ if corrections >= MAX_ACTION_CORRECTIONS:
708
+ error = (f"The model did not return a parseable crop action or final coordinate "
709
+ f"after {corrections + 1} attempts.")
710
+ break
711
+ corrections += 1
712
+ truncated = finish == "length"
713
+ if degenerate:
714
+ messages.append({"role": "assistant", "content": "(repeated degenerate output, discarded)"})
715
+ thinking_next, max_tokens_next = False, DEGENERATE_RESCUE_MAX_TOKENS
716
+ note = ("Your previous reply degenerated into a repeated token loop and was discarded. "
717
+ "Do not reason, plan, or request crops now. Reply immediately with exactly "
718
+ "[x.xx,y.yy] coordinate and nothing else.")
719
+ elif truncated:
720
+ short = content[:TRUNCATED_ASSISTANT_CONTENT_CHARS] + " ...[truncated]" \
721
+ if len(content) > TRUNCATED_ASSISTANT_CONTENT_CHARS else content
722
+ messages.append({"role": "assistant", "content": short})
723
+ note = ("Your previous reply hit the token limit and was cut off before producing any "
724
+ "crop action or final answer. Do not reason, plan, or request crops now. Reply "
725
+ "immediately with exactly [x.xx,y.yy] coordinate and nothing else.")
726
+ else:
727
+ messages.append({"role": "assistant", "content": content})
728
+ note = ("Your previous answer was not valid. Reply with either one or two independent "
729
+ "crop actions, each in its own <action>crop_image(...)</action> block, or one "
730
+ "final [x.xx,y.yy] coordinate. Do not emit more than two actions or mix actions "
731
+ "with a final answer. Every crop coordinate value must have exactly two digits "
732
+ "after the decimal point.")
733
+ messages.append({"role": "user", "content": note})
734
+ turns.append({"role": "client", "text": f"_correction:_ {note}"})
735
+ yield snapshot(f"Turn {turn}: invalid reply, asking the model to correct it…")
736
+ continue
737
+
738
+ if actions[0]["type"] == "final":
739
+ final_point = actions[0]["point"]
740
+ break
741
+
742
+ messages.append({"role": "assistant", "content": content})
743
+ batch_source = current_path
744
+ events = []
745
+ for action in actions:
746
+ tool_attempts += 1
747
+ arguments = dict(action.get("arguments") or {})
748
+ arguments.setdefault("image_path", batch_source)
749
+ if tool_count >= MAX_CROP_IMAGE_CALLS:
750
+ result = {"ok": False, "error": (
751
+ f"Crop budget exhausted: maximum is {MAX_CROP_IMAGE_CALLS}. "
752
+ "Return the final [x.xx,y.yy] now, using exactly two decimal places.")}
753
+ else:
754
+ tool_count += 1
755
+ result = execute_crop(arguments, registry, crop_dir, tool_count)
756
+ events.append({"tool_call_index": tool_attempts, "result": result})
757
+ if result.get("ok"):
758
+ current_path = result["image_path"]
759
+ region = result["original_image_region_pixels"]
760
+ crop_regions.append(region)
761
+ crops_gallery.append((
762
+ registry[current_path]["image"],
763
+ f"crop {len(crop_regions)} · original px "
764
+ f"[{region[0]:.0f}, {region[1]:.0f}, {region[2]:.0f}, {region[3]:.0f}] · "
765
+ f"{result['width']}×{result['height']}",
766
+ ))
767
+ turns.append({"role": "client", "text": (
768
+ f"executed crop {len(crop_regions)}: original region "
769
+ f"`[{region[0]:.0f}, {region[1]:.0f}, {region[2]:.0f}, {region[3]:.0f}]`, "
770
+ f"zoomed ×{result['resize_scale']:.2f} → {result['width']}×{result['height']}")})
771
+ else:
772
+ turns.append({"role": "client", "text": f"crop failed: `{result['error']}`"})
773
+ msg, new_images = crop_results_message(events, registry)
774
+ messages.append(msg)
775
+ images.extend(new_images)
776
+ yield snapshot(f"Turn {turn}: executed {len(events)} crop(s), sending the zoomed view back…")
777
+ else:
778
+ error = "The conversation used up the model-turn limit without a final coordinate."
779
+
780
+ elapsed = time.perf_counter() - started
781
+ print(f"[timing] {len(turns)} steps, {tool_count} crops, {elapsed:.1f}s", flush=True)
782
+
783
+ result = {
784
+ "instruction": instruction.strip(),
785
+ "image_size": [W, H],
786
+ "point_0_1000": final_point,
787
+ "point_pixels": None,
788
+ "infeasible": False,
789
+ "crops": [[round(v, 2) for v in r] for r in crop_regions],
790
+ "model_turns": sum(1 for t in turns if t["role"] == "model"),
791
+ "seconds": round(elapsed, 1),
792
+ }
793
+ point_px = None
794
+ if final_point is not None and final_point[0] < 0 and final_point[1] < 0:
795
+ result["infeasible"] = True
796
+ status = "### The model judged the target **absent / infeasible** (`[-1.00, -1.00]`)."
797
+ elif final_point is not None:
798
+ point_px = (final_point[0] / 1000 * W, final_point[1] / 1000 * H)
799
+ result["point_pixels"] = [round(point_px[0], 1), round(point_px[1], 1)]
800
+ status = (f"### Click at **({point_px[0]:.0f}, {point_px[1]:.0f})** px\n"
801
+ f"normalized `[{final_point[0]:.2f}, {final_point[1]:.2f}]` on a {W}×{H} screenshot · "
802
+ f"{tool_count} crop(s) · {result['model_turns']} model turn(s) · {elapsed:.0f}s")
803
+ else:
804
+ status = f"### No final coordinate\n{error}"
805
+ result["error"] = error
806
+ yield (annotate(original, crop_regions, point_px), list(crops_gallery), status, result,
807
+ render_transcript(turns))
808
+
809
+
810
+ # ---------------------------------------------------------------------------
811
+ # UI
812
+ # ---------------------------------------------------------------------------
813
+ CSS = """
814
+ #col-container { max-width: 1280px; margin: 0 auto; }
815
+ .dark .gradio-container { color: var(--body-text-color); }
816
+ """
817
+
818
+ EXAMPLES = [
819
+ [str(EX / "quartus.png"), "Programmer"],
820
+ [str(EX / "excel.png"), "open the sort dialog"],
821
+ [str(EX / "calc.png"), "Click the cell in column Q, row 35 in the spreadsheet of LibreOffice Calc."],
822
+ [str(EX / "note_style.png"),
823
+ 'Click on the style that the word "NOTE" is written in a light yellow font color in the pop-up window'],
824
+ [str(EX / "blender.png"), "Edit the Distance Min value for adjusting the minimum distance value."],
825
+ ]
826
+
827
+ with gr.Blocks(title="LBS-27B GUI grounding") as demo:
828
+ with gr.Column(elem_id="col-container"):
829
+ gr.Markdown(
830
+ "# 🔍 LBS-27B: GUI grounding with zoom-in crops\n"
831
+ "[LBS-27B](https://huggingface.co/inclusionAI/LBS-27B) (*Learning to Look Beyond Screens*) is a "
832
+ "27B vision-language model trained to **look closer before it answers**. Give it a screenshot and an "
833
+ "instruction. It can request up to three `crop_image` zoom-ins; the demo runs each crop, sends the "
834
+ "magnified region back, and the model returns the click point on the original screenshot. \n"
835
+ "[Model](https://huggingface.co/inclusionAI/LBS-27B) · "
836
+ "[Code](https://github.com/inclusionAI/UI-Venus/tree/LBS)"
837
+ )
838
+ with gr.Row():
839
+ with gr.Column(scale=1):
840
+ image_in = gr.Image(type="pil", label="Screenshot", height=420)
841
+ instruction = gr.Textbox(
842
+ label="Instruction", lines=2,
843
+ placeholder='e.g. "open the sort dialog" or "close the plotting window"',
844
+ )
845
+ run = gr.Button("Locate", variant="primary", size="lg")
846
+ with gr.Accordion("Advanced settings", open=False):
847
+ effort = gr.Radio(["xhigh", "medium", "low"], value="xhigh", label="Reasoning effort",
848
+ info="Chat-template setting; the authors' evaluation uses xhigh.")
849
+ max_tokens = gr.Slider(256, 4096, value=4096, step=128, label="Max new tokens per turn")
850
+ temperature = gr.Slider(0.0, 1.0, value=0.0, step=0.05, label="Temperature",
851
+ info="0 = greedy, as in the authors' evaluation.")
852
+ with gr.Column(scale=1):
853
+ annotated = gr.Image(label="Prediction (red) and crops the model asked for", height=420)
854
+ status = gr.Markdown()
855
+ crops = gr.Gallery(label="Zoomed crops the model inspected", columns=3, height=220)
856
+ with gr.Accordion("Trajectory (model turns and client actions)", open=True):
857
+ transcript = gr.Markdown()
858
+ with gr.Accordion("JSON result", open=False):
859
+ result_json = gr.JSON()
860
+
861
+ outputs = [annotated, crops, status, result_json, transcript]
862
+ run.click(ground, [image_in, instruction, effort, max_tokens, temperature], outputs,
863
+ api_name="ground")
864
+ instruction.submit(ground, [image_in, instruction, effort, max_tokens, temperature], outputs,
865
+ api_name=False)
866
+
867
+ gr.Examples(EXAMPLES, [image_in, instruction], outputs, fn=ground,
868
+ cache_examples=True, cache_mode="lazy",
869
+ label="Screenshots from ScreenSpot-Pro and OSWorld-G (from the authors' trajectories)")
870
+ gr.Markdown(
871
+ "Coordinates come back normalized to [0,1000] on the original image and are drawn in pixels here. "
872
+ "The model predicts a location only; nothing is clicked. \n"
873
+ "Example screenshots: [ScreenSpot-Pro](https://huggingface.co/datasets/likaixin/ScreenSpot-Pro) (MIT) "
874
+ "and [OSWorld-G](https://github.com/xlang-ai/OSWorld-G) (Apache-2.0)."
875
+ )
876
+
877
+ if __name__ == "__main__":
878
+ demo.queue(max_size=16).launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)
examples/blender.png ADDED

Git LFS Details

  • SHA256: e70689ac2412508cd2879e55e03c37f5abc93d817d16e0da67d9b82a8c1b420a
  • Pointer size: 131 Bytes
  • Size of remote file: 539 kB
examples/calc.png ADDED

Git LFS Details

  • SHA256: e607f4d3650c9987d5403637363a5b26fc899e8db594b21c0608402f030c75a6
  • Pointer size: 131 Bytes
  • Size of remote file: 234 kB
examples/excel.png ADDED

Git LFS Details

  • SHA256: 4d6e86f6b2e30eea7d15a4b9530c6ad0fb574ef7845ec7ae20e716d69276dfaf
  • Pointer size: 132 Bytes
  • Size of remote file: 1.74 MB
examples/note_style.png ADDED

Git LFS Details

  • SHA256: 3cdc329e7c964a34a32c6981bea2ab75df0125e79c53ef577a9e7d3472847d70
  • Pointer size: 131 Bytes
  • Size of remote file: 522 kB
examples/quartus.png ADDED

Git LFS Details

  • SHA256: 3682625467c3e36c721ea79a802be37c568c15eb69e06a608d7e0f081dca0fd4
  • Pointer size: 131 Bytes
  • Size of remote file: 158 kB
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ transformers>=5.15.0
2
+ accelerate
3
+ torchvision
4
+ pillow
5
+ flash-linear-attention==0.5.2
6
+ fla-core==0.5.2
7
+ einops