@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.
@@ -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.