@mengruo/dsh-vision-toolkit 0.0.1

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.
Files changed (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +356 -0
  4. package/README.zh.md +354 -0
  5. package/assets/community-group-qr.png +0 -0
  6. package/assets/dsh-conversation-artifact.png +0 -0
  7. package/assets/dsh-conversation-image-qa-top.png +0 -0
  8. package/assets/dsh-conversation-image-qa.png +0 -0
  9. package/assets/dsh-conversation-pixel-diff.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  11. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  12. package/assets/dsh-conversation-tool-call.png +0 -0
  13. package/assets/dsh-conversation-vision-trace.png +0 -0
  14. package/assets/dsh-view-example.png +0 -0
  15. package/assets/hero-v2.png +0 -0
  16. package/assets/logo_aihubmix.png +0 -0
  17. package/assets/python-bootstrap.json +48 -0
  18. package/assets/skill/SKILL.md +329 -0
  19. package/assets/skill/UPSTREAM.json +71 -0
  20. package/assets/skill/references/gui.md +88 -0
  21. package/assets/skill/references/long-screenshot-ocr.md +77 -0
  22. package/assets/skill/references/restore-graphic.md +84 -0
  23. package/assets/skill/references/restore-structure.md +45 -0
  24. package/assets/skill/references/restore-ui.md +202 -0
  25. package/assets/social-preview.png +0 -0
  26. package/assets/upstream/README.md +18 -0
  27. package/assets/upstream/focus-hint-comparison-1.webp +0 -0
  28. package/assets/upstream/focus-hint-comparison-2.webp +0 -0
  29. package/assets/upstream/infographic-reference.webp +0 -0
  30. package/assets/upstream/infographic-result.webp +0 -0
  31. package/assets/upstream/ui-fast-restore-reference.webp +0 -0
  32. package/assets/upstream/ui-fast-restore-result.webp +0 -0
  33. package/assets/upstream/ui-result.webp +0 -0
  34. package/assets/upstream/ui-sketch.webp +0 -0
  35. package/assets/vision-model-test.png +0 -0
  36. package/assets/vision-settings.png +0 -0
  37. package/assets/wechat-reward.png +0 -0
  38. package/cordis.patch.yml +6 -0
  39. package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
  40. package/docs/aihubmix-gemini-vision.md +138 -0
  41. package/docs/aihubmix-gemini-vision.zh.md +138 -0
  42. package/docs/assets/aihubmix-api-keys.png +0 -0
  43. package/docs/assets/aihubmix-create-key.png +0 -0
  44. package/docs/assets/aihubmix-free-vision-model.png +0 -0
  45. package/docs/assets/aihubmix-home.png +0 -0
  46. package/docs/assets/aihubmix-sign-up.png +0 -0
  47. package/docs/assets/groq-console-home.png +0 -0
  48. package/docs/assets/groq-console-keys-login.png +0 -0
  49. package/docs/assets/groq-docs-quickstart.png +0 -0
  50. package/docs/assets/groq-docs-qwen3.6.png +0 -0
  51. package/docs/assets/groq-docs-vision.png +0 -0
  52. package/docs/assets/vision-settings.png +0 -0
  53. package/docs/dsh-desktop-install.md +67 -0
  54. package/docs/dsh-desktop-install.zh.md +67 -0
  55. package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
  56. package/docs/groq-qwen3.6-vision.md +236 -0
  57. package/docs/groq-qwen3.6-vision.zh.md +236 -0
  58. package/docs/python-runtime.i18n.yaml +6 -0
  59. package/docs/python-runtime.md +89 -0
  60. package/docs/python-runtime.zh.md +89 -0
  61. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  62. package/docs/requirements-traceability/README.md +75 -0
  63. package/docs/requirements-traceability/README.zh.md +75 -0
  64. package/examples/ui-restoration/README.i18n.yaml +6 -0
  65. package/examples/ui-restoration/README.md +70 -0
  66. package/examples/ui-restoration/README.zh.md +70 -0
  67. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  68. package/examples/ui-restoration/assets/final-report.json +83 -0
  69. package/examples/ui-restoration/assets/implementation.png +0 -0
  70. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  71. package/examples/ui-restoration/assets/initial-report.json +83 -0
  72. package/examples/ui-restoration/assets/initial.png +0 -0
  73. package/examples/ui-restoration/assets/metrics.json +12 -0
  74. package/examples/ui-restoration/assets/reference.png +0 -0
  75. package/examples/ui-restoration/implementation.html +94 -0
  76. package/examples/ui-restoration/initial.html +57 -0
  77. package/lib/artifact-access.js +369 -0
  78. package/lib/artifact-access.js.map +1 -0
  79. package/lib/artifacts.js +56 -0
  80. package/lib/artifacts.js.map +1 -0
  81. package/lib/client.js +2099 -0
  82. package/lib/client.js.map +1 -0
  83. package/lib/config.js +172 -0
  84. package/lib/config.js.map +1 -0
  85. package/lib/defaults.js +6 -0
  86. package/lib/defaults.js.map +1 -0
  87. package/lib/errors.js +56 -0
  88. package/lib/errors.js.map +1 -0
  89. package/lib/evidence-cache.js +350 -0
  90. package/lib/evidence-cache.js.map +1 -0
  91. package/lib/exposure.js +247 -0
  92. package/lib/exposure.js.map +1 -0
  93. package/lib/image-input-variants.js +882 -0
  94. package/lib/image-input-variants.js.map +1 -0
  95. package/lib/index.js +105 -0
  96. package/lib/index.js.map +1 -0
  97. package/lib/paste-images.js +217 -0
  98. package/lib/paste-images.js.map +1 -0
  99. package/lib/paths.js +354 -0
  100. package/lib/paths.js.map +1 -0
  101. package/lib/plugin-update.js +1003 -0
  102. package/lib/plugin-update.js.map +1 -0
  103. package/lib/runtime-install.js +964 -0
  104. package/lib/runtime-install.js.map +1 -0
  105. package/lib/runtime-manager.js +133 -0
  106. package/lib/runtime-manager.js.map +1 -0
  107. package/lib/runtime.js +1678 -0
  108. package/lib/runtime.js.map +1 -0
  109. package/lib/skill.js +25 -0
  110. package/lib/skill.js.map +1 -0
  111. package/lib/tools.js +549 -0
  112. package/lib/tools.js.map +1 -0
  113. package/lib/types/artifact-access.d.ts +61 -0
  114. package/lib/types/artifact-access.d.ts.map +1 -0
  115. package/lib/types/artifacts.d.ts +42 -0
  116. package/lib/types/artifacts.d.ts.map +1 -0
  117. package/lib/types/client/display-config.d.ts +24 -0
  118. package/lib/types/client/display-config.d.ts.map +1 -0
  119. package/lib/types/client/index.d.ts +350 -0
  120. package/lib/types/client/index.d.ts.map +1 -0
  121. package/lib/types/client/model-variants-hider.d.ts +40 -0
  122. package/lib/types/client/model-variants-hider.d.ts.map +1 -0
  123. package/lib/types/client/paste-images.d.ts +126 -0
  124. package/lib/types/client/paste-images.d.ts.map +1 -0
  125. package/lib/types/config.d.ts +123 -0
  126. package/lib/types/config.d.ts.map +1 -0
  127. package/lib/types/defaults.d.ts +6 -0
  128. package/lib/types/defaults.d.ts.map +1 -0
  129. package/lib/types/errors.d.ts +35 -0
  130. package/lib/types/errors.d.ts.map +1 -0
  131. package/lib/types/evidence-cache.d.ts +91 -0
  132. package/lib/types/evidence-cache.d.ts.map +1 -0
  133. package/lib/types/exposure.d.ts +50 -0
  134. package/lib/types/exposure.d.ts.map +1 -0
  135. package/lib/types/image-input-variants.d.ts +144 -0
  136. package/lib/types/image-input-variants.d.ts.map +1 -0
  137. package/lib/types/index.d.ts +19 -0
  138. package/lib/types/index.d.ts.map +1 -0
  139. package/lib/types/paste-images.d.ts +73 -0
  140. package/lib/types/paste-images.d.ts.map +1 -0
  141. package/lib/types/paths.d.ts +117 -0
  142. package/lib/types/paths.d.ts.map +1 -0
  143. package/lib/types/plugin-update.d.ts +119 -0
  144. package/lib/types/plugin-update.d.ts.map +1 -0
  145. package/lib/types/runtime-install.d.ts +92 -0
  146. package/lib/types/runtime-install.d.ts.map +1 -0
  147. package/lib/types/runtime-manager.d.ts +60 -0
  148. package/lib/types/runtime-manager.d.ts.map +1 -0
  149. package/lib/types/runtime.d.ts +412 -0
  150. package/lib/types/runtime.d.ts.map +1 -0
  151. package/lib/types/skill.d.ts +14 -0
  152. package/lib/types/skill.d.ts.map +1 -0
  153. package/lib/types/tools.d.ts +35 -0
  154. package/lib/types/tools.d.ts.map +1 -0
  155. package/lib/types/upstream.d.ts +233 -0
  156. package/lib/types/upstream.d.ts.map +1 -0
  157. package/lib/types/version.d.ts +15 -0
  158. package/lib/types/version.d.ts.map +1 -0
  159. package/lib/types/web-request.d.ts +11 -0
  160. package/lib/types/web-request.d.ts.map +1 -0
  161. package/lib/types/web.d.ts +118 -0
  162. package/lib/types/web.d.ts.map +1 -0
  163. package/lib/upstream.js +860 -0
  164. package/lib/upstream.js.map +1 -0
  165. package/lib/version.js +18 -0
  166. package/lib/version.js.map +1 -0
  167. package/lib/web-request.js +29 -0
  168. package/lib/web-request.js.map +1 -0
  169. package/lib/web.js +462 -0
  170. package/lib/web.js.map +1 -0
  171. package/package.json +149 -0
  172. package/patches/vision-tools-dsh.patch +970 -0
  173. package/runtime/requirements.lock +3 -0
  174. package/src/artifact-access.ts +386 -0
  175. package/src/artifacts.ts +85 -0
  176. package/src/client/display-config.ts +62 -0
  177. package/src/client/index.tsx +1580 -0
  178. package/src/client/model-variants-hider.ts +159 -0
  179. package/src/client/paste-images.tsx +747 -0
  180. package/src/config.ts +290 -0
  181. package/src/defaults.ts +5 -0
  182. package/src/errors.ts +62 -0
  183. package/src/evidence-cache.ts +418 -0
  184. package/src/exposure.ts +265 -0
  185. package/src/image-input-variants.ts +996 -0
  186. package/src/index.ts +141 -0
  187. package/src/paste-images.ts +286 -0
  188. package/src/paths.ts +383 -0
  189. package/src/plugin-update.ts +1143 -0
  190. package/src/runtime-install.ts +1161 -0
  191. package/src/runtime-manager.ts +174 -0
  192. package/src/runtime.ts +2189 -0
  193. package/src/skill.ts +35 -0
  194. package/src/tools.ts +691 -0
  195. package/src/upstream.ts +1078 -0
  196. package/src/version.ts +37 -0
  197. package/src/web-request.ts +27 -0
  198. package/src/web.ts +594 -0
  199. package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
  200. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  201. package/vendor/agent-vision-toolkit/README.md +405 -0
  202. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
  203. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  204. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  205. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  206. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  207. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  208. package/vendor/agent-vision-toolkit/detect.py +60 -0
  209. package/vendor/agent-vision-toolkit/ground.py +267 -0
  210. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  211. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  212. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
  213. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  214. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  215. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
  216. package/vendor/agent-vision-toolkit/vision_client.py +310 -0
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ """glance: standalone image description, Q&A, and OCR CLI."""
3
+
4
+ import argparse
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
9
+
10
+ from vision_client import ( # noqa: E402
11
+ VisionError,
12
+ describe_image,
13
+ image_path_to_data_url,
14
+ load_default_env,
15
+ )
16
+
17
+
18
+ def region_data_url(path, region):
19
+ try:
20
+ from PIL import Image
21
+ except ImportError:
22
+ raise VisionError("--region requires Pillow; install the optional dependency pillow first")
23
+ try:
24
+ x1, y1, x2, y2 = (int(v) for v in region.split(","))
25
+ except ValueError:
26
+ raise VisionError("--region expects four integers: X1,Y1,X2,Y2 (pixels)")
27
+ import base64
28
+ import io
29
+ try:
30
+ with Image.open(Path(path).expanduser()) as image:
31
+ width, height = image.size
32
+ box = (max(0, min(x1, x2)), max(0, min(y1, y2)),
33
+ min(width, max(x1, x2)), min(height, max(y1, y2)))
34
+ if box[2] <= box[0] or box[3] <= box[1]:
35
+ raise VisionError(f"--region {region} is empty after clamping to {width}x{height}")
36
+ buffer = io.BytesIO()
37
+ image.crop(box).save(buffer, format="PNG")
38
+ except (OSError, ValueError) as exc:
39
+ raise VisionError(f"Cannot read image: {path}") from exc
40
+ return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
41
+
42
+
43
+ def build_prompt(args, count):
44
+ if args.ocr is not None:
45
+ extra = f" Additional requirements: {args.ocr}" if args.ocr else ""
46
+ scope = "these images" if count > 1 else "this image"
47
+ return (
48
+ f"Transcribe every piece of visible text in {scope} verbatim (titles, body text, labels, watermarks, etc.), "
49
+ "line by line, without omitting any characters. Do not rewrite, summarize, or translate the text, "
50
+ "and do not add any preamble, explanation, or extra content."
51
+ + (" Label each image's text with its ordinal (Image 1, Image 2, ...)." if count > 1 else "")
52
+ + extra
53
+ )
54
+ if args.query:
55
+ return args.query
56
+ if count > 1:
57
+ return ("Describe each image in detail (label them Image 1, Image 2, ...), "
58
+ "then point out the notable differences between them.")
59
+ return None
60
+
61
+
62
+ def main():
63
+ parser = argparse.ArgumentParser(
64
+ prog="glance",
65
+ description="Describe, answer questions about, or OCR an image with the configured vision model",
66
+ )
67
+ parser.add_argument("images", nargs="+", metavar="image",
68
+ help="path(s) to the image(s); pass several to compare them in one call")
69
+ group = parser.add_mutually_exclusive_group()
70
+ group.add_argument("-q", "--query", help="ask a question about the image(s)")
71
+ group.add_argument("--ocr", nargs="?", const="", metavar="EXTRA", help="transcribe all visible text verbatim")
72
+ parser.add_argument("--region", metavar="X1,Y1,X2,Y2",
73
+ help="crop to this pixel box (e.g. from ground) and send only the crop")
74
+ args = parser.parse_args()
75
+ load_default_env()
76
+ try:
77
+ if args.region and len(args.images) > 1:
78
+ raise VisionError("--region works with exactly one image")
79
+ urls = ([region_data_url(args.images[0], args.region)] if args.region
80
+ else [image_path_to_data_url(path) for path in args.images])
81
+ answer = describe_image(
82
+ urls,
83
+ build_prompt(args, len(urls)),
84
+ max_tokens=None, # no output cap; the vision model decides
85
+ apply_lang=args.ocr is None,
86
+ )
87
+ except VisionError as exc:
88
+ parser.exit(1, f"glance: {exc}\n")
89
+ print(answer)
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env python3
2
+ """ground: locate objects in an image and print pixel bounding boxes."""
3
+
4
+ from pathlib import Path
5
+ import sys
6
+
7
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
8
+
9
+ from ground import main # noqa: E402
10
+
11
+
12
+ if __name__ == "__main__":
13
+ main()
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """trace: deterministic image-to-SVG tracing for exact shape geometry.
3
+
4
+ Runs fully local (no vision API, no credentials): coordinates come from the
5
+ actual pixels, so use this instead of asking a vision model for shapes,
6
+ sizes, or outlines. Flat, high-contrast graphics only; text becomes curves.
7
+ """
8
+
9
+ import argparse
10
+ from pathlib import Path
11
+ import re
12
+ import sys
13
+ import tempfile
14
+
15
+ WHITE_FILLS = {"#ffffff", "#fff", "white"}
16
+
17
+ # Speckle filtering drops components under a fixed pixel area, so a 30px icon
18
+ # binarizes to nothing at 1x while the same icon enlarged traces cleanly.
19
+ # Enlarge anything smaller than this; full-size screenshots already clear it.
20
+ TARGET_MIN_SIDE = 256
21
+
22
+
23
+ def strip_background(svg):
24
+ """Drop the leading full-canvas white path vtracer emits for the background."""
25
+ match = re.search(r"<path [^>]*/>", svg)
26
+ if match:
27
+ fill = re.search(r'fill="([^"]+)"', match.group(0))
28
+ if fill and fill.group(1).strip().lower() in WHITE_FILLS:
29
+ return svg.replace(match.group(0), "", 1)
30
+ return svg
31
+
32
+
33
+ def truncate_decimals(svg, places=2):
34
+ return re.sub(r"-?\d+\.\d{3,}", lambda m: f"{float(m.group()):.{places}f}", svg)
35
+
36
+
37
+ def write_svg(path, svg):
38
+ """Write the exact UTF-8 payload whose byte count is reported to callers."""
39
+ payload = svg.encode("utf-8")
40
+ return Path(path).expanduser().write_bytes(payload)
41
+
42
+
43
+ def prepare_input(path, region, scale):
44
+ """Crop/upscale with Pillow when asked; return the path vtracer reads and the scale used.
45
+
46
+ `scale` may be None, meaning "pick one from the size": small shapes are
47
+ enlarged until they survive speckle filtering. Without Pillow we cannot
48
+ measure the image, so that choice falls back to no upscale.
49
+ """
50
+ try:
51
+ from PIL import Image
52
+ except ImportError:
53
+ if region or (scale or 1) != 1:
54
+ sys.exit("trace: --region/--scale require Pillow; install the optional dependency pillow first")
55
+ return str(path), 1
56
+ with Image.open(path) as image:
57
+ if region:
58
+ try:
59
+ x1, y1, x2, y2 = (int(v) for v in region.split(","))
60
+ except ValueError:
61
+ sys.exit("trace: --region expects four integers: X1,Y1,X2,Y2 (pixels)")
62
+ box = (max(0, min(x1, x2)), max(0, min(y1, y2)),
63
+ min(image.width, max(x1, x2)), min(image.height, max(y1, y2)))
64
+ if box[2] <= box[0] or box[3] <= box[1]:
65
+ sys.exit(f"trace: --region {region} is empty after clamping to {image.width}x{image.height}")
66
+ image = image.crop(box)
67
+ if scale is None:
68
+ shortest = max(min(image.width, image.height), 1)
69
+ scale = max(2 if region else 1, -(-TARGET_MIN_SIDE // shortest))
70
+ if not region and scale == 1:
71
+ return str(path), 1
72
+ if scale != 1:
73
+ image = image.resize((image.width * scale, image.height * scale), Image.LANCZOS)
74
+ handle = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
75
+ image.save(handle.name)
76
+ return handle.name, scale
77
+
78
+
79
+ def main():
80
+ parser = argparse.ArgumentParser(
81
+ prog="trace",
82
+ description="Trace an image into SVG with exact pixel-derived geometry (local, deterministic)",
83
+ )
84
+ parser.add_argument("image", help="path to the image")
85
+ parser.add_argument("--region", metavar="X1,Y1,X2,Y2",
86
+ help="crop to this pixel box first (e.g. a box from ground)")
87
+ parser.add_argument("--scale", type=int, metavar="N",
88
+ help="upscale N times before tracing (default: enough to clear "
89
+ f"{TARGET_MIN_SIDE}px on the shorter side, so small icons survive)")
90
+ parser.add_argument("--polygon", action="store_true",
91
+ help="polygon mode for boxy diagrams/wireframes (default: spline for curved shapes)")
92
+ parser.add_argument("--color", action="store_true",
93
+ help="keep colors (default: black-and-white, which is far more compact)")
94
+ parser.add_argument("-o", "--output", help="write the SVG here instead of stdout")
95
+ args = parser.parse_args()
96
+ try:
97
+ import vtracer
98
+ except ImportError:
99
+ parser.exit(1, "trace: requires vtracer; install the optional dependency vtracer first\n")
100
+ source = Path(args.image).expanduser()
101
+ if not source.is_file():
102
+ parser.exit(1, f"trace: Image not found: {source}\n")
103
+ work, scale = prepare_input(source, args.region, args.scale)
104
+ with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as out:
105
+ vtracer.convert_image_to_svg_py(
106
+ work, out.name,
107
+ colormode="color" if args.color else "binary",
108
+ filter_speckle=8, corner_threshold=40,
109
+ mode="polygon" if args.polygon else "spline",
110
+ )
111
+ svg = Path(out.name).read_text()
112
+ svg = truncate_decimals(strip_background(svg))
113
+ paths = svg.count("<path")
114
+ if not paths:
115
+ # This is where an agent decides the tool cannot see the shape and goes
116
+ # back to guessing. Name the recoveries instead, cheapest first.
117
+ print("trace: 0 paths - nothing survived binarization. Try a larger --scale, "
118
+ "--region to crop closer to the shape, or pre-invert a light-on-dark image. "
119
+ "--color is a last resort: on an anti-aliased image it splits every gray "
120
+ "level into its own path.", file=sys.stderr)
121
+ if args.output:
122
+ bytes_written = write_svg(args.output, svg)
123
+ print(f"wrote {args.output} ({bytes_written} bytes, {paths} paths, traced at {scale}x)")
124
+ else:
125
+ print(svg)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
@@ -0,0 +1,60 @@
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
+ target = (category or DEFAULT_CATEGORY).strip()
20
+ if not target.lower().startswith("every distinct "):
21
+ target = f"every distinct {target}"
22
+ if "exact visible text" not in target.lower():
23
+ target += " — include the exact visible text in each label"
24
+ return target
25
+
26
+
27
+ def format_inventory(matches, width: int, height: int) -> list[str]:
28
+ lines = []
29
+ for index, match in enumerate(matches, 1):
30
+ x1, y1, x2, y2 = match.bbox
31
+ position = _position(match.bbox, width, height)
32
+ lines.append(f"{index}. {position} {match.label} x1: {x1}, y1: {y1}, x2: {x2}, y2: {y2}")
33
+ return lines or ["no elements detected"]
34
+
35
+
36
+ def main() -> None:
37
+ parser = argparse.ArgumentParser(
38
+ prog="detect",
39
+ description="Inventory the elements in an image (or a region) with pixel bounding boxes",
40
+ )
41
+ parser.add_argument("image", type=Path, help="path to the image")
42
+ parser.add_argument("category", nargs="?",
43
+ help='restrict to a category, e.g. "buttons" or "icons" (default: all UI elements)')
44
+ parser.add_argument("--region", metavar="X1,Y1,X2,Y2",
45
+ help="inventory only this pixel box; output stays in original-image coordinates")
46
+ args = parser.parse_args()
47
+ try:
48
+ matches = locate(args.image.expanduser(), build_target(args.category), region=args.region)
49
+ if Image is None:
50
+ raise GroundError("detect requires Pillow; install the optional dependency pillow first")
51
+ with Image.open(args.image.expanduser()) as image:
52
+ width, height = image.size
53
+ except (GroundError, VisionError) as exc:
54
+ parser.exit(1, f"detect: {exc}\n")
55
+ for line in format_inventory(matches, width, height):
56
+ print(line)
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
@@ -0,0 +1,267 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import base64
5
+ import io
6
+ import json
7
+ import os
8
+ import re
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ try:
15
+ from PIL import Image
16
+ except ImportError:
17
+ Image = None
18
+
19
+ from vision_client import VisionError, describe_image, image_path_to_data_url, load_default_env
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Match:
24
+ label: str
25
+ bbox: tuple[int, int, int, int]
26
+
27
+
28
+ class GroundError(Exception):
29
+ pass
30
+
31
+
32
+ def coordinate_order() -> str:
33
+ configured = os.environ.get("VISION_BOX_ORDER", "").strip().lower()
34
+ if configured:
35
+ if configured not in {"xyxy", "yxyx"}:
36
+ raise GroundError("VISION_BOX_ORDER must be either xyxy or yxyx")
37
+ return configured
38
+ model = os.environ.get("VISION_MODEL", "").lower()
39
+ return "xyxy" if "qwen" in model else "yxyx"
40
+
41
+
42
+ def build_prompt(target: str, box_order: str = "yxyx") -> str:
43
+ coordinates = "[x0, y0, x1, y1]" if box_order == "xyxy" else "[y0, x0, y1, x1]"
44
+ return (
45
+ "Locate every visible object or region matching this target:\n"
46
+ f"{target}\n\n"
47
+ 'Return only a JSON array. Each item must contain "box_2d" as '
48
+ f'{coordinates} on a 0-1000 grid and "label" as a short description. '
49
+ "Use tight boxes in the original image. Return [] when nothing matches."
50
+ )
51
+
52
+
53
+ def _json_text(text: str) -> str:
54
+ cleaned = str(text or "").strip()
55
+ fenced = re.findall(r"```(?:json)?\s*(.*?)\s*```", cleaned, re.DOTALL | re.IGNORECASE)
56
+ return (fenced[-1] if fenced else cleaned).strip()
57
+
58
+
59
+ def _fallback_items(text: str) -> list[dict[str, Any]]:
60
+ items = []
61
+ object_pattern = re.compile(r"\{[^{}]*['\"](?:box_2d|bbox_2d|box2d|bbox|box)['\"]\s*:\s*\[[^\]]+\][^{}]*\}", re.DOTALL)
62
+ box_pattern = re.compile(r"['\"](?:box_2d|bbox_2d|box2d|bbox|box)['\"]\s*:\s*\[([^\]]+)\]", re.DOTALL)
63
+ label_pattern = re.compile(r"['\"](?:label|caption|description)['\"]\s*:\s*['\"]([^'\"]+)['\"]", re.DOTALL)
64
+ for match in object_pattern.finditer(text):
65
+ block = match.group(0)
66
+ box_match = box_pattern.search(block)
67
+ if not box_match:
68
+ continue
69
+ numbers = re.findall(r"-?\d+(?:\.\d+)?", box_match.group(1))
70
+ if len(numbers) < 4:
71
+ continue
72
+ item: dict[str, Any] = {"box_2d": [float(value) for value in numbers[:4]]}
73
+ label_match = label_pattern.search(block)
74
+ if label_match:
75
+ item["label"] = label_match.group(1).strip()
76
+ items.append(item)
77
+ return items
78
+
79
+
80
+ def _items(text: str) -> list[Any]:
81
+ cleaned = _json_text(text)
82
+ try:
83
+ payload = json.loads(cleaned)
84
+ except json.JSONDecodeError:
85
+ if cleaned.count("```") % 2 or _has_unclosed_json(cleaned):
86
+ raise GroundError("Vision API bounding-box JSON was truncated or incomplete") from None
87
+ fallback = _fallback_items(cleaned)
88
+ if fallback:
89
+ return fallback
90
+ raise GroundError("Vision API did not return parseable bounding-box JSON")
91
+ if isinstance(payload, list):
92
+ return payload
93
+ if isinstance(payload, dict):
94
+ for key in ("boxes", "bounding_boxes", "bboxes", "objects", "items", "results"):
95
+ if isinstance(payload.get(key), list):
96
+ return payload[key]
97
+ raise GroundError("Vision API returned an incompatible bounding-box JSON structure")
98
+
99
+
100
+ def _has_unclosed_json(text: str) -> bool:
101
+ start_positions = [position for position in (text.find("["), text.find("{")) if position >= 0]
102
+ if not start_positions:
103
+ return False
104
+ stack = []
105
+ in_string = False
106
+ escaped = False
107
+ pairs = {"]": "[", "}": "{"}
108
+ for character in text[min(start_positions):]:
109
+ if in_string:
110
+ if escaped:
111
+ escaped = False
112
+ elif character == "\\":
113
+ escaped = True
114
+ elif character == '"':
115
+ in_string = False
116
+ continue
117
+ if character == '"':
118
+ in_string = True
119
+ elif character in "[{":
120
+ stack.append(character)
121
+ elif character in "]}":
122
+ if not stack or stack[-1] != pairs[character]:
123
+ return False
124
+ stack.pop()
125
+ return bool(stack)
126
+
127
+
128
+ def _normalize_box(
129
+ item: dict[str, Any], width: int, height: int, box_order: str = "yxyx",
130
+ ) -> tuple[int, int, int, int] | None:
131
+ raw = item.get("box_2d")
132
+ if not isinstance(raw, list):
133
+ for key in ("bbox_2d", "box2d", "bbox", "box"):
134
+ if isinstance(item.get(key), list):
135
+ raw = item[key]
136
+ break
137
+ if not isinstance(raw, list) or len(raw) != 4:
138
+ return None
139
+ try:
140
+ values = tuple(float(value) for value in raw)
141
+ except (TypeError, ValueError):
142
+ return None
143
+ if box_order == "xyxy":
144
+ x0, y0, x1, y1 = values
145
+ else:
146
+ y0, x0, y1, x1 = values
147
+ if x0 > x1:
148
+ x0, x1 = x1, x0
149
+ if y0 > y1:
150
+ y0, y1 = y1, y0
151
+ box = (
152
+ max(0, min(width, round(x0 / 1000 * width))),
153
+ max(0, min(height, round(y0 / 1000 * height))),
154
+ max(0, min(width, round(x1 / 1000 * width))),
155
+ max(0, min(height, round(y1 / 1000 * height))),
156
+ )
157
+ return box if box[2] > box[0] and box[3] > box[1] else None
158
+
159
+
160
+ def parse_matches(
161
+ text: str, width: int, height: int, target: str, box_order: str = "yxyx",
162
+ ) -> list[Match]:
163
+ matches = []
164
+ for item in _items(text):
165
+ if not isinstance(item, dict):
166
+ continue
167
+ box = _normalize_box(item, width, height, box_order)
168
+ if box is None:
169
+ continue
170
+ label = str(item.get("label") or item.get("caption") or item.get("description") or target).strip()
171
+ matches.append(Match(label or target, box))
172
+ return matches
173
+
174
+
175
+ def _parse_region(region: str, width: int, height: int) -> tuple[int, int, int, int]:
176
+ try:
177
+ x1, y1, x2, y2 = (int(value) for value in region.split(","))
178
+ except ValueError:
179
+ raise GroundError("--region expects four integers: X1,Y1,X2,Y2 (pixels)") from None
180
+ box = (max(0, min(x1, x2)), max(0, min(y1, y2)),
181
+ min(width, max(x1, x2)), min(height, max(y1, y2)))
182
+ if box[2] <= box[0] or box[3] <= box[1]:
183
+ raise GroundError(f"--region {region} is empty after clamping to {width}x{height}")
184
+ return box
185
+
186
+
187
+ def locate(image_path: Path, target: str, region: str | None = None) -> list[Match]:
188
+ if Image is None:
189
+ raise GroundError("ground requires Pillow; install the optional dependency pillow first")
190
+ load_default_env()
191
+ box = None
192
+ try:
193
+ with Image.open(image_path) as image:
194
+ width, height = image.size
195
+ if region:
196
+ box = _parse_region(region, width, height)
197
+ buffer = io.BytesIO()
198
+ image.crop(box).save(buffer, format="PNG")
199
+ url = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
200
+ except (OSError, ValueError) as exc:
201
+ raise GroundError(f"Cannot read image: {image_path}") from exc
202
+ if box is None:
203
+ url = image_path_to_data_url(image_path)
204
+ width_used, height_used = width, height
205
+ else:
206
+ width_used, height_used = box[2] - box[0], box[3] - box[1]
207
+ # 8192 leaves room for exhaustive targets ("every UI element"): a dense
208
+ # screen can emit dozens of boxes and 2048 truncated the JSON mid-array.
209
+ box_order = coordinate_order()
210
+ response = describe_image(url, build_prompt(target, box_order), max_tokens=8192)
211
+ matches = parse_matches(response, width_used, height_used, target, box_order)
212
+ if box is None:
213
+ return matches
214
+ # Matches were parsed in crop coordinates; report them in the original image.
215
+ return [Match(m.label, (m.bbox[0] + box[0], m.bbox[1] + box[1],
216
+ m.bbox[2] + box[0], m.bbox[3] + box[1])) for m in matches]
217
+
218
+
219
+ def _position(box: tuple[int, int, int, int], width: int, height: int) -> str:
220
+ x1, y1, x2, y2 = box
221
+ x = (x1 + x2) / 2
222
+ y = (y1 + y2) / 2
223
+ horizontal = "left" if x < width / 3 else ("right" if x > width * 2 / 3 else "center")
224
+ vertical = "top" if y < height / 3 else ("bottom" if y > height * 2 / 3 else "center")
225
+ return {
226
+ ("left", "top"): "top-left", ("center", "top"): "top", ("right", "top"): "top-right",
227
+ ("left", "center"): "left", ("center", "center"): "center", ("right", "center"): "right",
228
+ ("left", "bottom"): "bottom-left", ("center", "bottom"): "bottom", ("right", "bottom"): "bottom-right",
229
+ }[(horizontal, vertical)]
230
+
231
+
232
+ def format_matches(matches: list[Match], width: int, height: int) -> list[str]:
233
+ if len(matches) == 1:
234
+ x1, y1, x2, y2 = matches[0].bbox
235
+ return [f"x1: {x1}, y1: {y1}, x2: {x2}, y2: {y2}"]
236
+ lines = []
237
+ for index, match in enumerate(matches, 1):
238
+ x1, y1, x2, y2 = match.bbox
239
+ position = _position(match.bbox, width, height)
240
+ lines.append(f"{index}. {position} {match.label} x1: {x1}, y1: {y1}, x2: {x2}, y2: {y2}")
241
+ return lines
242
+
243
+
244
+ def main() -> None:
245
+ parser = argparse.ArgumentParser(
246
+ prog="ground",
247
+ description="Locate targets in an image with natural language and output pixel coordinates",
248
+ )
249
+ parser.add_argument("image", type=Path, help="path to the image")
250
+ parser.add_argument("target", help="target object or region to locate")
251
+ parser.add_argument("--region", metavar="X1,Y1,X2,Y2",
252
+ help="search only this pixel box; output stays in original-image coordinates")
253
+ args = parser.parse_args()
254
+ try:
255
+ matches = locate(args.image.expanduser(), args.target, region=args.region)
256
+ if Image is None:
257
+ raise GroundError("ground requires Pillow; install the optional dependency pillow first")
258
+ with Image.open(args.image.expanduser()) as image:
259
+ width, height = image.size
260
+ except (GroundError, VisionError) as exc:
261
+ parser.exit(1, f"ground: {exc}\n")
262
+ for line in format_matches(matches, width, height):
263
+ print(line)
264
+
265
+
266
+ if __name__ == "__main__":
267
+ main()