multimodalart HF Staff commited on
Commit
144c25e
·
verified ·
1 Parent(s): 3977922

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,13 +1,30 @@
1
  ---
2
- title: Phonebuddy Agent
3
- emoji: 🏃
4
- colorFrom: green
5
- colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.19.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: PhoneBuddy Agent
3
+ emoji: 📱
4
+ colorFrom: gray
5
+ colorTo: pink
6
  sdk: gradio
7
  sdk_version: 6.19.0
 
8
  app_file: app.py
9
+ short_description: Phone-use GUI agent that predicts actions from screenshots
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 30m
12
  ---
13
 
14
+ # PhoneBuddy Agent
15
+
16
+ Interactive demo of [PhoneBuddy-4B-RealApp](https://huggingface.co/PhoneBuddyAI/PhoneBuddy-4B-RealApp), a 4B parameter vision-language model trained for agentic phone use.
17
+
18
+ ## How it works
19
+
20
+ 1. Upload a phone screenshot
21
+ 2. Enter an instruction (e.g., "Open the Contacts app")
22
+ 3. The model predicts the next action (click, swipe, type, etc.) with coordinates normalized to 0-1000
23
+ 4. The predicted action is visualized on the screenshot
24
+
25
+ ## Model
26
+
27
+ - **Architecture**: Qwen3.5-VL style (Qwen3_5ForConditionalGeneration)
28
+ - **Parameters**: ~4B
29
+ - **Training**: Real-app RL ablation checkpoint
30
+ - **Paper**: [Training Open Models for Agentic Phone Use](https://arxiv.org/abs/2606.23049)
app.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
3
+
4
+ import spaces
5
+ import torch
6
+ import json
7
+ import re
8
+ import math
9
+ import gradio as gr
10
+ from PIL import Image, ImageDraw, ImageFont
11
+ from transformers import AutoProcessor, AutoModelForImageTextToText
12
+
13
+ MODEL_ID = "PhoneBuddyAI/PhoneBuddy-4B-RealApp"
14
+
15
+ # Build tool-call format tags as variables to avoid issues with XML-like tokens
16
+ _TC_OPEN = chr(60) + "tool_call" + chr(62)
17
+ _TC_CLOSE = chr(60) + "/tool_call" + chr(62)
18
+ _THINK_OPEN = chr(60) + "think" + chr(62)
19
+ _THINK_CLOSE = chr(60) + "/think" + chr(62)
20
+
21
+
22
+ SYSTEM_PROMPT = (
23
+ "You are a GUI Agent. Given an instruction, the current screenshot, "
24
+ "and the history of operations, you need to predict how to fulfill "
25
+ "the user's request and provide the accurate invocation command. "
26
+ "Please note that coordinate values must be scaled to a range of 0 to 1000.\n\n"
27
+ "# Tools\n\n"
28
+ "You can call one or more of the following functions to complete "
29
+ "the user's request.\n\n"
30
+ "Below is the complete list of tools supported by the system:\n"
31
+ "<tools>\n"
32
+ '{"type": "function", "function": {"name": "click", "description": "Click on a specified coordinate position on the screen (coordinate range 0-1000)", "parameters": {"type": "object", "properties": {"points": {"description": "A list of click coordinates, formatted as [[x, y]]", "type": "array"}}, "required": ["points"]}}}' + "\n"
33
+ '{"type": "function", "function": {"name": "double_click", "description": "Double-click on a specified coordinate position on the screen", "parameters": {"type": "object", "properties": {"points": {"description": "Click coordinates [[x, y]]", "type": "array"}, "interval": {"description": "Interval between two clicks (milliseconds)", "type": "integer"}}, "required": ["points"]}}}' + "\n"
34
+ '{"type": "function", "function": {"name": "long_press", "description": "Long press on a specified coordinate position on the screen", "parameters": {"type": "object", "properties": {"points": {"description": "Long press coordinates [[x, y]]", "type": "array"}, "duration": {"description": "Long press duration (milliseconds)", "type": "integer"}}, "required": ["points"]}}}' + "\n"
35
+ '{"type": "function", "function": {"name": "type", "description": "Type text in the currently focused input field", "parameters": {"type": "object", "properties": {"text": {"description": "The text content to type", "type": "string"}}, "required": ["text"]}}}' + "\n"
36
+ '{"type": "function", "function": {"name": "scroll", "description": "Scroll from start coordinates to target coordinates (for scrolling pages)", "parameters": {"type": "object", "properties": {"points": {"description": "Start and end coordinates for scrolling [[x1, y1], [x2, y2]]", "type": "array"}, "duration": {"description": "Scroll duration (milliseconds)", "type": "integer"}}, "required": ["points"]}}}' + "\n"
37
+ '{"type": "function", "function": {"name": "drag", "description": "Drag an element from start coordinates to target coordinates", "parameters": {"type": "object", "properties": {"points": {"description": "Start and end coordinates for dragging [[x1, y1], [x2, y2]]", "type": "array"}, "duration": {"description": "Drag duration (milliseconds)", "type": "integer"}}, "required": ["points"]}}}' + "\n"
38
+ '{"type": "function", "function": {"name": "button_press", "description": "Press a phone physical/virtual button", "parameters": {"type": "object", "properties": {"type": {"description": "Button type: back/home/menu/enter", "type": "string", "enum": ["back", "home", "menu", "enter"]}}, "required": ["type"]}}}' + "\n"
39
+ '{"type": "function", "function": {"name": "open_app", "description": "Open an app by package name", "parameters": {"type": "object", "properties": {"package": {"description": "App package name", "type": "string"}}, "required": ["package"]}}}' + "\n"
40
+ '{"type": "function", "function": {"name": "close_app", "description": "Close an app by package name", "parameters": {"type": "object", "properties": {"package": {"description": "App package name", "type": "string"}}, "required": ["package"]}}}' + "\n"
41
+ '{"type": "function", "function": {"name": "wait", "description": "Wait for a specified duration", "parameters": {"type": "object", "properties": {"time": {"description": "Wait duration (milliseconds)", "type": "integer"}}, "required": ["time"]}}}' + "\n"
42
+ '{"type": "function", "function": {"name": "output", "description": "Output information to the user", "parameters": {"type": "object", "properties": {"text": {"description": "The text content to output", "type": "string"}}, "required": ["text"]}}}' + "\n"
43
+ '{"type": "function", "function": {"name": "finish", "description": "Mark the task as complete and output the final result", "parameters": {"type": "object", "properties": {"text": {"description": "Description or result of the completed task", "type": "string"}}, "required": ["text"]}}}' + "\n"
44
+ "</tools>\n\n"
45
+ "When making a function call, first output your thought process in natural language, "
46
+ "then make the function call.\n"
47
+ "The format for each function call is as follows:\n"
48
+ + _TC_OPEN + "\n"
49
+ + '{"name": <function-name>, "arguments": <args-json-object>}\n'
50
+ + _TC_CLOSE
51
+ )
52
+
53
+ # Load model and processor at module scope
54
+ processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
55
+ model = AutoModelForImageTextToText.from_pretrained(
56
+ MODEL_ID,
57
+ torch_dtype=torch.bfloat16,
58
+ attn_implementation="sdpa",
59
+ ).to("cuda")
60
+ model.eval()
61
+
62
+
63
+ def _lenient_json_loads(s):
64
+ s = s.strip()
65
+ try:
66
+ return json.loads(s)
67
+ except Exception:
68
+ pass
69
+ start = s.find("{")
70
+ end = s.rfind("}")
71
+ if start != -1 and end != -1 and end > start:
72
+ s = s[start : end + 1]
73
+ s = s.replace("\u201c", '"').replace("\u201d", '"').replace("\u2018", "'").replace("\u2019", "'")
74
+ try:
75
+ return json.loads(s)
76
+ except Exception:
77
+ pass
78
+ s2 = re.sub(r",\s*([}\]])", r"\1", s)
79
+ try:
80
+ return json.loads(s2)
81
+ except Exception:
82
+ pass
83
+ if '"' not in s2:
84
+ try:
85
+ return json.loads(s2.replace("'", '"'))
86
+ except Exception:
87
+ pass
88
+ raise ValueError(f"Could not parse JSON from: {s[:200]!r}")
89
+
90
+
91
+ def parse_model_response(response):
92
+ """Parse the model response to extract thought and tool call."""
93
+ if not response:
94
+ return None
95
+ try:
96
+ # Extract thought using think tags
97
+ think = ""
98
+ think_pattern = _THINK_OPEN + "(.*?)" + _THINK_CLOSE
99
+ think_match = re.search(think_pattern, response, re.DOTALL)
100
+ if think_match:
101
+ think = think_match.group(1).strip()
102
+
103
+ # Remove thinking from response
104
+ remaining = re.sub(think_pattern, "", response, flags=re.DOTALL).strip()
105
+
106
+ # Extract tool call
107
+ tc_pattern = re.escape(_TC_OPEN) + r"\s*(.*?)\s*" + re.escape(_TC_CLOSE)
108
+ tc = re.search(tc_pattern, remaining, re.DOTALL)
109
+ if not tc:
110
+ tc = re.search(tc_pattern, response, re.DOTALL)
111
+ if not tc:
112
+ # Try to grab JSON after tool_call open tag
113
+ tc_pattern2 = re.escape(_TC_OPEN) + r"\s*(\{.*\})"
114
+ tc = re.search(tc_pattern2, response, re.DOTALL)
115
+ if not tc:
116
+ return None
117
+
118
+ thought_text = remaining.split(_TC_OPEN)[0].strip() if _TC_OPEN in remaining else ""
119
+ full_thought = chr(10).join(x for x in (think, thought_text) if x).strip()
120
+
121
+ obj = _lenient_json_loads(tc.group(1).strip())
122
+ if not isinstance(obj, dict):
123
+ return None
124
+ name = (obj.get("name") or "").strip()
125
+ args = obj.get("arguments", {}) or {}
126
+ if not isinstance(args, dict):
127
+ args = {}
128
+ if name in ("open_app", "close_app") and "package" in args:
129
+ args["app"] = args.pop("package")
130
+ return {"action": name.lower(), "cot": full_thought, "args": args}
131
+ except Exception:
132
+ return None
133
+
134
+
135
+ def _extract_point(args, index=0):
136
+ """Extract [x, y] from points/coordinate fields."""
137
+ coord = None
138
+ for key in ("points", "coordinate", "point", "coordinates"):
139
+ if key in args and args[key] is not None:
140
+ coord = args[key]
141
+ break
142
+ if coord is None:
143
+ return None
144
+ if isinstance(coord, list) and coord:
145
+ if isinstance(coord[0], list):
146
+ if index < len(coord) and len(coord[index]) >= 2:
147
+ return [int(coord[index][0]), int(coord[index][1])]
148
+ return None
149
+ if len(coord) >= 2:
150
+ return [int(coord[0]), int(coord[1])]
151
+ return None
152
+
153
+
154
+ def _scale_point(pt, w, h):
155
+ """Scale normalized 0-1000 coordinates to pixel coordinates."""
156
+ x = int(pt[0] * w / 1000)
157
+ y = int(pt[1] * h / 1000)
158
+ x = max(0, min(x, w - 1))
159
+ y = max(0, min(y, h - 1))
160
+ return x, y
161
+
162
+
163
+ def visualize_action(image, action_name, args):
164
+ """Draw the predicted action on the screenshot."""
165
+ img = image.copy()
166
+ draw = ImageDraw.Draw(img)
167
+ w, h = img.size
168
+
169
+ try:
170
+ font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", max(20, w // 40))
171
+ except Exception:
172
+ font = ImageFont.load_default()
173
+
174
+ if action_name in ("click", "double_click", "long_press"):
175
+ pt = _extract_point(args)
176
+ if pt:
177
+ x, y = _scale_point(pt, w, h)
178
+ r = max(15, w // 50)
179
+ draw.ellipse([x-r, y-r, x+r, y+r], outline=(255, 0, 0), width=max(5, w // 200))
180
+ if action_name == "double_click":
181
+ draw.ellipse([x-r//2, y-r//2, x+r//2, y+r//2], outline=(255, 100, 0), width=max(3, w // 300))
182
+ elif action_name == "long_press":
183
+ draw.ellipse([x-r-5, y-r-5, x+r+5, y+r+5], outline=(0, 0, 255), width=max(3, w // 300))
184
+ label = f"{action_name} ({x},{y})"
185
+ draw.text((10, 10), label, fill=(255, 0, 0), font=font)
186
+ return img
187
+
188
+ elif action_name in ("scroll", "drag", "swipe"):
189
+ p1 = _extract_point(args, 0)
190
+ p2 = _extract_point(args, 1)
191
+ if p1 and p2:
192
+ x1, y1 = _scale_point(p1, w, h)
193
+ x2, y2 = _scale_point(p2, w, h)
194
+ r = max(10, w // 60)
195
+ draw.ellipse([x1-r, y1-r, x1+r, y1+r], outline=(0, 255, 0), width=max(5, w // 200))
196
+ draw.ellipse([x2-r, y2-r, x2+r, y2+r], outline=(255, 0, 0), width=max(5, w // 200))
197
+ draw.line([(x1, y1), (x2, y2)], fill=(255, 200, 0), width=max(5, w // 200))
198
+ angle = math.atan2(y2 - y1, x2 - x1)
199
+ arrow_len = max(20, w // 30)
200
+ for sign in [1, -1]:
201
+ ax = x2 - arrow_len * math.cos(angle - sign * 0.4)
202
+ ay = y2 - arrow_len * math.sin(angle - sign * 0.4)
203
+ draw.line([(x2, y2), (ax, ay)], fill=(255, 200, 0), width=max(3, w // 250))
204
+ label = f"{action_name} ({x1},{y1}) -> ({x2},{y2})"
205
+ draw.text((10, 10), label, fill=(255, 0, 0), font=font)
206
+ return img
207
+
208
+ elif action_name == "type":
209
+ text = args.get("text", "")
210
+ label = f"type: {text[:50]}"
211
+ draw.text((10, 10), label, fill=(0, 100, 255), font=font)
212
+ return img
213
+
214
+ elif action_name == "button_press":
215
+ btn = args.get("type", "")
216
+ label = f"button_press: {btn}"
217
+ draw.text((10, 10), label, fill=(255, 100, 0), font=font)
218
+ return img
219
+
220
+ elif action_name in ("open_app", "close_app"):
221
+ app = args.get("app", args.get("package", ""))
222
+ label = f"{action_name}: {app}"
223
+ draw.text((10, 10), label, fill=(0, 200, 100), font=font)
224
+ return img
225
+
226
+ elif action_name in ("finish", "output", "answer"):
227
+ text = args.get("text", "")
228
+ label = f"{action_name}: {text[:80]}"
229
+ draw.text((10, 10), label, fill=(128, 0, 128), font=font)
230
+ return img
231
+
232
+ draw.text((10, 10), action_name, fill=(255, 0, 0), font=font)
233
+ return img
234
+
235
+
236
+ def format_action_text(action_name, args):
237
+ """Format the action as readable text."""
238
+ if action_name in ("click", "double_click", "long_press"):
239
+ pt = _extract_point(args)
240
+ if pt:
241
+ return f"{action_name} at normalized coordinates [{pt[0]}, {pt[1]}] (0-1000 scale)"
242
+ elif action_name in ("scroll", "drag", "swipe"):
243
+ p1 = _extract_point(args, 0)
244
+ p2 = _extract_point(args, 1)
245
+ if p1 and p2:
246
+ return f"{action_name} from [{p1[0]}, {p1[1]}] to [{p2[0]}, {p2[1]}] (0-1000 scale)"
247
+ elif action_name == "type":
248
+ return f"type text: {args.get('text', '')}"
249
+ elif action_name == "button_press":
250
+ return f"press {args.get('type', '')} button"
251
+ elif action_name in ("open_app", "close_app"):
252
+ return f"{action_name}: {args.get('app', args.get('package', ''))}"
253
+ elif action_name in ("finish", "output", "answer"):
254
+ return f"{action_name}: {args.get('text', '')}"
255
+ elif action_name == "wait":
256
+ return f"wait {args.get('time', 1000)}ms"
257
+ return f"{action_name}({json.dumps(args, ensure_ascii=False)})"
258
+
259
+
260
+ @spaces.GPU(duration=120)
261
+ def predict_action(screenshot, instruction):
262
+ """Predict the next phone action given a screenshot and instruction.
263
+
264
+ Args:
265
+ screenshot: A phone screenshot image.
266
+ instruction: The task instruction (e.g., "Open the Contacts app").
267
+
268
+ Returns:
269
+ A tuple of (visualized_action_image, action_text, raw_response).
270
+ """
271
+ if screenshot is None:
272
+ return None, "Please upload a phone screenshot.", ""
273
+ if not instruction.strip():
274
+ return None, "Please provide an instruction.", ""
275
+
276
+ if isinstance(screenshot, str):
277
+ screenshot = Image.open(screenshot)
278
+ img = screenshot.convert("RGB")
279
+
280
+ # Build messages for the chat template
281
+ messages = [
282
+ {"role": "system", "content": ""},
283
+ {"role": "user", "content": [
284
+ {"type": "text", "text": SYSTEM_PROMPT},
285
+ {"type": "image", "image": img},
286
+ {"type": "text", "text": f"# Instruction\n{instruction}"},
287
+ ]},
288
+ ]
289
+
290
+ # Apply chat template
291
+ text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
292
+
293
+ # Process inputs
294
+ inputs = processor(
295
+ text=[text], images=[img], padding=True, return_tensors="pt"
296
+ ).to("cuda")
297
+
298
+ # Generate
299
+ with torch.no_grad():
300
+ output_ids = model.generate(
301
+ **inputs,
302
+ max_new_tokens=2048,
303
+ do_sample=False,
304
+ temperature=1.0,
305
+ top_p=1.0,
306
+ )
307
+
308
+ # Decode only the generated part
309
+ input_len = inputs["input_ids"].shape[1]
310
+ generated_ids = output_ids[0][input_len:]
311
+ response = processor.decode(generated_ids, skip_special_tokens=False)
312
+
313
+ # Parse the response
314
+ parsed = parse_model_response(response)
315
+ if parsed is None:
316
+ return img, "Could not parse model output.", response
317
+
318
+ action_name = parsed["action"]
319
+ args = parsed["args"]
320
+ cot = parsed["cot"]
321
+
322
+ # Visualize the action on the screenshot
323
+ vis_img = visualize_action(img, action_name, args)
324
+
325
+ # Format the output text
326
+ action_text = format_action_text(action_name, args)
327
+ if cot:
328
+ action_text = f"Thought: {cot}\n\nAction: {action_text}"
329
+
330
+ return vis_img, action_text, response
331
+
332
+
333
+ CSS = """
334
+ #col-container { max-width: 1100px; margin: 0 auto; }
335
+ .dark .gradio-container { color: var(--body-text-color); }
336
+ """
337
+
338
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo:
339
+ gr.Markdown(
340
+ "# PhoneBuddy: Agentic Phone Use\n"
341
+ "Upload a phone screenshot and an instruction. "
342
+ "The model predicts the next action (click, swipe, type, etc.) "
343
+ "and visualizes it on the screenshot.\n\n"
344
+ "Model: [PhoneBuddy-4B-RealApp](https://huggingface.co/PhoneBuddyAI/PhoneBuddy-4B-RealApp) | "
345
+ "Paper: [arXiv:2606.23049](https://arxiv.org/abs/2606.23049) | "
346
+ "Code: [GitHub](https://github.com/PhoneBuddyAI/phonebuddy)"
347
+ )
348
+
349
+ with gr.Column(elem_id="col-container"):
350
+ with gr.Row():
351
+ with gr.Column(scale=1):
352
+ screenshot_input = gr.Image(
353
+ label="Phone Screenshot",
354
+ type="pil",
355
+ height=500,
356
+ )
357
+ instruction_input = gr.Textbox(
358
+ label="Instruction",
359
+ placeholder="e.g., Open the Contacts app and add a new contact",
360
+ lines=2,
361
+ )
362
+ run_btn = gr.Button("Predict Action", variant="primary")
363
+
364
+ with gr.Column(scale=1):
365
+ output_image = gr.Image(
366
+ label="Visualized Action",
367
+ type="pil",
368
+ height=500,
369
+ )
370
+ output_text = gr.Textbox(
371
+ label="Predicted Action",
372
+ lines=6,
373
+ )
374
+
375
+ with gr.Accordion("Raw Model Output", open=False):
376
+ raw_output = gr.Textbox(
377
+ label="Raw Response",
378
+ lines=10,
379
+ interactive=False,
380
+ )
381
+
382
+ gr.Examples(
383
+ examples=[
384
+ ["example_home_screen.png", "Open the Phone app to make a call"],
385
+ ["example_home_screen.png", "Search for weather on Google"],
386
+ ["example_settings_screen.png", "Turn on Wi-Fi"],
387
+ ["example_settings_screen.png", "Check the battery percentage"],
388
+ ],
389
+ inputs=[screenshot_input, instruction_input],
390
+ outputs=[output_image, output_text, raw_output],
391
+ fn=predict_action,
392
+ cache_examples=True,
393
+ cache_mode="lazy",
394
+ )
395
+
396
+ demo.launch(mcp_server=True)
example_home_screen.png ADDED
example_settings_screen.png ADDED
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ transformers>=5.2.0
2
+ accelerate
3
+ sentencepiece
4
+ pillow
5
+ numpy