@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,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())