@saifymatteo/pi-vision 0.0.0-stage → 0.1.0
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.
- package/LICENSE +661 -0
- package/NOTICE.md +5 -0
- package/README.md +42 -2
- package/examples/vision-toolkit.example.json +12 -0
- package/package.json +36 -4
- package/skills/vision-tools/LICENSE +21 -0
- package/skills/vision-tools/SKILL.md +134 -0
- package/skills/vision-tools/agents/openai.yaml +4 -0
- package/skills/vision-tools/bin/crop +94 -0
- package/skills/vision-tools/bin/detect +18 -0
- package/skills/vision-tools/bin/glance +98 -0
- package/skills/vision-tools/bin/ground +18 -0
- package/skills/vision-tools/bin/trace +134 -0
- package/skills/vision-tools/bin/vision-doctor +168 -0
- package/skills/vision-tools/detect.py +56 -0
- package/skills/vision-tools/ground.py +216 -0
- package/skills/vision-tools/references/gui.md +93 -0
- package/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
- package/skills/vision-tools/references/restore-graphic.md +92 -0
- package/skills/vision-tools/references/restore-structure.md +47 -0
- package/skills/vision-tools/references/restore-ui.md +200 -0
- package/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/skills/vision-tools/scripts/html_shot.py +111 -0
- package/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/skills/vision-tools/scripts/pixel_diff.py +88 -0
- package/skills/vision-tools/vision_client.py +581 -0
- package/src/index.ts +728 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""vision-doctor: validate the vision-tools config chain before a task, and
|
|
3
|
+
diagnose any vision error afterwards.
|
|
4
|
+
|
|
5
|
+
Resolves vision-toolkit.json the same way every tool in this skill does
|
|
6
|
+
(model + fallbacks -> providers -> models.json/models-store.json ->
|
|
7
|
+
package-provider configs -> auth.json), prints what each candidate resolved
|
|
8
|
+
to, and can fire one real 1-token request (--smoke) to prove the chain
|
|
9
|
+
end-to-end. On any vision error, run this first: never scrape auth.json and
|
|
10
|
+
never hand-export VISION_* environment variables.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import base64
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
20
|
+
|
|
21
|
+
# Windows consoles default to cp1252; descriptions can contain any Unicode.
|
|
22
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
23
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
24
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
25
|
+
|
|
26
|
+
from vision_client import ( # noqa: E402
|
|
27
|
+
VisionError,
|
|
28
|
+
apply_resolved_to_env,
|
|
29
|
+
describe_image,
|
|
30
|
+
resolve_vision_config,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# 8x8 red PNG: a fixed tiny image so --smoke costs one token of attention.
|
|
34
|
+
SMOKE_PNG = base64.b64decode(
|
|
35
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGElEQVR4nGP8z8Dwn4EIwESMolGK"
|
|
36
|
+
"SjEAAJ0QBAE5tHqKAAAAAElFTkSuQmCC"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
SMOKE_PROMPT = "What colour is this square? Answer with one word."
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def fingerprint(key):
|
|
43
|
+
if not key:
|
|
44
|
+
return None
|
|
45
|
+
return key[:6] + "..." + key[-4:] if len(key) > 12 else "***"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def print_resolution(resolution):
|
|
49
|
+
cfg = resolution["cfg"]
|
|
50
|
+
print("extensions/vision-toolkit.json (%s):" % resolution["agent_dir"])
|
|
51
|
+
shown = {k: v for k, v in cfg.items() if k != "providers"}
|
|
52
|
+
print(" " + repr(shown))
|
|
53
|
+
if cfg.get("providers"):
|
|
54
|
+
print(" providers overrides present for: %s" % ", ".join(cfg["providers"]))
|
|
55
|
+
print()
|
|
56
|
+
attempts = resolution["attempts"]
|
|
57
|
+
if not attempts:
|
|
58
|
+
print("no candidates found in vision-toolkit.json")
|
|
59
|
+
return
|
|
60
|
+
# Group by base spec: a bare-id primary resolves through several providers
|
|
61
|
+
# in search order (openrouter -> anthropic -> ... -> 9router); those
|
|
62
|
+
# attempts are one candidate's resolution trail, not configured fallbacks.
|
|
63
|
+
groups = []
|
|
64
|
+
for attempt in attempts:
|
|
65
|
+
base = attempt["spec"].split(" (as ")[0]
|
|
66
|
+
if not groups or groups[-1][0] != base:
|
|
67
|
+
groups.append((base, []))
|
|
68
|
+
groups[-1][1].append(attempt)
|
|
69
|
+
for gi, (base, group) in enumerate(groups):
|
|
70
|
+
head = "primary candidate" if gi == 0 else "fallback candidate %d" % gi
|
|
71
|
+
print("%s: %s" % (head, base))
|
|
72
|
+
ok = next((a for a in group if a.get("ok")), None)
|
|
73
|
+
for a in group:
|
|
74
|
+
if a is not ok:
|
|
75
|
+
print(" tried as %s" % a["spec"])
|
|
76
|
+
print(" FAILED: %s" % a["error"])
|
|
77
|
+
if ok is not None:
|
|
78
|
+
if len(group) > 1:
|
|
79
|
+
print(" resolved as %s" % ok["spec"])
|
|
80
|
+
_print_attempt(ok)
|
|
81
|
+
else:
|
|
82
|
+
print(" status -> FAILED: %s" % group[0]["error"])
|
|
83
|
+
print()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _print_attempt(attempt):
|
|
87
|
+
print(" model -> %s/%s" % (attempt["provider"], attempt["model_id"]))
|
|
88
|
+
print(" catalog -> %s" % ("static catalog entry" if attempt.get("in_catalog")
|
|
89
|
+
else "runtime provider (id validated at request time)"))
|
|
90
|
+
print(" image -> %s" % ({True: "yes", False: "NO - not vision-capable",
|
|
91
|
+
None: "unknown (runtime catalog)"}[attempt["image_capable"]]))
|
|
92
|
+
print(" baseUrl -> %s (from %s)" % (attempt["base_url"], attempt["base_source"]))
|
|
93
|
+
print(" apiKey -> %s (from %s)" % (fingerprint(attempt.get("key_fingerprint") or ""),
|
|
94
|
+
attempt["key_source"]))
|
|
95
|
+
print(" protocol -> %s" % attempt["protocol"])
|
|
96
|
+
print(" status -> OK")
|
|
97
|
+
|
|
98
|
+
def main():
|
|
99
|
+
parser = argparse.ArgumentParser(description="Validate the vision-tools config chain.")
|
|
100
|
+
parser.add_argument("--smoke", action="store_true",
|
|
101
|
+
help="send one tiny real image request through the primary candidate")
|
|
102
|
+
parser.add_argument("--json", action="store_true", help="machine-readable resolution report")
|
|
103
|
+
args = parser.parse_args()
|
|
104
|
+
|
|
105
|
+
resolution = resolve_vision_config()
|
|
106
|
+
config = resolution["config"]
|
|
107
|
+
|
|
108
|
+
if args.json:
|
|
109
|
+
import json
|
|
110
|
+
report = {"config": None, "attempts": resolution["attempts"],
|
|
111
|
+
"cfg": {k: v for k, v in resolution["cfg"].items() if k != "providers"}}
|
|
112
|
+
if config:
|
|
113
|
+
report["config"] = {k: v for k, v in config.items() if k != "api_key"}
|
|
114
|
+
report["config"]["key_fingerprint"] = fingerprint(config.get("api_key"))
|
|
115
|
+
print(json.dumps(report, indent=1, default=str))
|
|
116
|
+
return 0 if config else 1
|
|
117
|
+
|
|
118
|
+
print("vision-doctor: resolving the vision-tools chain\n")
|
|
119
|
+
print_resolution(resolution)
|
|
120
|
+
|
|
121
|
+
if not config:
|
|
122
|
+
print("SUMMARY: UNRESOLVED - no candidate has both a key and a baseUrl.")
|
|
123
|
+
print("Fix the first failing candidate above, then re-run vision-doctor.")
|
|
124
|
+
return 1
|
|
125
|
+
|
|
126
|
+
fallback_count = max(0, len(resolution["resolved"]) - 1)
|
|
127
|
+
extra = ""
|
|
128
|
+
if fallback_count:
|
|
129
|
+
extra = " (+%d fallback(s) resolved)" % fallback_count
|
|
130
|
+
print("SUMMARY: primary resolves%s." % extra)
|
|
131
|
+
primary = resolution["resolved"][0] if resolution["resolved"] else None
|
|
132
|
+
if primary and primary.get("image_capable") is False:
|
|
133
|
+
print("WARNING: the primary model is not vision-capable - pick one with "
|
|
134
|
+
'"image" input.')
|
|
135
|
+
if not args.smoke:
|
|
136
|
+
print("Run 'vision-doctor --smoke' to verify the API end-to-end.")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
if fallback_count:
|
|
140
|
+
print("Note: --smoke tests the PRIMARY only; the CLIs fail over to "
|
|
141
|
+
"fallbacks automatically when it errors.")
|
|
142
|
+
apply_resolved_to_env(config)
|
|
143
|
+
import tempfile
|
|
144
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as fh:
|
|
145
|
+
fh.write(SMOKE_PNG)
|
|
146
|
+
smoke_path = fh.name
|
|
147
|
+
try:
|
|
148
|
+
data_url = "data:image/png;base64," + base64.b64encode(SMOKE_PNG).decode()
|
|
149
|
+
started = time.time()
|
|
150
|
+
answer = describe_image(data_url, SMOKE_PROMPT, max_tokens=16)
|
|
151
|
+
elapsed = time.time() - started
|
|
152
|
+
print("SMOKE OK (%s, %.1fs): %s" % (config["model_id"], elapsed, answer.strip()[:80]))
|
|
153
|
+
return 0
|
|
154
|
+
except VisionError as exc:
|
|
155
|
+
print("SMOKE FAILED: %s" % exc)
|
|
156
|
+
print("The chain resolves but the API call failed. If this persists "
|
|
157
|
+
"(auth, model unknown, or upstream 4xx), fix the primary or let a "
|
|
158
|
+
'fallback take over by moving it up in "fallbacks".')
|
|
159
|
+
return 2
|
|
160
|
+
finally:
|
|
161
|
+
try:
|
|
162
|
+
Path(smoke_path).unlink()
|
|
163
|
+
except OSError:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
sys.exit(main())
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from PIL import Image
|
|
8
|
+
except ImportError:
|
|
9
|
+
Image = None
|
|
10
|
+
|
|
11
|
+
from ground import GroundError, _position, locate
|
|
12
|
+
from vision_client import VisionError
|
|
13
|
+
|
|
14
|
+
DEFAULT_CATEGORY = ("UI element (buttons, links, inputs, icons, labels, "
|
|
15
|
+
"headings, images, badges)")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_target(category: str | None) -> str:
|
|
19
|
+
return (f"every distinct {category or DEFAULT_CATEGORY} — "
|
|
20
|
+
"include the exact visible text in each label")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_inventory(matches, width: int, height: int) -> list[str]:
|
|
24
|
+
lines = []
|
|
25
|
+
for index, match in enumerate(matches, 1):
|
|
26
|
+
x1, y1, x2, y2 = match.bbox
|
|
27
|
+
position = _position(match.bbox, width, height)
|
|
28
|
+
lines.append(f"{index}. {position} {match.label} x1: {x1}, y1: {y1}, x2: {x2}, y2: {y2}")
|
|
29
|
+
return lines or ["no elements detected"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main() -> None:
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
prog="detect",
|
|
35
|
+
description="Inventory the elements in an image (or a region) with pixel bounding boxes",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument("image", type=Path, help="path to the image")
|
|
38
|
+
parser.add_argument("category", nargs="?",
|
|
39
|
+
help='restrict to a category, e.g. "buttons" or "icons" (default: all UI elements)')
|
|
40
|
+
parser.add_argument("--region", metavar="X1,Y1,X2,Y2",
|
|
41
|
+
help="inventory only this pixel box; output stays in original-image coordinates")
|
|
42
|
+
args = parser.parse_args()
|
|
43
|
+
try:
|
|
44
|
+
matches = locate(args.image.expanduser(), build_target(args.category), region=args.region)
|
|
45
|
+
if Image is None:
|
|
46
|
+
raise GroundError("detect requires Pillow; install the optional dependency pillow first")
|
|
47
|
+
with Image.open(args.image.expanduser()) as image:
|
|
48
|
+
width, height = image.size
|
|
49
|
+
except (GroundError, VisionError) as exc:
|
|
50
|
+
parser.exit(1, f"detect: {exc}\n")
|
|
51
|
+
for line in format_inventory(matches, width, height):
|
|
52
|
+
print(line)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
main()
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from PIL import Image
|
|
15
|
+
except ImportError:
|
|
16
|
+
Image = None
|
|
17
|
+
|
|
18
|
+
from vision_client import VisionError, describe_image, image_path_to_data_url, load_default_env
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Match:
|
|
23
|
+
label: str
|
|
24
|
+
bbox: tuple[int, int, int, int]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GroundError(Exception):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_prompt(target: str) -> str:
|
|
32
|
+
return (
|
|
33
|
+
"Locate every visible object or region matching this target:\n"
|
|
34
|
+
f"{target}\n\n"
|
|
35
|
+
'Return only a JSON array. Each item must contain "box_2d" as '
|
|
36
|
+
'[y0, x0, y1, x1] on a 0-1000 grid and "label" as a short description. '
|
|
37
|
+
"Use tight boxes in the original image. Return [] when nothing matches."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _json_text(text: str) -> str:
|
|
42
|
+
cleaned = str(text or "").strip()
|
|
43
|
+
fenced = re.findall(r"```(?:json)?\s*(.*?)\s*```", cleaned, re.DOTALL | re.IGNORECASE)
|
|
44
|
+
return (fenced[-1] if fenced else cleaned).strip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fallback_items(text: str) -> list[dict[str, Any]]:
|
|
48
|
+
items = []
|
|
49
|
+
object_pattern = re.compile(r"\{[^{}]*['\"](?:box_2d|bbox_2d|box2d|bbox|box)['\"]\s*:\s*\[[^\]]+\][^{}]*\}", re.DOTALL)
|
|
50
|
+
box_pattern = re.compile(r"['\"](?:box_2d|bbox_2d|box2d|bbox|box)['\"]\s*:\s*\[([^\]]+)\]", re.DOTALL)
|
|
51
|
+
label_pattern = re.compile(r"['\"](?:label|caption|description)['\"]\s*:\s*['\"]([^'\"]+)['\"]", re.DOTALL)
|
|
52
|
+
for match in object_pattern.finditer(text):
|
|
53
|
+
block = match.group(0)
|
|
54
|
+
box_match = box_pattern.search(block)
|
|
55
|
+
if not box_match:
|
|
56
|
+
continue
|
|
57
|
+
numbers = re.findall(r"-?\d+(?:\.\d+)?", box_match.group(1))
|
|
58
|
+
if len(numbers) < 4:
|
|
59
|
+
continue
|
|
60
|
+
item: dict[str, Any] = {"box_2d": [float(value) for value in numbers[:4]]}
|
|
61
|
+
label_match = label_pattern.search(block)
|
|
62
|
+
if label_match:
|
|
63
|
+
item["label"] = label_match.group(1).strip()
|
|
64
|
+
items.append(item)
|
|
65
|
+
return items
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _items(text: str) -> list[Any]:
|
|
69
|
+
cleaned = _json_text(text)
|
|
70
|
+
try:
|
|
71
|
+
payload = json.loads(cleaned)
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
fallback = _fallback_items(cleaned)
|
|
74
|
+
if fallback:
|
|
75
|
+
return fallback
|
|
76
|
+
raise GroundError("Vision API did not return parseable bounding-box JSON")
|
|
77
|
+
if isinstance(payload, list):
|
|
78
|
+
return payload
|
|
79
|
+
if isinstance(payload, dict):
|
|
80
|
+
for key in ("boxes", "bounding_boxes", "bboxes", "objects", "items", "results"):
|
|
81
|
+
if isinstance(payload.get(key), list):
|
|
82
|
+
return payload[key]
|
|
83
|
+
raise GroundError("Vision API returned an incompatible bounding-box JSON structure")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _normalize_box(item: dict[str, Any], width: int, height: int) -> tuple[int, int, int, int] | None:
|
|
87
|
+
raw = item.get("box_2d")
|
|
88
|
+
if not isinstance(raw, list):
|
|
89
|
+
for key in ("bbox_2d", "box2d", "bbox", "box"):
|
|
90
|
+
if isinstance(item.get(key), list):
|
|
91
|
+
raw = item[key]
|
|
92
|
+
break
|
|
93
|
+
if not isinstance(raw, list) or len(raw) != 4:
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
y0, x0, y1, x1 = (float(value) for value in raw)
|
|
97
|
+
except (TypeError, ValueError):
|
|
98
|
+
return None
|
|
99
|
+
if x0 > x1:
|
|
100
|
+
x0, x1 = x1, x0
|
|
101
|
+
if y0 > y1:
|
|
102
|
+
y0, y1 = y1, y0
|
|
103
|
+
box = (
|
|
104
|
+
max(0, min(width, round(x0 / 1000 * width))),
|
|
105
|
+
max(0, min(height, round(y0 / 1000 * height))),
|
|
106
|
+
max(0, min(width, round(x1 / 1000 * width))),
|
|
107
|
+
max(0, min(height, round(y1 / 1000 * height))),
|
|
108
|
+
)
|
|
109
|
+
return box if box[2] > box[0] and box[3] > box[1] else None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_matches(text: str, width: int, height: int, target: str) -> list[Match]:
|
|
113
|
+
matches = []
|
|
114
|
+
for item in _items(text):
|
|
115
|
+
if not isinstance(item, dict):
|
|
116
|
+
continue
|
|
117
|
+
box = _normalize_box(item, width, height)
|
|
118
|
+
if box is None:
|
|
119
|
+
continue
|
|
120
|
+
label = str(item.get("label") or item.get("caption") or item.get("description") or target).strip()
|
|
121
|
+
matches.append(Match(label or target, box))
|
|
122
|
+
return matches
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_region(region: str, width: int, height: int) -> tuple[int, int, int, int]:
|
|
126
|
+
try:
|
|
127
|
+
x1, y1, x2, y2 = (int(value) for value in region.split(","))
|
|
128
|
+
except ValueError:
|
|
129
|
+
raise GroundError("--region expects four integers: X1,Y1,X2,Y2 (pixels)") from None
|
|
130
|
+
box = (max(0, min(x1, x2)), max(0, min(y1, y2)),
|
|
131
|
+
min(width, max(x1, x2)), min(height, max(y1, y2)))
|
|
132
|
+
if box[2] <= box[0] or box[3] <= box[1]:
|
|
133
|
+
raise GroundError(f"--region {region} is empty after clamping to {width}x{height}")
|
|
134
|
+
return box
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def locate(image_path: Path, target: str, region: str | None = None) -> list[Match]:
|
|
138
|
+
if Image is None:
|
|
139
|
+
raise GroundError("ground requires Pillow; install the optional dependency pillow first")
|
|
140
|
+
load_default_env()
|
|
141
|
+
box = None
|
|
142
|
+
try:
|
|
143
|
+
with Image.open(image_path) as image:
|
|
144
|
+
width, height = image.size
|
|
145
|
+
if region:
|
|
146
|
+
box = _parse_region(region, width, height)
|
|
147
|
+
buffer = io.BytesIO()
|
|
148
|
+
image.crop(box).save(buffer, format="PNG")
|
|
149
|
+
url = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
|
|
150
|
+
except (OSError, ValueError) as exc:
|
|
151
|
+
raise GroundError(f"Cannot read image: {image_path}") from exc
|
|
152
|
+
if box is None:
|
|
153
|
+
url = image_path_to_data_url(image_path)
|
|
154
|
+
width_used, height_used = width, height
|
|
155
|
+
else:
|
|
156
|
+
width_used, height_used = box[2] - box[0], box[3] - box[1]
|
|
157
|
+
# 8192 leaves room for exhaustive targets ("every UI element"): a dense
|
|
158
|
+
# screen can emit dozens of boxes and 2048 truncated the JSON mid-array.
|
|
159
|
+
response = describe_image(url, build_prompt(target), max_tokens=8192)
|
|
160
|
+
matches = parse_matches(response, width_used, height_used, target)
|
|
161
|
+
if box is None:
|
|
162
|
+
return matches
|
|
163
|
+
# Matches were parsed in crop coordinates; report them in the original image.
|
|
164
|
+
return [Match(m.label, (m.bbox[0] + box[0], m.bbox[1] + box[1],
|
|
165
|
+
m.bbox[2] + box[0], m.bbox[3] + box[1])) for m in matches]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _position(box: tuple[int, int, int, int], width: int, height: int) -> str:
|
|
169
|
+
x1, y1, x2, y2 = box
|
|
170
|
+
x = (x1 + x2) / 2
|
|
171
|
+
y = (y1 + y2) / 2
|
|
172
|
+
horizontal = "left" if x < width / 3 else ("right" if x > width * 2 / 3 else "center")
|
|
173
|
+
vertical = "top" if y < height / 3 else ("bottom" if y > height * 2 / 3 else "center")
|
|
174
|
+
return {
|
|
175
|
+
("left", "top"): "top-left", ("center", "top"): "top", ("right", "top"): "top-right",
|
|
176
|
+
("left", "center"): "left", ("center", "center"): "center", ("right", "center"): "right",
|
|
177
|
+
("left", "bottom"): "bottom-left", ("center", "bottom"): "bottom", ("right", "bottom"): "bottom-right",
|
|
178
|
+
}[(horizontal, vertical)]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def format_matches(matches: list[Match], width: int, height: int) -> list[str]:
|
|
182
|
+
if len(matches) == 1:
|
|
183
|
+
x1, y1, x2, y2 = matches[0].bbox
|
|
184
|
+
return [f"x1: {x1}, y1: {y1}, x2: {x2}, y2: {y2}"]
|
|
185
|
+
lines = []
|
|
186
|
+
for index, match in enumerate(matches, 1):
|
|
187
|
+
x1, y1, x2, y2 = match.bbox
|
|
188
|
+
position = _position(match.bbox, width, height)
|
|
189
|
+
lines.append(f"{index}. {position} {match.label} x1: {x1}, y1: {y1}, x2: {x2}, y2: {y2}")
|
|
190
|
+
return lines
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main() -> None:
|
|
194
|
+
parser = argparse.ArgumentParser(
|
|
195
|
+
prog="ground",
|
|
196
|
+
description="Locate targets in an image with natural language and output pixel coordinates",
|
|
197
|
+
)
|
|
198
|
+
parser.add_argument("image", type=Path, help="path to the image")
|
|
199
|
+
parser.add_argument("target", help="target object or region to locate")
|
|
200
|
+
parser.add_argument("--region", metavar="X1,Y1,X2,Y2",
|
|
201
|
+
help="search only this pixel box; output stays in original-image coordinates")
|
|
202
|
+
args = parser.parse_args()
|
|
203
|
+
try:
|
|
204
|
+
matches = locate(args.image.expanduser(), args.target, region=args.region)
|
|
205
|
+
if Image is None:
|
|
206
|
+
raise GroundError("ground requires Pillow; install the optional dependency pillow first")
|
|
207
|
+
with Image.open(args.image.expanduser()) as image:
|
|
208
|
+
width, height = image.size
|
|
209
|
+
except (GroundError, VisionError) as exc:
|
|
210
|
+
parser.exit(1, f"ground: {exc}\n")
|
|
211
|
+
for line in format_matches(matches, width, height):
|
|
212
|
+
print(line)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
main()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Operating a GUI from screenshots
|
|
2
|
+
|
|
3
|
+
**When to use**: the task is to act on a live screen — click, type,
|
|
4
|
+
scroll — guided by screenshots: driving an app, automating a workflow,
|
|
5
|
+
walking a bug reproduction. The vision layer answers "where is it" and
|
|
6
|
+
"what state is it in"; the acting itself goes through whatever automation
|
|
7
|
+
channel the task already has.
|
|
8
|
+
|
|
9
|
+
Tool syntax lives in `SKILL.md`. This file is the sequence and the
|
|
10
|
+
pass/fail test.
|
|
11
|
+
|
|
12
|
+
## Steps
|
|
13
|
+
|
|
14
|
+
**1. Calibrate coordinates once, before the first click.**
|
|
15
|
+
|
|
16
|
+
Screenshot pixels and pointer coordinates are often different spaces: on
|
|
17
|
+
HiDPI displays a screenshot is 2-3× the logical points the click API
|
|
18
|
+
takes. Compare the screenshot's pixel width to the screen's logical width
|
|
19
|
+
and divide every box by that ratio. Skipping this puts every click at half
|
|
20
|
+
or double distance from the origin — consistently wrong in a way that
|
|
21
|
+
looks like bad grounding.
|
|
22
|
+
|
|
23
|
+
**2. Prefer the UI tree; fall back to vision.**
|
|
24
|
+
|
|
25
|
+
If the environment exposes a UI tree (Android `uiautomator dump`, desktop
|
|
26
|
+
accessibility tree, browser DOM), read coordinates and state from there —
|
|
27
|
+
it is exact, fast, and carries semantic attributes (enabled, checked,
|
|
28
|
+
focusable) that pixels cannot. Fall back to native screenshot inspection when:
|
|
29
|
+
|
|
30
|
+
- No tree is available (games, custom-rendered canvases, remote desktops),
|
|
31
|
+
- The target element is absent from the tree (canvas-drawn overlays,
|
|
32
|
+
non-standard widgets), or
|
|
33
|
+
- You need to verify a visual state the tree doesn't expose (color, icon
|
|
34
|
+
appearance).
|
|
35
|
+
|
|
36
|
+
Inspect the screenshot natively with the main agent's image reader. If a
|
|
37
|
+
small target or state is unclear, crop the region and read it natively;
|
|
38
|
+
translate crop coordinates back to the full screenshot before calibration.
|
|
39
|
+
Use `ground` for an unresolved target box, `detect` for an inventory, or
|
|
40
|
+
`glance` for a second opinion/OCR when needed.
|
|
41
|
+
|
|
42
|
+
Model boxes are estimates: they can point at the wrong target or omit much
|
|
43
|
+
of it, not just miss an edge by a few pixels. Verify the target visually
|
|
44
|
+
before clicking its center. For tiny targets (checkboxes, close buttons),
|
|
45
|
+
inspect a larger crop before acting.
|
|
46
|
+
|
|
47
|
+
For elements that stay put across interactions (toolbar buttons, sidebar
|
|
48
|
+
links, fixed panels), record verified center coordinates in a table with
|
|
49
|
+
an id/label. Reuse them only while fresh screenshots confirm the layout is
|
|
50
|
+
unchanged. Invalidate the table on resize, navigation, or scroll.
|
|
51
|
+
|
|
52
|
+
**3. One screenshot per action — act, re-shoot, verify, then continue.**
|
|
53
|
+
|
|
54
|
+
Every action invalidates the current screenshot: menus open, focus moves,
|
|
55
|
+
content loads; scrolling invalidates every box at once. Chaining several
|
|
56
|
+
clicks on one stale screenshot is how you click through where a dialog
|
|
57
|
+
used to be.
|
|
58
|
+
|
|
59
|
+
**4. Verify as a question about the expected state.**
|
|
60
|
+
|
|
61
|
+
Read the fresh screenshot natively to answer the expected-state question,
|
|
62
|
+
for example "is the settings dialog open?" If not, stop and inspect the
|
|
63
|
+
current screen instead of pressing on against an assumed state. For a
|
|
64
|
+
small change (a toggle, one badge), optionally use `pixel_diff` to locate
|
|
65
|
+
the changed region, then crop and read it natively. A targeted `glance`
|
|
66
|
+
question remains available for an unresolved state.
|
|
67
|
+
|
|
68
|
+
**5. After typing, read the field back.**
|
|
69
|
+
|
|
70
|
+
Focus loss eats keystrokes silently. Read the field value from the UI tree
|
|
71
|
+
when available; otherwise inspect it natively, cropping if necessary.
|
|
72
|
+
Use targeted OCR only if the text remains unclear. Confirm the text
|
|
73
|
+
actually landed before submitting.
|
|
74
|
+
|
|
75
|
+
## Verify
|
|
76
|
+
|
|
77
|
+
The loop in steps 3-4 *is* the verification: no action counts as done
|
|
78
|
+
until a fresh screenshot answers the expected-state question. For a
|
|
79
|
+
multi-step flow, the final screenshot must show the end state the task
|
|
80
|
+
defines — the confirmation page, the saved indicator. A completed action
|
|
81
|
+
list is not evidence; the pixels are.
|
|
82
|
+
|
|
83
|
+
## Boundaries
|
|
84
|
+
|
|
85
|
+
- Loading is asynchronous: after an action that triggers it, poll —
|
|
86
|
+
re-screenshot until two consecutive shots stop differing (`pixel_diff`
|
|
87
|
+
near 0) — rather than trusting one fixed sleep.
|
|
88
|
+
- Irreversible actions (send, delete, pay) follow the calling agent's own
|
|
89
|
+
confirmation policy. The vision layer reports what is on screen; it
|
|
90
|
+
never makes that call.
|
|
91
|
+
- A screen the task didn't predict — an error dialog, a permission
|
|
92
|
+
prompt, a login wall — is a stop-and-surface, not an obstacle to click
|
|
93
|
+
through. Clicking through unknown dialogs is how automations do damage.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# OCR a long screenshot without losing boundary text
|
|
2
|
+
|
|
3
|
+
**Use for**: a vertically scrolling screenshot, chat history, long web page,
|
|
4
|
+
requirements conversation, log view, or other image that would be downscaled too
|
|
5
|
+
aggressively in one vision call. For a PDF or an exported document, prefer a
|
|
6
|
+
format-aware document parser instead of screenshot OCR.
|
|
7
|
+
|
|
8
|
+
## Run the workflow
|
|
9
|
+
|
|
10
|
+
Read the source at its current path; copy it only when it needs to persist
|
|
11
|
+
beyond a temporary file's lifetime. Inspect the script's help before choosing
|
|
12
|
+
options:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
python3 scripts/long_screenshot_ocr.py --help
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Prefer native chunk reading by the main agent; the glance-backed script is
|
|
19
|
+
available for automated transcription. Both paths use the same content-aware
|
|
20
|
+
splitter; verify joins even when the cut falls in a low-content band.
|
|
21
|
+
|
|
22
|
+
### Native chunk reading
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
python3 scripts/long_screenshot_ocr.py work/page.png --split-only
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This writes ordered chunk images and `manifest.json` without vision calls.
|
|
29
|
+
Read the chunks natively in manifest order and assemble `work/page.ocr.md`.
|
|
30
|
+
For unresolved text, crop the relevant region and read it natively. Verify
|
|
31
|
+
every join against both adjacent images, removing only confirmed overlap;
|
|
32
|
+
preserve chat senders, timestamps, and quoted messages. Split-only does not
|
|
33
|
+
produce merged OCR or `ocr_audit.md`, so record uncertain joins in the output.
|
|
34
|
+
|
|
35
|
+
### Automated transcription with glance
|
|
36
|
+
|
|
37
|
+
Choose the content mode; this path makes configured vision calls:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Web pages, documents, logs, tables, and other general content
|
|
41
|
+
python3 scripts/long_screenshot_ocr.py work/page.png -o work/page.ocr.md
|
|
42
|
+
|
|
43
|
+
# Chat histories: preserve message grouping, senders, timestamps, and quotes
|
|
44
|
+
python3 scripts/long_screenshot_ocr.py work/chat.png --mode chat -o work/chat.ocr.md
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Without `-o`, write `<input-stem>.ocr.md`; store chunks, sidecars, the manifest,
|
|
48
|
+
and the audit in `<input-stem>_chunks/` unless `--chunks-dir` overrides it.
|
|
49
|
+
|
|
50
|
+
The script performs four operations:
|
|
51
|
+
|
|
52
|
+
1. Measure per-row content density and find low-content cut bands near the
|
|
53
|
+
target height.
|
|
54
|
+
2. Add pixel overlap only when no safe band exists, so text crossing a risky
|
|
55
|
+
cut appears in both adjacent chunks.
|
|
56
|
+
3. Run `glance` on the chunks through the configured `vision-toolkit.json`
|
|
57
|
+
chain (or legacy `VISION_*` settings); chat mode requests structured messages
|
|
58
|
+
while general mode uses verbatim OCR.
|
|
59
|
+
4. Merge only confident repeated lines or messages and write `manifest.json` plus
|
|
60
|
+
`ocr_audit.md` beside the chunks.
|
|
61
|
+
|
|
62
|
+
Use `--resume` after an interrupted run. A chunk is reused only when its image,
|
|
63
|
+
mode, and custom prompt fingerprint still match:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
python3 scripts/long_screenshot_ocr.py work/chat.png --mode chat --resume -o work/chat.ocr.md
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
If the defaults produce awkward chunks, rerun with `--target-height`,
|
|
70
|
+
`--min-height`, `--max-height`, or `--overlap`. Keep enough height for local
|
|
71
|
+
context; do not make tiny OCR tiles unless the source text is unusually small.
|
|
72
|
+
|
|
73
|
+
## Verify before delivering
|
|
74
|
+
|
|
75
|
+
1. Read the merged Markdown from top to bottom and compare its opening and
|
|
76
|
+
ending lines with the source.
|
|
77
|
+
2. For automated transcription, open `ocr_audit.md` and review every boundary
|
|
78
|
+
marked `yes` against the two adjacent `chunk_*.png` files. A marked boundary
|
|
79
|
+
used pixel overlap or fuzzy text matching and is not safe to accept blindly.
|
|
80
|
+
For native chunk reading, verify every join against adjacent images and the
|
|
81
|
+
manifest; no automated merge audit is generated.
|
|
82
|
+
3. Check sender changes, timestamps, quoted messages, table row breaks, code
|
|
83
|
+
indentation, and paragraphs that cross chunk boundaries.
|
|
84
|
+
4. For doubtful text, crop and read the region natively. If it remains
|
|
85
|
+
unresolved, targeted specialist OCR is available:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
glance work/page_chunks/chunk_002.png --ocr "Re-check the final five lines carefully."
|
|
89
|
+
glance work/page_chunks/chunk_002.png --region X1,Y1,X2,Y2 --ocr
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
5. Keep visible spelling and punctuation verbatim. Write `[unreadable]` for
|
|
93
|
+
text that remains illegible; do not silently guess or editorially repair it.
|
|
94
|
+
|
|
95
|
+
## Output contract
|
|
96
|
+
|
|
97
|
+
- Return the merged `.ocr.md` file as the primary result.
|
|
98
|
+
- Keep the chunk directory until verification is complete; it is the evidence
|
|
99
|
+
for ordering and boundary decisions.
|
|
100
|
+
- Report unresolved `[unreadable]` text and every boundary that still needs
|
|
101
|
+
human review.
|
|
102
|
+
- Do not claim a complete transcription when the first or last screenshot edge
|
|
103
|
+
visibly clips content.
|