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 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
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m ui_locator``."""
2
+
3
+ from .cli import entrypoint
4
+
5
+ if __name__ == "__main__": # pragma: no cover
6
+ entrypoint()
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
+ ]