Spaces:
Running on Zero
Running on Zero
LBS-27B grounding demo
Browse files- .gitattributes +5 -0
- README.md +19 -7
- app.py +878 -0
- examples/blender.png +3 -0
- examples/calc.png +3 -0
- examples/excel.png +3 -0
- examples/note_style.png +3 -0
- examples/quartus.png +3 -0
- requirements.txt +7 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.30.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
examples/calc.png
ADDED
|
Git LFS Details
|
examples/excel.png
ADDED
|
Git LFS Details
|
examples/note_style.png
ADDED
|
Git LFS Details
|
examples/quartus.png
ADDED
|
Git LFS Details
|
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
|