@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,224 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""dominant_colors: a region's significant colours, and the exact value among a candidate palette.
|
|
3
|
+
|
|
4
|
+
Two halves of one job. First you extract the region's palette -- downsample,
|
|
5
|
+
quantize, merge near-duplicates -- so you can see which colours are actually
|
|
6
|
+
there and how much of the region each one owns. That share histogram is the
|
|
7
|
+
role map: a background takes a big share, an accent a small one.
|
|
8
|
+
|
|
9
|
+
A vision model (glance) names those clusters in prose ("white", "light gray",
|
|
10
|
+
"orange") but not their values. So for the value itself you supply the
|
|
11
|
+
candidate palette your label implies ("light gray" -> #F9FAFA / #F5F5F5 /
|
|
12
|
+
#F3F3F3 / #EDEDED) and this script decides from the pixels: each candidate is
|
|
13
|
+
scored by a distance filter over the region's pixels, and the best one wins.
|
|
14
|
+
The label comes from the model; the number always comes from here.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from PIL import Image, ImageChops
|
|
24
|
+
except ImportError:
|
|
25
|
+
Image = None
|
|
26
|
+
ImageChops = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_rgb(path: Path) -> "Image.Image":
|
|
30
|
+
# Composite transparency on white the way a viewer would; unflattened
|
|
31
|
+
# alpha reads as black and skews every colour statistic.
|
|
32
|
+
image = Image.open(path)
|
|
33
|
+
if image.mode in ("RGBA", "LA", "P"):
|
|
34
|
+
image = image.convert("RGBA")
|
|
35
|
+
canvas = Image.new("RGB", image.size, "white")
|
|
36
|
+
canvas.paste(image, mask=image.split()[-1])
|
|
37
|
+
image = canvas
|
|
38
|
+
else:
|
|
39
|
+
image = image.convert("RGB")
|
|
40
|
+
return image
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_region(region: str, width: int, height: int) -> tuple[int, int, int, int]:
|
|
44
|
+
try:
|
|
45
|
+
x1, y1, x2, y2 = (int(v) for v in region.split(","))
|
|
46
|
+
except ValueError:
|
|
47
|
+
raise ValueError("--region expects four integers: X1,Y1,X2,Y2 (pixels)") from None
|
|
48
|
+
box = (max(0, min(x1, x2)), max(0, min(y1, y2)),
|
|
49
|
+
min(width, max(x1, x2)), min(height, max(y1, y2)))
|
|
50
|
+
if box[2] <= box[0] or box[3] <= box[1]:
|
|
51
|
+
raise ValueError(f"--region {region} is empty after clamping to {width}x{height}")
|
|
52
|
+
return box
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def hex_of(rgb: tuple[int, int, int]) -> str:
|
|
56
|
+
return "#{:02X}{:02X}{:02X}".format(*rgb)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_hex(text: str) -> tuple[int, int, int]:
|
|
60
|
+
value = text.strip().lstrip("#")
|
|
61
|
+
if len(value) != 6:
|
|
62
|
+
raise ValueError(f"invalid colour {text!r}: expected #RRGGBB")
|
|
63
|
+
return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def chebyshev(a: tuple[int, int, int], b: tuple[int, int, int]) -> int:
|
|
67
|
+
return max(abs(x - y) for x, y in zip(a, b))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def bar(share: float, max_share: float, width: int = 20) -> str:
|
|
71
|
+
fill = round(share / max_share * width) if max_share else 0
|
|
72
|
+
return "#" * fill
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _Cluster:
|
|
76
|
+
__slots__ = ("rgb", "count")
|
|
77
|
+
|
|
78
|
+
def __init__(self, rgb: tuple[int, int, int], count: int) -> None:
|
|
79
|
+
self.rgb = rgb
|
|
80
|
+
self.count = count
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def extract(image: "Image.Image", box: tuple[int, int, int, int], top: int,
|
|
84
|
+
quantize_k: int, max_pixels: int, merge_tol: int) -> list[_Cluster]:
|
|
85
|
+
"""Downsample, quantize, merge near-duplicates; return clusters sorted by count."""
|
|
86
|
+
crop = image.crop(box)
|
|
87
|
+
width, height = crop.size
|
|
88
|
+
scale = min(1.0, max_pixels / max(width, height))
|
|
89
|
+
if scale < 1.0:
|
|
90
|
+
crop = crop.resize((max(1, round(width * scale)), max(1, round(height * scale))), Image.LANCZOS)
|
|
91
|
+
quantized = crop.quantize(colors=quantize_k, method=Image.MEDIANCUT)
|
|
92
|
+
palette = quantized.getpalette()
|
|
93
|
+
clusters: list[_Cluster] = []
|
|
94
|
+
for count, index in sorted(quantized.getcolors(maxcolors=quantize_k), reverse=True):
|
|
95
|
+
rgb = (palette[index * 3], palette[index * 3 + 1], palette[index * 3 + 2])
|
|
96
|
+
for existing in clusters:
|
|
97
|
+
if chebyshev(rgb, existing.rgb) <= merge_tol:
|
|
98
|
+
total = existing.count + count
|
|
99
|
+
existing.rgb = tuple(
|
|
100
|
+
round((existing.rgb[i] * existing.count + rgb[i] * count) / total)
|
|
101
|
+
for i in range(3))
|
|
102
|
+
existing.count = total
|
|
103
|
+
break
|
|
104
|
+
else:
|
|
105
|
+
clusters.append(_Cluster(rgb, count))
|
|
106
|
+
clusters.sort(key=lambda c: c.count, reverse=True)
|
|
107
|
+
return clusters
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def pick(image: "Image.Image", box: tuple[int, int, int, int], candidates: list[str],
|
|
111
|
+
tol: int) -> tuple[list[dict], dict, dict]:
|
|
112
|
+
"""Score each candidate by how close the region's pixels are to it."""
|
|
113
|
+
crop = image.crop(box)
|
|
114
|
+
width, height = crop.size
|
|
115
|
+
total = width * height
|
|
116
|
+
bands = crop.split()
|
|
117
|
+
rows = []
|
|
118
|
+
for text in candidates:
|
|
119
|
+
rgb = parse_hex(text)
|
|
120
|
+
farthest = None
|
|
121
|
+
for band, value in zip(bands, rgb):
|
|
122
|
+
diff = ImageChops.difference(band, Image.new("L", (width, height), value))
|
|
123
|
+
farthest = diff if farthest is None else ImageChops.lighter(farthest, diff)
|
|
124
|
+
histogram = farthest.histogram()
|
|
125
|
+
mean_distance = sum(i * count for i, count in enumerate(histogram)) / total
|
|
126
|
+
hard = sum(count for i, count in enumerate(histogram) if i <= tol)
|
|
127
|
+
# Soft support: pixels closer to the candidate weigh more, so a match
|
|
128
|
+
# is not a binary in/out -- a candidate exactly on the colour wins over
|
|
129
|
+
# a near neighbour that is also inside the tolerance.
|
|
130
|
+
weighted = sum(max(0, tol - i) * count for i, count in enumerate(histogram))
|
|
131
|
+
rows.append({"text": text, "rgb": rgb, "mean_distance": mean_distance,
|
|
132
|
+
"hard": hard, "weighted": weighted,
|
|
133
|
+
"share": hard / total * 100})
|
|
134
|
+
winner = max(rows, key=lambda r: (r["weighted"], r["hard"]))
|
|
135
|
+
closest = min(rows, key=lambda r: r["mean_distance"])
|
|
136
|
+
return rows, winner, closest
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def format_extract(clusters: list[_Cluster], top: int, box: tuple[int, int, int, int],
|
|
140
|
+
merge_tol: int) -> list[str]:
|
|
141
|
+
total = sum(c.count for c in clusters)
|
|
142
|
+
if not total:
|
|
143
|
+
return ["(region has no pixels)"]
|
|
144
|
+
width = box[2] - box[0]
|
|
145
|
+
height = box[3] - box[1]
|
|
146
|
+
max_share = max(c.count / total for c in clusters) * 100
|
|
147
|
+
lines = [f"region {box[0]},{box[1]},{box[2]},{box[3]} - {width}x{height} px",
|
|
148
|
+
f"top {top} of {len(clusters)} clusters (merged at distance <= {merge_tol}):"]
|
|
149
|
+
for cluster in clusters[:top]:
|
|
150
|
+
share = cluster.count / total * 100
|
|
151
|
+
lines.append(f"{hex_of(cluster.rgb)} {share:5.1f}% {bar(share, max_share)}")
|
|
152
|
+
return lines
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def format_pick(rows: list[dict], winner: dict, closest: dict,
|
|
156
|
+
box: tuple[int, int, int, int], tol: int) -> list[str]:
|
|
157
|
+
width = box[2] - box[0]
|
|
158
|
+
height = box[3] - box[1]
|
|
159
|
+
total = width * height
|
|
160
|
+
max_share = max(r["share"] for r in rows) or 1.0
|
|
161
|
+
max_wt = max(r["weighted"] for r in rows)
|
|
162
|
+
lines = [f"region {box[0]},{box[1]},{box[2]},{box[3]} - {width}x{height} px ({total} px sampled)",
|
|
163
|
+
"candidate share mean_d wt bar"]
|
|
164
|
+
for row in rows:
|
|
165
|
+
mark = "*" if row is winner else " "
|
|
166
|
+
wt = (row["weighted"] / max_wt * 100) if max_wt else 0.0
|
|
167
|
+
lines.append(f"{mark}{row['text']:<9} {row['share']:5.1f}% {row['mean_distance']:4.1f} "
|
|
168
|
+
f"{wt:4.0f}% "
|
|
169
|
+
f"{bar(row['share'], max_share)}")
|
|
170
|
+
if winner["hard"] == 0:
|
|
171
|
+
lines.append(f"note: no candidate is within distance <= {tol} of the region; "
|
|
172
|
+
f"closest by mean distance is {closest['text']}")
|
|
173
|
+
else:
|
|
174
|
+
lines.append(f"winner: {winner['text']} (* in table) - wt is soft-match closeness, "
|
|
175
|
+
f"so the winner need not have the highest share; "
|
|
176
|
+
f"{winner['share']:.1f}% of region pixels within distance <= {tol}")
|
|
177
|
+
return lines
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def main() -> None:
|
|
181
|
+
parser = argparse.ArgumentParser(
|
|
182
|
+
prog="dominant_colors",
|
|
183
|
+
description="Extract a region's significant colours, or pick the concrete value among a candidate palette",
|
|
184
|
+
)
|
|
185
|
+
parser.add_argument("image", type=Path, help="the image")
|
|
186
|
+
parser.add_argument("--region", metavar="X1,Y1,X2,Y2",
|
|
187
|
+
help="restrict to this pixel box (e.g. from ground); default: whole image")
|
|
188
|
+
parser.add_argument("--candidates", metavar="LIST",
|
|
189
|
+
help="pick mode: comma-separated candidate palette, e.g. #F9FAFA,#F5F5F5,#F3F3F3,#EDEDED")
|
|
190
|
+
parser.add_argument("--top", type=int, default=5, help="how many significant clusters to show (default: 5)")
|
|
191
|
+
parser.add_argument("--quantize", type=int, default=16,
|
|
192
|
+
help="palette size before merging near-duplicates (default: 16)")
|
|
193
|
+
parser.add_argument("--max-pixels", type=int, default=96,
|
|
194
|
+
help="downsample the crop to at most this on the long side (default: 96)")
|
|
195
|
+
parser.add_argument("--merge-tol", type=int, default=8,
|
|
196
|
+
help="merge clusters whose centres are within this per-channel distance (default: 8)")
|
|
197
|
+
parser.add_argument("--tol", type=int, default=16,
|
|
198
|
+
help="pick mode: per-channel distance for 'matches this candidate' (default: 16)")
|
|
199
|
+
args = parser.parse_args()
|
|
200
|
+
|
|
201
|
+
if Image is None:
|
|
202
|
+
parser.exit(1, "dominant_colors: requires Pillow; install the optional dependency pillow first\n")
|
|
203
|
+
path = args.image.expanduser()
|
|
204
|
+
if not path.is_file():
|
|
205
|
+
parser.exit(1, f"dominant_colors: image not found: {path}\n")
|
|
206
|
+
try:
|
|
207
|
+
image = load_rgb(path)
|
|
208
|
+
width, height = image.size
|
|
209
|
+
box = parse_region(args.region, width, height) if args.region else (0, 0, width, height)
|
|
210
|
+
if args.candidates:
|
|
211
|
+
candidates = [candidate.strip() for candidate in args.candidates.split(",") if candidate.strip()]
|
|
212
|
+
if not candidates:
|
|
213
|
+
parser.exit(1, "dominant_colors: --candidates needs at least one #RRGGBB\n")
|
|
214
|
+
rows, winner, closest = pick(image, box, candidates, args.tol)
|
|
215
|
+
print("\n".join(format_pick(rows, winner, closest, box, args.tol)))
|
|
216
|
+
else:
|
|
217
|
+
clusters = extract(image, box, args.top, args.quantize, args.max_pixels, args.merge_tol)
|
|
218
|
+
print("\n".join(format_extract(clusters, args.top, box, args.merge_tol)))
|
|
219
|
+
except ValueError as exc:
|
|
220
|
+
parser.exit(1, f"dominant_colors: {exc}\n")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
main()
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""extract_fg.py — 从截图提取图标/logo 前景(透明 PNG)。
|
|
3
|
+
|
|
4
|
+
手动模式(2026-08 实测):搜索区域内取满足判定的像素,整体做 8 邻域连通分量
|
|
5
|
+
分析,保留最大连通分量即为前景。背景噪点是散点、图标线条是连续线,连通性
|
|
6
|
+
自动分离,无需预先知道主色,抗锯齿全部保留。
|
|
7
|
+
|
|
8
|
+
用法:
|
|
9
|
+
python3 scripts/extract_fg.py shot.png --region X1,Y1,X2,Y2 -o icon.png
|
|
10
|
+
python3 scripts/extract_fg.py shot.png --region X1,Y1,X2,Y2 --mode dark # 灰色/黑色线条(logo)
|
|
11
|
+
python3 scripts/extract_fg.py shot.png --region X1,Y1,X2,Y2 --exclude-color '#E6E6E6' # 彩色背景干扰
|
|
12
|
+
自动模式(crop --scale 输出、图标居中、浅色圆底;多张可一次传):
|
|
13
|
+
python3 scripts/extract_fg.py d/icon1.png d/icon2.png # 圆心=图中心,圆底半径/颜色自动推断
|
|
14
|
+
python3 scripts/extract_fg.py d/icon1.png --disc-radius 60 # 圆底半径兜底
|
|
15
|
+
python3 scripts/extract_fg.py d/icon1.png --boxes "101,84,184,171" # ground 框校正
|
|
16
|
+
|
|
17
|
+
输出: 透明背景 PNG(仅前景像素保留原色,其余 alpha=0),打印 bbox / 像素数 / 分量数。
|
|
18
|
+
自动模式输出单一图形分量(前 3 大彩色分量中饱和度最高者,白环/文字自动出局)。
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from PIL import Image
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def connected_components(ink: set, w: int, h: int) -> list[list[tuple[int, int]]]:
|
|
30
|
+
"""8 邻域连通分量,按大小降序返回。"""
|
|
31
|
+
seen: set = set()
|
|
32
|
+
comps: list[list[tuple[int, int]]] = []
|
|
33
|
+
for p in ink:
|
|
34
|
+
if p in seen:
|
|
35
|
+
continue
|
|
36
|
+
stack = [p]
|
|
37
|
+
seen.add(p)
|
|
38
|
+
comp = []
|
|
39
|
+
while stack:
|
|
40
|
+
cx, cy = stack.pop()
|
|
41
|
+
comp.append((cx, cy))
|
|
42
|
+
for dx in (-1, 0, 1):
|
|
43
|
+
for dy in (-1, 0, 1):
|
|
44
|
+
if dx == 0 and dy == 0:
|
|
45
|
+
continue
|
|
46
|
+
q = (cx + dx, cy + dy)
|
|
47
|
+
if q in seen or q not in ink:
|
|
48
|
+
continue
|
|
49
|
+
seen.add(q)
|
|
50
|
+
stack.append(q)
|
|
51
|
+
comps.append(comp)
|
|
52
|
+
comps.sort(key=len, reverse=True)
|
|
53
|
+
return comps
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def process_one(image: str, args: argparse.Namespace) -> int:
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
im = Image.open(image).convert("RGB")
|
|
60
|
+
except Exception as e:
|
|
61
|
+
print(f"error: cannot open {image}: {e}", file=sys.stderr)
|
|
62
|
+
return 1
|
|
63
|
+
w, h = im.size
|
|
64
|
+
|
|
65
|
+
auto_mode = args.region is None
|
|
66
|
+
if auto_mode:
|
|
67
|
+
try:
|
|
68
|
+
import numpy as np
|
|
69
|
+
except ImportError:
|
|
70
|
+
print("error: auto mode requires numpy; pass --region instead", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
arr = np.asarray(im).astype(int)
|
|
73
|
+
box = None
|
|
74
|
+
if args.boxes:
|
|
75
|
+
try:
|
|
76
|
+
box = tuple(int(v) for v in args.boxes.split(","))
|
|
77
|
+
except ValueError:
|
|
78
|
+
print("error: --boxes must be X1,Y1,X2,Y2", file=sys.stderr)
|
|
79
|
+
return 1
|
|
80
|
+
cx, cy = (box[0] + box[2]) / 2, (box[1] + box[3]) / 2
|
|
81
|
+
radius = args.disc_radius or max(box[2] - box[0], box[3] - box[1]) * 0.8
|
|
82
|
+
else:
|
|
83
|
+
cx, cy = w / 2, h / 2
|
|
84
|
+
radius = args.disc_radius or min(w, h) / 2 * 0.6
|
|
85
|
+
yy, xx = np.mgrid[0:h, 0:w]
|
|
86
|
+
dist = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
|
|
87
|
+
ring = (dist > radius * 0.75) & (dist < radius * 0.95)
|
|
88
|
+
if ring.sum() == 0:
|
|
89
|
+
print("error: disc ring empty (--disc-radius too small?)", file=sys.stderr)
|
|
90
|
+
return 1
|
|
91
|
+
mean = arr[ring].mean(0).round(0).astype(int)
|
|
92
|
+
auto_excl = tuple(int(v) for v in mean)
|
|
93
|
+
print(f"auto: center=({cx:.0f},{cy:.0f}) disc radius≈{radius:.0f} "
|
|
94
|
+
f"exclude-color=#{auto_excl[0]:02X}{auto_excl[1]:02X}{auto_excl[2]:02X}")
|
|
95
|
+
x1, y1, x2, y2 = 0, 0, w, h
|
|
96
|
+
else:
|
|
97
|
+
auto_excl = None
|
|
98
|
+
box = None
|
|
99
|
+
try:
|
|
100
|
+
x1, y1, x2, y2 = (int(v) for v in args.region.split(","))
|
|
101
|
+
except ValueError:
|
|
102
|
+
print("error: --region must be X1,Y1,X2,Y2", file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
x1, y1 = max(0, x1), max(0, y1)
|
|
105
|
+
x2, y2 = min(w, x2), min(h, y2)
|
|
106
|
+
if x2 <= x1 or y2 <= y1:
|
|
107
|
+
print("error: empty region", file=sys.stderr)
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
excl = None
|
|
111
|
+
if auto_excl:
|
|
112
|
+
excl = auto_excl
|
|
113
|
+
exclude_tol = 35 # 自动模式:圆底渐变排除容差(默认 24 排不干净渐变)
|
|
114
|
+
elif args.exclude_color:
|
|
115
|
+
v = args.exclude_color.lstrip("#")
|
|
116
|
+
try:
|
|
117
|
+
excl = tuple(int(v[i:i + 2], 16) for i in (0, 2, 4))
|
|
118
|
+
except ValueError:
|
|
119
|
+
print("error: --exclude-color must be #RRGGBB", file=sys.stderr)
|
|
120
|
+
return 1
|
|
121
|
+
exclude_tol = args.exclude_tol
|
|
122
|
+
else:
|
|
123
|
+
exclude_tol = args.exclude_tol
|
|
124
|
+
|
|
125
|
+
ink: set = set()
|
|
126
|
+
px = im.load()
|
|
127
|
+
for y in range(y1, y2):
|
|
128
|
+
for x in range(x1, x2):
|
|
129
|
+
r, g, b = px[x, y]
|
|
130
|
+
mx, mn = max(r, g, b), min(r, g, b)
|
|
131
|
+
if args.mode == "color":
|
|
132
|
+
if mx - mn <= args.sat:
|
|
133
|
+
continue
|
|
134
|
+
else:
|
|
135
|
+
if mx >= args.dark:
|
|
136
|
+
continue
|
|
137
|
+
if excl is not None:
|
|
138
|
+
d = ((r - excl[0]) ** 2 + (g - excl[1]) ** 2 + (b - excl[2]) ** 2) ** 0.5
|
|
139
|
+
if d <= exclude_tol:
|
|
140
|
+
continue
|
|
141
|
+
ink.add((x - x1, y - y1))
|
|
142
|
+
|
|
143
|
+
if not ink:
|
|
144
|
+
print("error: no foreground pixels found in region (raise --sat or lower --dark?)",
|
|
145
|
+
file=sys.stderr)
|
|
146
|
+
return 1
|
|
147
|
+
|
|
148
|
+
comps = connected_components(ink, x2 - x1, y2 - y1)
|
|
149
|
+
# 图标可能由多个分离子形状组成(云朵 logo 的 ">_" 与轮廓不相连),
|
|
150
|
+
# 保留所有足够大的分量;噪点散点(远小于最大分量)自动排除。
|
|
151
|
+
min_size = max(len(comps[0]) * 0.02, 8)
|
|
152
|
+
mx0 = [p[0] for p in comps[0]]
|
|
153
|
+
my0 = [p[1] for p in comps[0]]
|
|
154
|
+
main_box = (min(mx0), min(my0), max(mx0), max(my0))
|
|
155
|
+
|
|
156
|
+
def overlaps_main(c) -> bool:
|
|
157
|
+
cx = [p[0] for p in c]
|
|
158
|
+
cy = [p[1] for p in c]
|
|
159
|
+
return not (max(cx) < main_box[0] or min(cx) > main_box[2]
|
|
160
|
+
or max(cy) < main_box[1] or min(cy) > main_box[3])
|
|
161
|
+
|
|
162
|
+
kept = [c for c in comps if len(c) >= min_size or overlaps_main(c)]
|
|
163
|
+
if auto_mode:
|
|
164
|
+
# 自动模式:选单一图形分量——前 3 大彩色分量中饱和度最高者(白环/波纹 sat 低出局)。
|
|
165
|
+
colored = []
|
|
166
|
+
for c in kept:
|
|
167
|
+
rs = [px[x1 + p[0], y1 + p[1]][0] for p in c]
|
|
168
|
+
gs = [px[x1 + p[0], y1 + p[1]][1] for p in c]
|
|
169
|
+
bs = [px[x1 + p[0], y1 + p[1]][2] for p in c]
|
|
170
|
+
n = len(c)
|
|
171
|
+
sat = max(sum(rs) / n, sum(gs) / n, sum(bs) / n) - min(sum(rs) / n, sum(gs) / n, sum(bs) / n)
|
|
172
|
+
if sat > 25:
|
|
173
|
+
colored.append((len(c), sat, c))
|
|
174
|
+
if colored:
|
|
175
|
+
colored.sort(key=lambda t: t[0], reverse=True)
|
|
176
|
+
chosen = max(colored[:3], key=lambda t: t[1])[2]
|
|
177
|
+
else:
|
|
178
|
+
chosen = kept[0]
|
|
179
|
+
if box:
|
|
180
|
+
bx0, by0, bx1, by1 = box
|
|
181
|
+
scored = []
|
|
182
|
+
for c in kept:
|
|
183
|
+
overlap = sum(1 for p in c if bx0 <= p[0] < bx1 and by0 <= p[1] < by1)
|
|
184
|
+
scored.append((overlap, len(c), c))
|
|
185
|
+
scored.sort(key=lambda t: (t[0], t[1]), reverse=True)
|
|
186
|
+
chosen = scored[0][2]
|
|
187
|
+
best = chosen
|
|
188
|
+
pad = 0
|
|
189
|
+
else:
|
|
190
|
+
best = [p for c in kept for p in c]
|
|
191
|
+
pad = args.pad
|
|
192
|
+
bx1 = x1 + min(p[0] for p in best) - pad
|
|
193
|
+
by1 = y1 + min(p[1] for p in best) - pad
|
|
194
|
+
bx2 = x1 + max(p[0] for p in best) + 1 + pad
|
|
195
|
+
by2 = y1 + max(p[1] for p in best) + 1 + pad
|
|
196
|
+
bx1, by1 = max(0, bx1), max(0, by1)
|
|
197
|
+
bx2, by2 = min(w, bx2), min(h, by2)
|
|
198
|
+
|
|
199
|
+
out = Image.new("RGBA", (bx2 - bx1, by2 - by1), (0, 0, 0, 0))
|
|
200
|
+
o = out.load()
|
|
201
|
+
for lx, ly in best:
|
|
202
|
+
o[x1 + lx - bx1, y1 + ly - by1] = px[x1 + lx, y1 + ly] + (255,)
|
|
203
|
+
|
|
204
|
+
if not args.no_keep_whites:
|
|
205
|
+
# 背景填充:近白像素从 bbox 边缘 flood fill -> 透明(外部背景);
|
|
206
|
+
# 被彩色前景包围的近白像素(图标内部白色镂空细节)-> 保留纯白。
|
|
207
|
+
w, h = out.width, out.height
|
|
208
|
+
near: set = set()
|
|
209
|
+
for y in range(h):
|
|
210
|
+
for x in range(w):
|
|
211
|
+
r, g, b, a = o[x, y]
|
|
212
|
+
if a == 0:
|
|
213
|
+
continue
|
|
214
|
+
mx, mn = max(r, g, b), min(r, g, b)
|
|
215
|
+
if mx >= 240 and mx - mn <= 25:
|
|
216
|
+
near.add((x, y))
|
|
217
|
+
bg: set = set()
|
|
218
|
+
stack = [p for p in near if p[0] == 0 or p[1] == 0 or p[0] == w - 1 or p[1] == h - 1]
|
|
219
|
+
while stack:
|
|
220
|
+
cx, cy = stack.pop()
|
|
221
|
+
if (cx, cy) in bg:
|
|
222
|
+
continue
|
|
223
|
+
bg.add((cx, cy))
|
|
224
|
+
for dx in (-1, 0, 1):
|
|
225
|
+
for dy in (-1, 0, 1):
|
|
226
|
+
q = (cx + dx, cy + dy)
|
|
227
|
+
if q in near and q not in bg:
|
|
228
|
+
stack.append(q)
|
|
229
|
+
for x, y in bg:
|
|
230
|
+
o[x, y] = (255, 255, 255, 0)
|
|
231
|
+
for x, y in near - bg:
|
|
232
|
+
o[x, y] = (255, 255, 255, 255)
|
|
233
|
+
|
|
234
|
+
if args.output:
|
|
235
|
+
dest = Path(args.output)
|
|
236
|
+
elif auto_mode:
|
|
237
|
+
dest = Path(image).with_suffix(".clean.png")
|
|
238
|
+
else:
|
|
239
|
+
dest = Path(image).with_suffix(".fg.png")
|
|
240
|
+
out.save(dest)
|
|
241
|
+
|
|
242
|
+
print(f"bbox (原图像素): x1: {bx1}, y1: {by1}, x2: {bx2}, y2: {by2}")
|
|
243
|
+
print(f"前景像素: {len(best)} 保留分量: {len(kept)}/{len(comps)} 最大分量占比: {len(comps[0]) / len(ink) * 100:.0f}%")
|
|
244
|
+
print(f"wrote {dest} ({out.width}x{out.height})")
|
|
245
|
+
return 0
|
|
246
|
+
def main() -> int:
|
|
247
|
+
ap = argparse.ArgumentParser(description="从截图区域提取图标/logo 前景(透明 PNG)")
|
|
248
|
+
ap.add_argument("image", nargs="+", help="截图路径(PNG/JPEG/WebP)")
|
|
249
|
+
ap.add_argument("--region", metavar="X1,Y1,X2,Y2",
|
|
250
|
+
help="手动模式:搜索区域(原图像素,宽松即可);不传则自动推断(图标居中)")
|
|
251
|
+
ap.add_argument("-o", "--output", default=None,
|
|
252
|
+
help="输出 PNG 路径(默认 <image-stem>.fg.png;自动模式 <image-stem>.clean.png)")
|
|
253
|
+
ap.add_argument("--disc-radius", type=float, default=None,
|
|
254
|
+
help="自动模式:圆底半径(像素),缺省 = min(宽,高)/2 × 0.6")
|
|
255
|
+
ap.add_argument("--boxes", default=None, metavar="X1,Y1,X2,Y2",
|
|
256
|
+
help="自动模式:ground 在放大图上的框,用于圆心/半径校正与分量重叠筛选")
|
|
257
|
+
ap.add_argument("--mode", choices=("color", "dark"), default="color",
|
|
258
|
+
help="color=彩色线条(默认);dark=灰色/黑色线条(logo、图标)")
|
|
259
|
+
ap.add_argument("--sat", type=int, default=12,
|
|
260
|
+
help="color 模式饱和度阈值(RGB max-min,默认 12)")
|
|
261
|
+
ap.add_argument("--dark", type=int, default=215,
|
|
262
|
+
help="dark 模式亮度阈值(RGB max,默认 215,即 #BABBBC 级灰线可收)")
|
|
263
|
+
ap.add_argument("--exclude-color", default=None, metavar="#RRGGBB",
|
|
264
|
+
help="排除与该颜色接近的像素(彩色背景干扰时用)")
|
|
265
|
+
ap.add_argument("--exclude-tol", type=float, default=24,
|
|
266
|
+
help="--exclude-color 的距离容差(默认 24)")
|
|
267
|
+
ap.add_argument("--pad", type=int, default=3, help="输出 bbox 每边外扩像素(默认 3)")
|
|
268
|
+
ap.add_argument("--no-keep-whites", action="store_true",
|
|
269
|
+
help="不保留被前景包围的内部白色细节(默认保留)")
|
|
270
|
+
args = ap.parse_args()
|
|
271
|
+
code = 0
|
|
272
|
+
for image in args.image:
|
|
273
|
+
code |= process_one(image, args)
|
|
274
|
+
return code
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == "__main__":
|
|
278
|
+
sys.exit(main())
|