Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 116 additions & 17 deletions gui_agents/s3/agents/grounding.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from io import BytesIO
from typing import Any, Dict, List, Optional, Tuple

import numpy as np
import pytesseract
from PIL import Image
from pytesseract import Output
Expand Down Expand Up @@ -225,24 +226,122 @@ def __init__(
self.current_task_instruction = None
self.last_code_agent_result = None

# Given the state and worker's referring expression, use the grounding model to generate (x,y)
def generate_coords(self, ref_expr: str, obs: Dict) -> List[int]:

# Reset the grounding model state
self.grounding_model.reset()

# Configure the context, UI-TARS demo does not use system prompt
prompt = f"Query:{ref_expr}\nOutput only the coordinate of one point in your response.\n"
self.grounding_model.add_message(
text_content=prompt, image_content=obs["screenshot"], put_text_last=True
)
def _parse_coordinates(self, response: str) -> Optional[Tuple[float, float]]:
"""
Parse coordinates from grounding model response.
Handles both integer (e.g., "523, 847") and normalized (e.g., "0.45, 0.72") formats.

Uses the LAST two numeric values to handle model prefixes like "Point 1: (523, 847)".

Returns None if parsing fails.
"""
# First try to find parenthesized pair like "(x, y)" or "[x, y]"
paren_match = re.search(r"[\(\[]\s*([\d.]+)\s*,\s*([\d.]+)\s*[\)\]]", response)
if paren_match:
try:
x, y = float(paren_match.group(1)), float(paren_match.group(2))
return (x, y)
except ValueError:
pass

# Fall back to extracting last two numeric values
# Match both integer and floating point numbers (including .45 format without leading zero)
numericals = re.findall(r"\d+\.?\d*|\.\d+", response)

if len(numericals) < 2:
return None

# Use last two numbers to handle prefixes like "Point 1: (523, 847)"
x, y = float(numericals[-2]), float(numericals[-1])
return (x, y)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def _normalize_coordinates(self, x: float, y: float) -> Tuple[int, int]:
"""
Convert coordinates to absolute pixels, handling normalized (0-1) format.
"""
grounding_width = self.engine_params_for_grounding.get("grounding_width", 1920)
grounding_height = self.engine_params_for_grounding.get("grounding_height", 1080)

# Detect and scale normalized coordinates (0-1 range)
# Use < 1.0 to avoid treating tiny pixel coords like (1, 0) as normalized
if x < 1.0 and y < 1.0:
x = x * grounding_width
y = y * grounding_height

return (round(x), round(y))

# Generate and parse coordinates
response = call_llm_safe(self.grounding_model)
print("RAW GROUNDING MODEL RESPONSE:", response)
numericals = re.findall(r"\d+", response)
assert len(numericals) >= 2
return [int(numericals[0]), int(numericals[1])]
# Given the state and worker's referring expression, use the grounding model to generate (x,y)
def generate_coords(self, ref_expr: str, obs: Dict, n_samples: int = 1) -> List[int]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd gui_agents/s3/agents && rg -n 'generate_coords\(' --type=py -A 1

Repository: simular-ai/Agent-S

Length of output: 914


🏁 Script executed:

cd gui_agents/s3/agents && rg -n 'def (click|type|drag_and_drop|scroll)\(' grounding.py

Repository: simular-ai/Agent-S

Length of output: 211


🏁 Script executed:

cd gui_agents && rg -n 'generate_coords\(' --type=py

Repository: simular-ai/Agent-S

Length of output: 1403


n_samples parameter is unreachable—all callers use the default value.

All five callers of generate_coords() in this file (click at line 440, type at line 519, drag_and_drop at lines 563–564, and scroll at line 692) pass only ref_expr and obs arguments, always defaulting to n_samples=1. The multi-sample voting feature cannot be exercised without wiring the parameter to a configurable source.

Consider either:

  • Storing n_samples as an instance attribute on OSWorldACI (set from CLI args / config) and using it in each caller, or
  • Passing n_samples through from the callers.
🤖 Prompt for AI Agents
In `@gui_agents/s3/agents/grounding.py` at line 260, The generate_coords(ref_expr:
str, obs: Dict, n_samples: int = 1) function’s n_samples argument is never
exercised because all callers (click, type, drag_and_drop, scroll) call it with
only ref_expr and obs; fix by wiring n_samples through: either add an instance
attribute on OSWorldACI (e.g., self.n_samples set from CLI/config) and have each
caller call generate_coords(..., n_samples=self.n_samples), or add an explicit
n_samples parameter to the caller methods (click, type, drag_and_drop, scroll)
and forward that value into generate_coords; update the OSWorldACI constructor
or call sites to source the configured sample count so multi-sample voting is
actually used.

"""
Generate coordinates for an element using the grounding model.

Args:
ref_expr: Natural language description of the element to locate
obs: Dictionary containing 'screenshot' key with the screen image
n_samples: Number of samples for multi-sample voting (default=1 for backward compat)
Set to 3 or 5 for improved accuracy on small elements

Returns:
[x, y] coordinates in grounding model resolution

Notes:
- When n_samples > 1, takes median of multiple predictions for robustness
- Median is more robust to outliers than mean
"""
if n_samples <= 1:
# Original single-sample behavior for backward compatibility
self.grounding_model.reset()
prompt = f"Query:{ref_expr}\nOutput only the coordinate of one point in your response.\n"
self.grounding_model.add_message(
text_content=prompt, image_content=obs["screenshot"], put_text_last=True
)

response = call_llm_safe(self.grounding_model)
logger.debug("RAW GROUNDING MODEL RESPONSE: %s", response)

coords = self._parse_coordinates(response)
if coords is None:
raise ValueError(f"Failed to parse coordinates from response: {response}")

x, y = self._normalize_coordinates(coords[0], coords[1])
return [x, y]

# Multi-sample voting for improved accuracy
# Collect raw float coordinates to preserve sub-pixel precision during aggregation
samples = []

for i in range(n_samples):
self.grounding_model.reset()
prompt = f"Query:{ref_expr}\nOutput only the coordinate of one point in your response.\n"
self.grounding_model.add_message(
text_content=prompt, image_content=obs["screenshot"], put_text_last=True
)

# Use temperature > 0 to get variance across samples
response = call_llm_safe(self.grounding_model, temperature=0.3)
logger.debug("GROUNDING SAMPLE %d/%d: %s", i + 1, n_samples, response)

coords = self._parse_coordinates(response)
if coords is not None:
# Store raw float coords to preserve precision for median calculation
samples.append(coords)

if not samples:
raise ValueError(f"Failed to get valid coordinates after {n_samples} attempts")

# Use median for robustness to outliers, then normalize once at the end
x_coords = [s[0] for s in samples]
y_coords = [s[1] for s in samples]

median_x = float(np.median(x_coords))
median_y = float(np.median(y_coords))

# Normalize (scale + round) only once after aggregation
final_x, final_y = self._normalize_coordinates(median_x, median_y)

logger.debug("MULTI-SAMPLE RESULT: samples=%s, median=(%d, %d)", samples, final_x, final_y)

return [final_x, final_y]

# Calls pytesseract to generate word level bounding boxes for text grounding
def get_ocr_elements(self, b64_image_data: str) -> Tuple[str, List]:
Expand Down