@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.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +356 -0
- package/README.zh.md +354 -0
- package/assets/community-group-qr.png +0 -0
- package/assets/dsh-conversation-artifact.png +0 -0
- package/assets/dsh-conversation-image-qa-top.png +0 -0
- package/assets/dsh-conversation-image-qa.png +0 -0
- package/assets/dsh-conversation-pixel-diff.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging.png +0 -0
- package/assets/dsh-conversation-tool-call.png +0 -0
- package/assets/dsh-conversation-vision-trace.png +0 -0
- package/assets/dsh-view-example.png +0 -0
- package/assets/hero-v2.png +0 -0
- package/assets/logo_aihubmix.png +0 -0
- package/assets/python-bootstrap.json +48 -0
- package/assets/skill/SKILL.md +329 -0
- package/assets/skill/UPSTREAM.json +71 -0
- package/assets/skill/references/gui.md +88 -0
- package/assets/skill/references/long-screenshot-ocr.md +77 -0
- package/assets/skill/references/restore-graphic.md +84 -0
- package/assets/skill/references/restore-structure.md +45 -0
- package/assets/skill/references/restore-ui.md +202 -0
- package/assets/social-preview.png +0 -0
- package/assets/upstream/README.md +18 -0
- package/assets/upstream/focus-hint-comparison-1.webp +0 -0
- package/assets/upstream/focus-hint-comparison-2.webp +0 -0
- package/assets/upstream/infographic-reference.webp +0 -0
- package/assets/upstream/infographic-result.webp +0 -0
- package/assets/upstream/ui-fast-restore-reference.webp +0 -0
- package/assets/upstream/ui-fast-restore-result.webp +0 -0
- package/assets/upstream/ui-result.webp +0 -0
- package/assets/upstream/ui-sketch.webp +0 -0
- package/assets/vision-model-test.png +0 -0
- package/assets/vision-settings.png +0 -0
- package/assets/wechat-reward.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
- package/docs/aihubmix-gemini-vision.md +138 -0
- package/docs/aihubmix-gemini-vision.zh.md +138 -0
- package/docs/assets/aihubmix-api-keys.png +0 -0
- package/docs/assets/aihubmix-create-key.png +0 -0
- package/docs/assets/aihubmix-free-vision-model.png +0 -0
- package/docs/assets/aihubmix-home.png +0 -0
- package/docs/assets/aihubmix-sign-up.png +0 -0
- package/docs/assets/groq-console-home.png +0 -0
- package/docs/assets/groq-console-keys-login.png +0 -0
- package/docs/assets/groq-docs-quickstart.png +0 -0
- package/docs/assets/groq-docs-qwen3.6.png +0 -0
- package/docs/assets/groq-docs-vision.png +0 -0
- package/docs/assets/vision-settings.png +0 -0
- package/docs/dsh-desktop-install.md +67 -0
- package/docs/dsh-desktop-install.zh.md +67 -0
- package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
- package/docs/groq-qwen3.6-vision.md +236 -0
- package/docs/groq-qwen3.6-vision.zh.md +236 -0
- package/docs/python-runtime.i18n.yaml +6 -0
- package/docs/python-runtime.md +89 -0
- package/docs/python-runtime.zh.md +89 -0
- package/docs/requirements-traceability/README.i18n.yaml +6 -0
- package/docs/requirements-traceability/README.md +75 -0
- package/docs/requirements-traceability/README.zh.md +75 -0
- package/examples/ui-restoration/README.i18n.yaml +6 -0
- package/examples/ui-restoration/README.md +70 -0
- package/examples/ui-restoration/README.zh.md +70 -0
- package/examples/ui-restoration/assets/final-heatmap.png +0 -0
- package/examples/ui-restoration/assets/final-report.json +83 -0
- package/examples/ui-restoration/assets/implementation.png +0 -0
- package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
- package/examples/ui-restoration/assets/initial-report.json +83 -0
- package/examples/ui-restoration/assets/initial.png +0 -0
- package/examples/ui-restoration/assets/metrics.json +12 -0
- package/examples/ui-restoration/assets/reference.png +0 -0
- package/examples/ui-restoration/implementation.html +94 -0
- package/examples/ui-restoration/initial.html +57 -0
- package/lib/artifact-access.js +369 -0
- package/lib/artifact-access.js.map +1 -0
- package/lib/artifacts.js +56 -0
- package/lib/artifacts.js.map +1 -0
- package/lib/client.js +2099 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +172 -0
- package/lib/config.js.map +1 -0
- package/lib/defaults.js +6 -0
- package/lib/defaults.js.map +1 -0
- package/lib/errors.js +56 -0
- package/lib/errors.js.map +1 -0
- package/lib/evidence-cache.js +350 -0
- package/lib/evidence-cache.js.map +1 -0
- package/lib/exposure.js +247 -0
- package/lib/exposure.js.map +1 -0
- package/lib/image-input-variants.js +882 -0
- package/lib/image-input-variants.js.map +1 -0
- package/lib/index.js +105 -0
- package/lib/index.js.map +1 -0
- package/lib/paste-images.js +217 -0
- package/lib/paste-images.js.map +1 -0
- package/lib/paths.js +354 -0
- package/lib/paths.js.map +1 -0
- package/lib/plugin-update.js +1003 -0
- package/lib/plugin-update.js.map +1 -0
- package/lib/runtime-install.js +964 -0
- package/lib/runtime-install.js.map +1 -0
- package/lib/runtime-manager.js +133 -0
- package/lib/runtime-manager.js.map +1 -0
- package/lib/runtime.js +1678 -0
- package/lib/runtime.js.map +1 -0
- package/lib/skill.js +25 -0
- package/lib/skill.js.map +1 -0
- package/lib/tools.js +549 -0
- package/lib/tools.js.map +1 -0
- package/lib/types/artifact-access.d.ts +61 -0
- package/lib/types/artifact-access.d.ts.map +1 -0
- package/lib/types/artifacts.d.ts +42 -0
- package/lib/types/artifacts.d.ts.map +1 -0
- package/lib/types/client/display-config.d.ts +24 -0
- package/lib/types/client/display-config.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +350 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/model-variants-hider.d.ts +40 -0
- package/lib/types/client/model-variants-hider.d.ts.map +1 -0
- package/lib/types/client/paste-images.d.ts +126 -0
- package/lib/types/client/paste-images.d.ts.map +1 -0
- package/lib/types/config.d.ts +123 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.d.ts.map +1 -0
- package/lib/types/errors.d.ts +35 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/evidence-cache.d.ts +91 -0
- package/lib/types/evidence-cache.d.ts.map +1 -0
- package/lib/types/exposure.d.ts +50 -0
- package/lib/types/exposure.d.ts.map +1 -0
- package/lib/types/image-input-variants.d.ts +144 -0
- package/lib/types/image-input-variants.d.ts.map +1 -0
- package/lib/types/index.d.ts +19 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/paste-images.d.ts +73 -0
- package/lib/types/paste-images.d.ts.map +1 -0
- package/lib/types/paths.d.ts +117 -0
- package/lib/types/paths.d.ts.map +1 -0
- package/lib/types/plugin-update.d.ts +119 -0
- package/lib/types/plugin-update.d.ts.map +1 -0
- package/lib/types/runtime-install.d.ts +92 -0
- package/lib/types/runtime-install.d.ts.map +1 -0
- package/lib/types/runtime-manager.d.ts +60 -0
- package/lib/types/runtime-manager.d.ts.map +1 -0
- package/lib/types/runtime.d.ts +412 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/skill.d.ts +14 -0
- package/lib/types/skill.d.ts.map +1 -0
- package/lib/types/tools.d.ts +35 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/upstream.d.ts +233 -0
- package/lib/types/upstream.d.ts.map +1 -0
- package/lib/types/version.d.ts +15 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/web-request.d.ts +11 -0
- package/lib/types/web-request.d.ts.map +1 -0
- package/lib/types/web.d.ts +118 -0
- package/lib/types/web.d.ts.map +1 -0
- package/lib/upstream.js +860 -0
- package/lib/upstream.js.map +1 -0
- package/lib/version.js +18 -0
- package/lib/version.js.map +1 -0
- package/lib/web-request.js +29 -0
- package/lib/web-request.js.map +1 -0
- package/lib/web.js +462 -0
- package/lib/web.js.map +1 -0
- package/package.json +149 -0
- package/patches/vision-tools-dsh.patch +970 -0
- package/runtime/requirements.lock +3 -0
- package/src/artifact-access.ts +386 -0
- package/src/artifacts.ts +85 -0
- package/src/client/display-config.ts +62 -0
- package/src/client/index.tsx +1580 -0
- package/src/client/model-variants-hider.ts +159 -0
- package/src/client/paste-images.tsx +747 -0
- package/src/config.ts +290 -0
- package/src/defaults.ts +5 -0
- package/src/errors.ts +62 -0
- package/src/evidence-cache.ts +418 -0
- package/src/exposure.ts +265 -0
- package/src/image-input-variants.ts +996 -0
- package/src/index.ts +141 -0
- package/src/paste-images.ts +286 -0
- package/src/paths.ts +383 -0
- package/src/plugin-update.ts +1143 -0
- package/src/runtime-install.ts +1161 -0
- package/src/runtime-manager.ts +174 -0
- package/src/runtime.ts +2189 -0
- package/src/skill.ts +35 -0
- package/src/tools.ts +691 -0
- package/src/upstream.ts +1078 -0
- package/src/version.ts +37 -0
- package/src/web-request.ts +27 -0
- package/src/web.ts +594 -0
- package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
- package/vendor/agent-vision-toolkit/LICENSE +21 -0
- package/vendor/agent-vision-toolkit/README.md +405 -0
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
- package/vendor/agent-vision-toolkit/bin/crop +90 -0
- package/vendor/agent-vision-toolkit/bin/detect +13 -0
- package/vendor/agent-vision-toolkit/bin/glance +93 -0
- package/vendor/agent-vision-toolkit/bin/ground +13 -0
- package/vendor/agent-vision-toolkit/bin/trace +129 -0
- package/vendor/agent-vision-toolkit/detect.py +60 -0
- package/vendor/agent-vision-toolkit/ground.py +267 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
- package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
- 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()
|