File size: 9,871 Bytes
59022ca
e187916
 
59022ca
 
 
 
e187916
 
 
 
59022ca
e187916
59022ca
e187916
 
 
59022ca
 
 
 
 
 
 
 
 
 
e187916
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59022ca
 
e187916
 
 
 
 
 
 
 
 
 
 
 
59022ca
 
 
 
 
 
 
 
 
e187916
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59022ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e187916
 
 
 
 
59022ca
 
 
 
 
 
 
 
 
 
 
 
e187916
 
59022ca
 
 
 
 
 
 
 
e187916
 
59022ca
 
 
e187916
 
 
59022ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e187916
 
59022ca
 
 
e187916
59022ca
 
 
 
 
 
 
 
e187916
59022ca
e187916
59022ca
 
e187916
59022ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
"""The Observation contract: screen frames and the element index built from them.

The harness never asks the model to *find* anything. OCR or the accessibility tree
produces a labelled element index (see :mod:`tiny_computer_agent.perception`); the
model only chooses among those labels.

Every :class:`Element` carries **absolute screen coordinates**, whoever built it.
"""

from __future__ import annotations

import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol, Sequence


def normalise_label(text: str) -> str:
    """Lower-case and keep only letters and digits.

    OCR routinely loses or invents spaces ("Untitleddocument-unsavedchanges") and
    models add punctuation ("Save."), so labels are compared on their alphanumeric
    core - otherwise nothing a model says ever matches what was seen on screen.
    """
    return "".join(character for character in text.lower() if character.isalnum())


@dataclass(frozen=True)
class Element:
    """A clickable thing, located by the platform — not by the model."""

    label: str
    kind: str = "button"
    x: int = 0
    y: int = 0
    width: int = 0
    height: int = 0

    def __post_init__(self) -> None:
        if not self.label.strip():
            raise ValueError("Element.label must be non-empty")
        if min(self.x, self.y, self.width, self.height) < 0:
            raise ValueError("Element geometry must be non-negative")

    @property
    def center(self) -> tuple[int, int]:
        if self.width and self.height:
            return (self.x + self.width // 2, self.y + self.height // 2)
        return (self.x, self.y)

    def matches(self, needle: str) -> bool:
        wanted = normalise_label(needle)
        return bool(wanted) and wanted in normalise_label(self.label)


@dataclass(frozen=True)
class Observation:
    """One frame of the world: pixels plus whatever index we could build."""

    index: int = 0
    frame: Any = None
    elements: tuple[Element, ...] = ()
    text: str = ""

    def find(self, label: str) -> Element | None:
        """Exact label first, then substring; None if nothing matches.

        Comparison is on normalised text (see :func:`normalise_label`), so
        ``find("Save")`` still matches a label the OCR rendered as ``"Save ."``.
        """
        wanted = normalise_label(label)
        if not wanted:
            return None
        exact = [e for e in self.elements if normalise_label(e.label) == wanted]
        if exact:
            return exact[0]
        loose = [e for e in self.elements if e.matches(label)]
        return loose[0] if loose else None

    def summary(self, max_elements: int = 40) -> str:
        """Compact text form — this is what a model would be shown."""
        lines = [f"step {self.index}"]
        if self.text:
            lines.append("visible text: " + " ".join(self.text.split())[:400])
        if self.elements:
            lines.append("clickable elements:")
            for e in self.elements[:max_elements]:
                x, y = e.center
                lines.append(f"  - {e.label!r} ({e.kind}) at {x},{y}")
            if len(self.elements) > max_elements:
                lines.append(f"  ... and {len(self.elements) - max_elements} more")
        if not self.elements and not self.text:
            lines.append("no element index available (pixels only)")
        return "\n".join(lines)


class ScreenSource(Protocol):
    def capture(self) -> Observation: ...


class FakeScreen:
    """Scripted screen: each capture() advances one frame.

    Used to exercise the loop with no display at all. The final frame repeats
    forever, so a runaway agent cannot exhaust the script and crash.
    """

    def __init__(self, frames: Sequence[Sequence[Element]], *, text: str = "") -> None:
        if not frames:
            raise ValueError("FakeScreen needs at least one frame")
        self._frames: tuple[tuple[Element, ...], ...] = tuple(tuple(f) for f in frames)
        self._text = text
        self.captures = 0

    def capture(self) -> Observation:
        i = min(self.captures, len(self._frames) - 1)
        observation = Observation(
            index=self.captures,
            frame=f"frame-{i}",
            elements=self._frames[i],
            text=self._text,
        )
        self.captures += 1
        return observation


class ImageScreen:
    """A static screen source: replay one screenshot (or a sequence of them).

    Lets the full pipeline - OCR, index, menu, model, action - be exercised with no
    display at all, and lets you replay a captured failure offline. Accepts a path,
    a PIL image, or a sequence of either.
    """

    def __init__(self, source: Any) -> None:
        from PIL import Image

        frames = [source] if isinstance(source, (str, Path)) or not isinstance(source, (list, tuple)) else list(source)
        self._images: list[Any] = []
        for frame in frames:
            if isinstance(frame, (str, Path)):
                self._images.append(Image.open(str(frame)).convert("RGB"))
            else:
                self._images.append(frame)
        if not self._images:
            raise ValueError("ImageScreen needs at least one image")
        self.captures = 0

    def capture(self) -> Observation:
        index = min(self.captures, len(self._images) - 1)
        self.captures += 1
        return Observation(index=self.captures - 1, frame=self._images[index], elements=(), text="")


class ScreenUnavailable(RuntimeError):
    """Raised when real capture is requested but the machine cannot provide it."""


class GrabScreen:
    """Capture the real desktop as an image.

    Prefers ``mss`` (fast, multi-monitor) and falls back to Pillow's ``ImageGrab``.
    Caps are deliberately absent: a frame pixel must be a *screen* pixel, which is
    what lets every detector produce Elements with absolute screen coordinates.

    Two options break that identity, so both are explicit: ``monitor`` may select a
    display whose origin is not (0, 0), and ``max_width`` downscales a wide capture
    (faster OCR, and a smaller image for a model to look at).
    :meth:`map_to_screen` converts a frame coordinate back to a real one, and
    :class:`~tiny_computer_agent.perception.IndexedScreen` applies it to every
    detected element, so a click never lands on the wrong pixel.
    """

    def __init__(self, *, monitor: int = 1, max_width: int | None = None,
                 scale: float | None = None) -> None:
        if monitor < 0:
            raise ValueError("monitor must be 0 (all displays) or a positive index")
        if max_width is not None and max_width < 1:
            raise ValueError("max_width must be positive")
        if scale is not None and scale <= 0:
            raise ValueError("scale must be positive")
        self.monitor = monitor
        self.max_width = max_width
        self.scale = scale
        self.origin: tuple[int, int] = (0, 0)
        self.frame_scale: float = 1.0
        self._index = 0

    def capture(self) -> Observation:
        image, origin = self._grab()
        native_width = image.width
        if self.max_width is not None and image.width > self.max_width:
            ratio = self.max_width / image.width
            image = image.resize((self.max_width, max(1, round(image.height * ratio))))
        if self.scale is not None:
            self.frame_scale = float(self.scale)
        else:
            self.frame_scale = native_width / image.width
        self.origin = origin
        observation = Observation(index=self._index, frame=image, elements=(), text="")
        self._index += 1
        return observation

    def map_to_screen(self, element: Element) -> Element:
        """Frame pixel -> absolute screen pixel, honouring the origin and any scale."""
        if self.frame_scale == 1.0 and self.origin == (0, 0):
            return element
        factor = self.frame_scale
        return Element(
            label=element.label, kind=element.kind,
            x=round(element.x * factor) + self.origin[0],
            y=round(element.y * factor) + self.origin[1],
            width=max(1, round(element.width * factor)),
            height=max(1, round(element.height * factor)),
        )

    def _grab(self) -> tuple[Any, tuple[int, int]]:
        """(image, screen position of the image's top-left pixel)."""
        try:
            import mss
        except ImportError:
            return self._grab_with_pillow()
        try:
            from PIL import Image

            with mss.mss() as sct:
                monitors = sct.monitors
                monitor = monitors[self.monitor] if self.monitor < len(monitors) else monitors[-1]
                shot = sct.grab(monitor)
                image = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
            return image, (int(monitor.get("left", 0)), int(monitor.get("top", 0)))
        except Exception as exc:
            raise ScreenUnavailable(
                f"screen capture failed (no display?): {type(exc).__name__}: {exc}"
            ) from exc

    def _grab_with_pillow(self) -> tuple[Any, tuple[int, int]]:
        try:
            from PIL import ImageGrab
        except ImportError as exc:  # pragma: no cover - pillow is a desktop extra
            raise ScreenUnavailable(
                "screen capture needs mss or Pillow: pip install \'tiny-computer-agent[desktop]\'"
            ) from exc
        try:
            if sys.platform.startswith("win"):
                image = ImageGrab.grab(all_screens=self.monitor == 0)
            else:
                image = ImageGrab.grab()
        except Exception as exc:
            raise ScreenUnavailable(
                f"screen capture failed (no display?): {type(exc).__name__}: {exc}"
            ) from exc
        return image.convert("RGB"), (0, 0)