ui-locator-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ui_locator/__init__.py +54 -0
- ui_locator/__main__.py +6 -0
- ui_locator/api.py +329 -0
- ui_locator/cli.py +765 -0
- ui_locator/config.py +125 -0
- ui_locator/coords.py +333 -0
- ui_locator/errors.py +29 -0
- ui_locator/image.py +59 -0
- ui_locator/mapstore.py +313 -0
- ui_locator/project.py +379 -0
- ui_locator/prompting.py +113 -0
- ui_locator/providers/__init__.py +7 -0
- ui_locator/providers/_mlx_utils.py +91 -0
- ui_locator/providers/base.py +31 -0
- ui_locator/providers/cloud_cua.py +225 -0
- ui_locator/providers/mlx_qwen.py +96 -0
- ui_locator/providers/mlx_uitars.py +91 -0
- ui_locator/providers/omniparser.py +26 -0
- ui_locator/providers/openai_compat.py +184 -0
- ui_locator/skill/SKILL.md +148 -0
- ui_locator/skillcmd.py +222 -0
- ui_locator/types.py +128 -0
- ui_locator_cli-0.1.0.dist-info/METADATA +498 -0
- ui_locator_cli-0.1.0.dist-info/RECORD +27 -0
- ui_locator_cli-0.1.0.dist-info/WHEEL +4 -0
- ui_locator_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ui_locator_cli-0.1.0.dist-info/licenses/LICENSE +17 -0
ui_locator/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""ui-locator — precise click coordinates for UI elements in screenshots.
|
|
2
|
+
|
|
3
|
+
Given a screenshot and a natural-language query, return absolute-pixel
|
|
4
|
+
coordinates of the target element, ready for a computer-use agent.
|
|
5
|
+
|
|
6
|
+
>>> from ui_locator import Locator
|
|
7
|
+
>>> loc = Locator()
|
|
8
|
+
>>> res = loc.locate("screen.png", "where is the 'Submit' button?")
|
|
9
|
+
>>> res.click_point
|
|
10
|
+
Coordinates(x=520, y=430)
|
|
11
|
+
|
|
12
|
+
Default backend on Apple Silicon is UI-TARS-1.5-7B via MLX; any
|
|
13
|
+
OpenAI-compatible endpoint (mlx-vlm server, vLLM, HuggingFace Inference
|
|
14
|
+
Endpoint, Volcengine/Doubao Ark for UI-TARS-2) works via the ``openai`` provider.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .api import Locator, locate
|
|
20
|
+
from .config import ProviderName, Settings
|
|
21
|
+
from .errors import (
|
|
22
|
+
CoordinateParseError,
|
|
23
|
+
ElementNotFoundError,
|
|
24
|
+
ProviderError,
|
|
25
|
+
ProviderNotAvailableError,
|
|
26
|
+
UILocatorError,
|
|
27
|
+
)
|
|
28
|
+
from .image import load_image
|
|
29
|
+
from .types import BoundingBox, Coordinates, Element, LocateResult, ProviderKind
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"__version__",
|
|
35
|
+
# API
|
|
36
|
+
"Locator",
|
|
37
|
+
"locate",
|
|
38
|
+
"load_image",
|
|
39
|
+
# Types
|
|
40
|
+
"BoundingBox",
|
|
41
|
+
"Coordinates",
|
|
42
|
+
"Element",
|
|
43
|
+
"LocateResult",
|
|
44
|
+
"ProviderKind",
|
|
45
|
+
# Config
|
|
46
|
+
"Settings",
|
|
47
|
+
"ProviderName",
|
|
48
|
+
# Errors
|
|
49
|
+
"UILocatorError",
|
|
50
|
+
"ProviderError",
|
|
51
|
+
"ProviderNotAvailableError",
|
|
52
|
+
"ElementNotFoundError",
|
|
53
|
+
"CoordinateParseError",
|
|
54
|
+
]
|
ui_locator/__main__.py
ADDED
ui_locator/api.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""High-level API: the :class:`Locator` and the convenience :func:`locate`.
|
|
2
|
+
|
|
3
|
+
Example
|
|
4
|
+
-------
|
|
5
|
+
>>> from ui_locator import Locator
|
|
6
|
+
>>> loc = Locator() # auto-selects MLX on Apple Silicon
|
|
7
|
+
>>> res = loc.locate("screen.png", "dove si trova il bottone 'Invia'?")
|
|
8
|
+
>>> res.click_point
|
|
9
|
+
Coordinates(x=742, y=511)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
|
|
17
|
+
from PIL import Image
|
|
18
|
+
|
|
19
|
+
from .config import OpenAIPromptStyle, ProviderName, Settings, get_settings
|
|
20
|
+
from .errors import (
|
|
21
|
+
ElementNotFoundError,
|
|
22
|
+
ProviderError,
|
|
23
|
+
ProviderNotAvailableError,
|
|
24
|
+
UILocatorError,
|
|
25
|
+
)
|
|
26
|
+
from .image import ImageLike, load_image, peek_size
|
|
27
|
+
from .mapstore import CROP_MAX_DIFF, ElementMap, MapEntry, crop_diff, make_crop
|
|
28
|
+
from .providers.base import GroundingProvider
|
|
29
|
+
from .types import Element, LocateResult, MapMeta
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Locator:
|
|
33
|
+
"""Locate UI elements in screenshots via natural-language queries.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
settings:
|
|
38
|
+
Override the auto-loaded :class:`Settings`. Useful to force a provider
|
|
39
|
+
or model programmatically.
|
|
40
|
+
provider:
|
|
41
|
+
Force a specific provider instance (mostly for testing).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
settings: Settings | None = None,
|
|
47
|
+
*,
|
|
48
|
+
provider: GroundingProvider | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._settings = settings or get_settings()
|
|
51
|
+
self._provider: GroundingProvider | None = provider
|
|
52
|
+
self._map: ElementMap | None = None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def settings(self) -> Settings:
|
|
56
|
+
return self._settings
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def element_map(self) -> ElementMap:
|
|
60
|
+
"""The on-disk element map (lazily opened)."""
|
|
61
|
+
if self._map is None:
|
|
62
|
+
self._map = ElementMap(self._settings.map_dir)
|
|
63
|
+
return self._map
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def provider(self) -> GroundingProvider:
|
|
67
|
+
if self._provider is None:
|
|
68
|
+
self._provider = self._select_provider()
|
|
69
|
+
return self._provider
|
|
70
|
+
|
|
71
|
+
def _select_provider(self) -> GroundingProvider:
|
|
72
|
+
"""Pick a provider according to settings + environment."""
|
|
73
|
+
choice = self._settings.provider
|
|
74
|
+
|
|
75
|
+
if choice == ProviderName.MLX:
|
|
76
|
+
return self._make_mlx()
|
|
77
|
+
if choice == ProviderName.QWEN:
|
|
78
|
+
# 'qwen' means Qwen grounding wherever it is available: the cloud
|
|
79
|
+
# endpoint when one is configured, the local checkpoint otherwise.
|
|
80
|
+
# Either way the Qwen prompt style is the only one whose <box>
|
|
81
|
+
# output the parser understands.
|
|
82
|
+
if self._cloud_configured():
|
|
83
|
+
return self._make_openai(prompt=OpenAIPromptStyle.QWEN)
|
|
84
|
+
return self._make_qwen()
|
|
85
|
+
if choice == ProviderName.QWEN_LOCAL:
|
|
86
|
+
return self._make_qwen()
|
|
87
|
+
if choice == ProviderName.OPENAI:
|
|
88
|
+
return self._make_openai()
|
|
89
|
+
if choice == ProviderName.ANTHROPIC:
|
|
90
|
+
return self._make_anthropic()
|
|
91
|
+
|
|
92
|
+
# AUTO: prefer MLX on Apple Silicon, fall back to OpenAI if configured.
|
|
93
|
+
if platform.system() == "Darwin" and platform.machine() == "arm64":
|
|
94
|
+
try:
|
|
95
|
+
return self._make_mlx()
|
|
96
|
+
except ProviderNotAvailableError:
|
|
97
|
+
pass
|
|
98
|
+
# OpenAI-compatible cloud (if configured).
|
|
99
|
+
try:
|
|
100
|
+
return self._make_openai()
|
|
101
|
+
except ProviderNotAvailableError:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
raise UILocatorError(
|
|
105
|
+
"No grounding provider available. Either:\n"
|
|
106
|
+
" - install the MLX extra on Apple Silicon: "
|
|
107
|
+
"uv pip install -e '.[mlx,uitars]', or\n"
|
|
108
|
+
" - configure a cloud endpoint with "
|
|
109
|
+
"UI_LOCATOR_OPENAI_BASE_URL / UI_LOCATOR_OPENAI_API_KEY / "
|
|
110
|
+
"UI_LOCATOR_OPENAI_MODEL (uv pip install -e '.[cloud]')."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _make_mlx(self) -> GroundingProvider:
|
|
114
|
+
from .providers.mlx_uitars import MlxUITarsProvider
|
|
115
|
+
|
|
116
|
+
return MlxUITarsProvider(self._settings)
|
|
117
|
+
|
|
118
|
+
def _make_qwen(self) -> GroundingProvider:
|
|
119
|
+
from .providers.mlx_qwen import MlxQwenProvider
|
|
120
|
+
|
|
121
|
+
return MlxQwenProvider(self._settings)
|
|
122
|
+
|
|
123
|
+
def _cloud_configured(self) -> bool:
|
|
124
|
+
return bool(self._settings.openai_base_url or os.environ.get("OPENAI_BASE_URL"))
|
|
125
|
+
|
|
126
|
+
def _make_openai(self, *, prompt: OpenAIPromptStyle | None = None) -> GroundingProvider:
|
|
127
|
+
from .providers.openai_compat import OpenAICompatProvider
|
|
128
|
+
|
|
129
|
+
settings = self._settings
|
|
130
|
+
if prompt is not None and settings.openai_prompt != prompt:
|
|
131
|
+
settings = settings.model_copy(update={"openai_prompt": prompt})
|
|
132
|
+
return OpenAICompatProvider(settings)
|
|
133
|
+
|
|
134
|
+
def _make_anthropic(self) -> GroundingProvider:
|
|
135
|
+
from .providers.cloud_cua import AnthropicProvider
|
|
136
|
+
|
|
137
|
+
return AnthropicProvider(self._settings)
|
|
138
|
+
|
|
139
|
+
def _engine_id(self, provider_name: str) -> str:
|
|
140
|
+
"""A short provenance string for the backend that found an element."""
|
|
141
|
+
s = self._settings
|
|
142
|
+
model = {
|
|
143
|
+
"mlx-uitars": s.model,
|
|
144
|
+
"mlx-qwen": s.qwen_model,
|
|
145
|
+
"openai": s.openai_model or "",
|
|
146
|
+
"anthropic": s.anthropic_model,
|
|
147
|
+
}.get(provider_name, "")
|
|
148
|
+
return f"{provider_name}:{model}" if model else provider_name
|
|
149
|
+
|
|
150
|
+
def locate(
|
|
151
|
+
self,
|
|
152
|
+
image: ImageLike,
|
|
153
|
+
query: str | None = None,
|
|
154
|
+
*,
|
|
155
|
+
element_id: str | None = None,
|
|
156
|
+
session: str | None = None,
|
|
157
|
+
use_map: bool | None = None,
|
|
158
|
+
verify: bool | None = None,
|
|
159
|
+
) -> LocateResult:
|
|
160
|
+
"""Locate the UI element described by ``query`` in ``image``.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
image: A path, ``PIL.Image``, or bytes.
|
|
164
|
+
query: Natural-language description / intent, e.g.
|
|
165
|
+
``"where is the 'Invia' button?"`` or ``"chiudi il terminale"``.
|
|
166
|
+
Optional when ``element_id`` already has a map entry.
|
|
167
|
+
element_id: Dotted, caller-declared element id (e.g.
|
|
168
|
+
``"sap.screen.submit"``). Asserts that this element sits at a
|
|
169
|
+
stable position on that screen: a stored hit is returned
|
|
170
|
+
without decoding the screenshot or loading a model, and a
|
|
171
|
+
model-discovered position is written back for next time.
|
|
172
|
+
session: Tag recorded on learned entries, so a whole run can be
|
|
173
|
+
dropped later with ``ElementMap.forget(session=...)``.
|
|
174
|
+
use_map: Set ``False`` to skip the lookup and re-ask the model;
|
|
175
|
+
the fresh result still replaces the stored entry, which is the
|
|
176
|
+
recovery path after a stored point turned out to be wrong.
|
|
177
|
+
verify: On a map hit, compare a small crop around the stored point
|
|
178
|
+
against the screenshot; on mismatch fall back to the model.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
A :class:`LocateResult` whose ``best`` element has absolute-pixel
|
|
182
|
+
``click_point`` (and ``bbox`` when available). ``result.map``
|
|
183
|
+
carries the provenance when ``element_id`` was given.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ElementNotFoundError: if the model produced no usable coordinates,
|
|
187
|
+
or if no query was given and the map has no entry.
|
|
188
|
+
ProviderError: if the backend call or parsing failed.
|
|
189
|
+
ProviderNotAvailableError: if dependencies are missing.
|
|
190
|
+
"""
|
|
191
|
+
settings = self._settings
|
|
192
|
+
session = session if session is not None else settings.session
|
|
193
|
+
verify = settings.verify if verify is None else verify
|
|
194
|
+
read_map = bool(element_id) and (settings.use_map if use_map is None else use_map)
|
|
195
|
+
|
|
196
|
+
if read_map and element_id:
|
|
197
|
+
hit = self._map_lookup(element_id, image, verify=verify)
|
|
198
|
+
if hit is not None:
|
|
199
|
+
return hit.model_copy(update={"query": query or hit.query})
|
|
200
|
+
|
|
201
|
+
if query is None or not query.strip():
|
|
202
|
+
if element_id:
|
|
203
|
+
raise ElementNotFoundError(
|
|
204
|
+
f"No map entry for id {element_id!r} at this screenshot size. "
|
|
205
|
+
"Pass a query so the model can locate it."
|
|
206
|
+
)
|
|
207
|
+
raise ValueError("query must be a non-empty string.")
|
|
208
|
+
|
|
209
|
+
img = load_image(image)
|
|
210
|
+
provider = self.provider
|
|
211
|
+
|
|
212
|
+
candidates = provider.ground(img, query)
|
|
213
|
+
if not candidates:
|
|
214
|
+
raise ElementNotFoundError(
|
|
215
|
+
f"No element found for query {query!r} via provider '{provider.name}'."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
candidates.sort(key=lambda e: e.confidence, reverse=True)
|
|
219
|
+
best = candidates[0]
|
|
220
|
+
|
|
221
|
+
meta: MapMeta | None = None
|
|
222
|
+
if element_id:
|
|
223
|
+
meta = self._map_store(element_id, img, best, query, session)
|
|
224
|
+
|
|
225
|
+
return LocateResult(
|
|
226
|
+
query=query,
|
|
227
|
+
image_size=img.size,
|
|
228
|
+
best=best,
|
|
229
|
+
candidates=candidates,
|
|
230
|
+
map=meta,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _map_lookup(
|
|
234
|
+
self, element_id: str, image: ImageLike, *, verify: bool
|
|
235
|
+
) -> LocateResult | None:
|
|
236
|
+
"""Return a stored result for ``element_id``, or ``None`` to ask the model."""
|
|
237
|
+
size = peek_size(image)
|
|
238
|
+
if size is None:
|
|
239
|
+
return None
|
|
240
|
+
entry = self.element_map.lookup(element_id, size)
|
|
241
|
+
if entry is None:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
verified: bool | None = None
|
|
245
|
+
diff: float | None = None
|
|
246
|
+
if verify:
|
|
247
|
+
if entry.crop:
|
|
248
|
+
diff = crop_diff(load_image(image), entry.click_point, entry.crop)
|
|
249
|
+
verified = diff is not None and diff <= CROP_MAX_DIFF
|
|
250
|
+
if not verified:
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
self.element_map.record_hit(element_id, entry)
|
|
254
|
+
return LocateResult(
|
|
255
|
+
query=entry.query,
|
|
256
|
+
image_size=size,
|
|
257
|
+
best=entry.to_element(),
|
|
258
|
+
candidates=[],
|
|
259
|
+
map=MapMeta(
|
|
260
|
+
element_id=element_id,
|
|
261
|
+
hit=True,
|
|
262
|
+
source=entry.source,
|
|
263
|
+
hits=entry.hits + 1,
|
|
264
|
+
verified=verified,
|
|
265
|
+
diff=diff,
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def _map_store(
|
|
270
|
+
self,
|
|
271
|
+
element_id: str,
|
|
272
|
+
img: Image.Image,
|
|
273
|
+
best: Element,
|
|
274
|
+
query: str,
|
|
275
|
+
session: str | None,
|
|
276
|
+
) -> MapMeta:
|
|
277
|
+
"""Write a model-discovered element back to the map."""
|
|
278
|
+
entry = MapEntry(
|
|
279
|
+
click_point=(best.click_point.x, best.click_point.y),
|
|
280
|
+
bbox=(
|
|
281
|
+
(best.bbox.x1, best.bbox.y1, best.bbox.x2, best.bbox.y2) if best.bbox else None
|
|
282
|
+
),
|
|
283
|
+
size=img.size,
|
|
284
|
+
engine=self._engine_id(self.provider.name),
|
|
285
|
+
source="model",
|
|
286
|
+
query=query,
|
|
287
|
+
label=best.label,
|
|
288
|
+
confidence=best.confidence,
|
|
289
|
+
session=session,
|
|
290
|
+
crop=make_crop(img, (best.click_point.x, best.click_point.y)),
|
|
291
|
+
)
|
|
292
|
+
saved = self.element_map.save(element_id, entry)
|
|
293
|
+
return MapMeta(
|
|
294
|
+
element_id=element_id,
|
|
295
|
+
hit=False,
|
|
296
|
+
source="model",
|
|
297
|
+
stored=saved is entry,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def locate(
|
|
302
|
+
image: ImageLike,
|
|
303
|
+
query: str | None = None,
|
|
304
|
+
*,
|
|
305
|
+
element_id: str | None = None,
|
|
306
|
+
**kwargs: object,
|
|
307
|
+
) -> LocateResult:
|
|
308
|
+
"""One-shot convenience wrapper around :meth:`Locator.locate`.
|
|
309
|
+
|
|
310
|
+
Extra keyword arguments are forwarded to :class:`Settings` (e.g.
|
|
311
|
+
``model=..., think=True, session="123"``).
|
|
312
|
+
"""
|
|
313
|
+
settings = Settings(**kwargs) # type: ignore[arg-type]
|
|
314
|
+
return Locator(settings=settings).locate(image, query, element_id=element_id)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
__all__ = [
|
|
318
|
+
"Locator",
|
|
319
|
+
"locate",
|
|
320
|
+
"Element",
|
|
321
|
+
"ElementMap",
|
|
322
|
+
"MapEntry",
|
|
323
|
+
"Image",
|
|
324
|
+
"LocateResult",
|
|
325
|
+
"ProviderError",
|
|
326
|
+
"ProviderNotAvailableError",
|
|
327
|
+
"ElementNotFoundError",
|
|
328
|
+
"UILocatorError",
|
|
329
|
+
]
|