import spaces # MUST come before torch / transformers (ZeroGPU) import json import os import re import time import gradio as gr import torch from PIL import Image, ImageDraw from transformers import AutoModelForImageTextToText, AutoProcessor # GroundGUI-8B: a Qwen3-VL-8B derivative trained for GUI grounding with the # ScaleCUA-recipe per-platform tool-call system prompts (frozen verbatim in # prompts.json, extracted from ScaleCUAToolCallPrompt._GROUNDING_SYSTEM in # github.com/PrentisAI/GroundGUI eval/prompts.py). MODEL = "PrentisAI/GroundGUI-8B" # Eval-side image budget (run_eval.sh: MAX_IMAGE_PIXELS=16777216). Larger # screenshots are downscaled with smart_resize (factor = patch 16 * merge 2 = # 32, the Qwen3-VL grid) before encoding; the predicted point is mapped back # onto the ORIGINAL image afterwards, so the overlay is unaffected. FACTOR = 32 MAX_PIXELS = 16_777_216 HERE = os.path.dirname(os.path.abspath(__file__)) with open(os.path.join(HERE, "prompts.json"), encoding="utf-8") as f: GROUNDING_SYSTEM = json.load(f) TOOL_CALL_OPEN = chr(60) + "tool_call" + chr(62) TOOL_CALL_CLOSE = chr(60) + "/" + "tool_call" + chr(62) def smart_resize(h, w, factor=FACTOR, min_pixels=56 * 56, max_pixels=MAX_PIXELS): """Qwen3-VL smart_resize: snap sides to the factor grid under a pixel budget. Mirrors qwen_vl_utils.smart_resize as used by the GroundGUI eval (IMAGE_FACTOR=32, MAX_IMAGE_PIXELS=16777216). """ h_bar = round(h / factor) * factor w_bar = round(w / factor) * factor if h_bar * w_bar > max_pixels: beta = (h * w / max_pixels) ** 0.5 h_bar = round(h / beta / factor) * factor w_bar = round(w / beta / factor) * factor if h_bar * w_bar < min_pixels: beta2 = (min_pixels / (h * w)) ** 0.5 h_bar = round(h * beta2 / factor) * factor w_bar = round(w * beta2 / factor) * factor return max(h_bar, factor), max(w_bar, factor) def fit(image): """Downscale a screenshot to the eval's vision budget (factor-32 grid).""" w, h = image.size nh, nw = smart_resize(h, w) if (nh, nw) != (h, w): image = image.resize((nw, nh), Image.LANCZOS) return image def _try_tool_blobs(blobs): """Decode tool-call JSON blobs (last first) -> (point, refusal, name, action). Returns (None, None, "", "") when no blob decodes into a usable payload. """ for blob in reversed(blobs): try: obj = json.loads(blob) except json.JSONDecodeError: continue if not isinstance(obj, dict): continue name = obj.get("name") or "" args = obj.get("arguments") or {} if not isinstance(args, dict): continue if args.get("action") in ("terminate", "abstain"): return None, True, name, args.get("action") coord = args.get("coordinate") if isinstance(coord, (list, tuple)) and len(coord) >= 2: try: return [float(coord[0]), float(coord[1])], False, name, args.get("action") except (TypeError, ValueError): continue return None, None, "", "" def extract_point(response): """Parse the model's tool-call reply into a [x, y] point in 0-1000 space. Follows the official eval parse (Qwen3VLPrompt.extract_coordinates): the JSON object inside the tool-call block, terminate/abstain -> refusal, falling back to the GUI-Owl "(x, y)" then "[x, y]" regexes (last match). Tag-wrapped, bare, and fenced JSON forms are all accepted, since the chat template may or may not re-wrap the model's reply with the tags. Returns (point, refusal, tool_name, action_name); point is None if nothing parsed, refusal True only on an explicit abstain/terminate. """ tag_blobs = re.findall( re.escape(TOOL_CALL_OPEN) + r"\s*(\{.*?\})\s*" + re.escape(TOOL_CALL_CLOSE), response, re.DOTALL, ) point, refusal, name, action = _try_tool_blobs(tag_blobs) if point is not None or refusal: return point, refusal, name, action # bare or fenced JSON objects carrying an "arguments" payload bare_blobs = re.findall(r'\{\s*"name"[^{}]*"arguments"\s*:\s*\{.*?\}\s*\}', response, re.DOTALL) point, refusal, name, action = _try_tool_blobs(bare_blobs) if point is not None or refusal: return point, refusal, name, action # explicit refusal wording anywhere in the reply, as in the official eval if ('"action": "terminate"' in response or '"status": "failure"' in response or '"action": "abstain"' in response): return None, True, "", "" matches = re.findall(r"\((\d+),\s*(\d+)\)", response) if not matches: matches = re.findall(r"\[(\d+),\s*(\d+)\]", response) if not matches: return None, False, "", "" x, y = matches[-1] return [int(x), int(y)], False, "", "" def norm_to_pixels(point, width, height): """Map a 0-1000 normalized point onto image pixels (GUIOwlPrompt.calculate_metrics).""" return round(point[0] / 1000.0 * width), round(point[1] / 1000.0 * height) def draw_marker(image, point): """Draw a crosshair + circle at the predicted click point. A white underlay keeps the marker visible on both light and dark UIs. """ out = image.copy() d = ImageDraw.Draw(out) x, y = point r = max(10, min(out.width, out.height) // 60) # white underlay, then orange marker on top 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=(255, 64, 0), width=3) for (x0, y0, x1, y1) in [ (x - r * 1.8, y, x + r * 1.8, y), # horizontal (x, y - r * 1.8, x, y + r * 1.8), # vertical ]: d.line([x0, y0, x1, y1], fill=(255, 255, 255), width=9) d.line([x0, y0, x1, y1], fill=(255, 64, 0), width=4) return out processor = AutoProcessor.from_pretrained(MODEL) model = ( AutoModelForImageTextToText.from_pretrained( MODEL, dtype=torch.bfloat16, attn_implementation="sdpa" ) .eval() .to("cuda") ) @spaces.GPU(duration=30) def predict(instruction: str, screenshot, platform: str = "desktop", max_new_tokens: int = 256): """Locate a UI element in a screenshot. Args: instruction: referring expression naming the element to find, e.g. "the redo button". screenshot: a GUI screenshot (desktop, web, or mobile). platform: one of "desktop", "web", "mobile" -- selects the per-platform grounding tool schema the model was trained with (computer_use / browser_use / mobile_use). max_new_tokens: generation length cap for the emitted tool call. Returns: Tuple of (annotated screenshot with the predicted click point, raw model output, predicted action, coordinates on the original image, inference seconds). """ if screenshot is None: raise gr.Error("Please provide a GUI screenshot first.") if not instruction or not instruction.strip(): raise gr.Error("Please enter an instruction, e.g. 'the redo button'.") platform = platform if platform in GROUNDING_SYSTEM else "desktop" if screenshot.mode != "RGB": screenshot = screenshot.convert("RGB") orig_w, orig_h = screenshot.size # Eval-order prompt: system = frozen per-platform grounding prompt, # user = image first, then the bare instruction (scalecua_toolcall). messages = [ {"role": "system", "content": GROUNDING_SYSTEM[platform]}, { "role": "user", "content": [ {"type": "image"}, {"type": "text", "text": instruction.strip()}, ], }, ] prompt = processor.apply_chat_template( messages, tokenize=False, add_generation_prompt=True ) inputs = processor( text=[prompt], images=[fit(screenshot)], return_tensors="pt" ).to(model.device) t0 = time.perf_counter() with torch.inference_mode(): output = model.generate( **inputs, max_new_tokens=int(max_new_tokens), do_sample=False, use_cache=True, ) elapsed = time.perf_counter() - t0 response = processor.decode( output[0, inputs["input_ids"].shape[1]:], skip_special_tokens=True ).strip() # Parse the full reply: the tags may or may not be re-emitted by the # template; extract_point handles tagged, bare, and fenced JSON forms. point, refusal, tool_name, action_name = extract_point(response) # display: strip the (non-special) tool-call tags if present, else raw display = response if TOOL_CALL_OPEN in display: display = display.replace(TOOL_CALL_OPEN, "").replace(TOOL_CALL_CLOSE, "").strip() if refusal or point is None: note = "Model abstained: element not found on screen." if refusal else "No coordinates parsed from the model output." return screenshot.copy(), display, "abstain" if refusal else "", note, f"{elapsed:.1f}s" px = norm_to_pixels(point, orig_w, orig_h) clipped = (max(0, min(orig_w - 1, px[0])), max(0, min(orig_h - 1, px[1]))) annotated = draw_marker(screenshot, clipped) coords_text = f"click point ({clipped[0]}, {clipped[1]}) px on {orig_w}x{orig_h} image; model space [{point[0]:g}, {point[1]:g}] / 1000" action_text = (tool_name or "?") + "." + (action_name or "?") + f" -> ({clipped[0]}, {clipped[1]})" return annotated, display, action_text, coords_text, f"{elapsed:.1f}s" CSS = """ #col-container { max-width: 1400px; margin: 0 auto; } .dark .gradio-container { color: var(--body-text-color); } """ with gr.Blocks() as demo: with gr.Column(elem_id="col-container"): gr.Markdown( """ # :mag: GroundGUI · GUI Grounding **GroundGUI-8B** ([PrentisAI/GroundGUI-8B](https://huggingface.co/PrentisAI/GroundGUI-8B)) is an 8B GUI-grounding vision-language model fine-tuned from Qwen3-VL-8B-Instruct with high-quality grounding data and distillation-to-reinforcement learning. Give it a GUI screenshot and a short instruction, and it emits the click action with coordinates in a **normalized 0–1000 space**, which are mapped onto your original image and marked with a crosshair. Paper: [GroundGUI: GUI Grounding by High-Quality Data and Distillation-to-Reinforcement Learning](https://huggingface.co/papers/2610.19100) · Code: [github.com/PrentisAI/GroundGUI](https://github.com/PrentisAI/GroundGUI) Benchmarks: **95.2** ScreenSpot-v2 · **63.9** ScreenSpot-Pro · **86.8** MMBench-GUI L2 · **38.3** UI-Vision · **65.4** OSWorld-G. """ ) with gr.Row(): with gr.Column(scale=1): screenshot_in = gr.Image( type="pil", label="GUI screenshot", height=420, sources=["upload", "clipboard"], ) instruction = gr.Textbox( label="Instruction", placeholder="e.g. the redo button", lines=2, ) platform = gr.Radio( ["desktop", "web", "mobile"], value="desktop", label="Platform (tool schema used in the system prompt)", ) run_btn = gr.Button("Ground it", variant="primary") with gr.Accordion("Advanced", open=False): max_new_tokens = gr.Slider( 64, 512, value=256, step=32, label="Max new tokens", ) with gr.Column(scale=1): screenshot_out = gr.Image( type="pil", label="Annotated screenshot (predicted click point)", height=420, ) action_out = gr.Textbox(label="Predicted action", lines=1) coords_out = gr.Textbox(label="Coordinates", lines=2) raw_out = gr.Textbox(label="Raw model output", lines=6) time_out = gr.Textbox(label="Inference time", lines=1) gr.Examples( examples=[ ["redo", "examples/word_redo.png", "desktop"], ["Split editor.", "examples/vscode_split_editor.png", "desktop"], ["mute", "examples/windows_mute.png", "desktop"], ["take a photo", "examples/linux_take_photo.png", "desktop"], ], inputs=[instruction, screenshot_in, platform], fn=predict, outputs=[screenshot_out, raw_out, action_out, coords_out, time_out], cache_examples=True, cache_mode="lazy", ) run_btn.click( predict, inputs=[instruction, screenshot_in, platform, max_new_tokens], outputs=[screenshot_out, raw_out, action_out, coords_out, time_out], api_name="predict", ) if __name__ == "__main__": demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=CSS)