@saifymatteo/pi-vision 0.0.0-stage → 0.1.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.
@@ -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())
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """html_shot: one-command HTML file (or URL) -> PNG screenshot.
3
+
4
+ The render loop for rebuild work: write HTML, screenshot it, pixel_diff it
5
+ against the design image. Rendering happens in a real headless
6
+ Chrome/Chromium/Edge, so the shot shows what a browser actually renders —
7
+ no Python rendering stack, no dependencies beyond a Chrome-family browser
8
+ and the stdlib.
9
+
10
+ Only the viewport is captured; pass --height (and --scale for HiDPI)
11
+ when the page is taller than the default window. Local HTML files and
12
+ http(s):// URLs both work.
13
+ """
14
+
15
+ import argparse
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+ from urllib.parse import urlparse
22
+
23
+ CHROME_CANDIDATES = (
24
+ # macOS
25
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
26
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
27
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
28
+ # Linux
29
+ "google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
30
+ "microsoft-edge", "brave-browser",
31
+ )
32
+
33
+
34
+ def find_chrome() -> str | None:
35
+ for candidate in CHROME_CANDIDATES:
36
+ path = shutil.which(candidate)
37
+ if path:
38
+ return path
39
+ if candidate.startswith("/") and Path(candidate).is_file():
40
+ return candidate
41
+ for env in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
42
+ base = os.environ.get(env)
43
+ if not base:
44
+ continue
45
+ for sub in ("Google/Chrome/Application/chrome.exe",
46
+ "Microsoft/Edge/Application/msedge.exe"):
47
+ path = Path(base) / sub
48
+ if path.is_file():
49
+ return str(path)
50
+ return None
51
+
52
+
53
+ def default_output(source: str) -> str:
54
+ # Renders land in the skill's work/ dir (gitignored), not cwd.
55
+ work = Path(__file__).resolve().parent.parent / "work"
56
+ work.mkdir(exist_ok=True)
57
+ if source.startswith(("http://", "https://", "file://", "data:")):
58
+ stem = Path(urlparse(source).path).stem
59
+ return str(work / f"{stem or 'page'}.png")
60
+ return str(work / f"{Path(source).stem}.png")
61
+
62
+
63
+ def main() -> None:
64
+ parser = argparse.ArgumentParser(
65
+ prog="html_shot",
66
+ description="Screenshot an HTML file (or URL) to PNG with headless Chrome/Chromium/Edge",
67
+ )
68
+ parser.add_argument("source", help="path to a local .html file, or an http(s):// URL")
69
+ parser.add_argument("-o", "--output", help="output PNG path (default: <source-stem>.png in the current directory)")
70
+ parser.add_argument("--width", type=int, default=1280, help="viewport width in CSS pixels (default: 1280)")
71
+ parser.add_argument("--height", type=int, default=800, help="viewport height in CSS pixels (default: 800)")
72
+ parser.add_argument("--scale", type=int, default=1,
73
+ help="device scale factor: 2 makes the PNG 2x for small text (default: 1)")
74
+ parser.add_argument("--wait-ms", type=int, default=0,
75
+ help="virtual time budget in ms before capturing (default: 0, capture immediately)")
76
+ args = parser.parse_args()
77
+
78
+ chrome = find_chrome()
79
+ if not chrome:
80
+ parser.exit(1, "html_shot: no Chrome/Chromium/Edge found; install one and retry\n")
81
+
82
+ source = args.source
83
+ if not source.startswith(("http://", "https://", "file://", "data:")):
84
+ path = Path(source).expanduser()
85
+ if not path.is_file():
86
+ parser.exit(1, f"html_shot: file not found: {path}\n")
87
+ source = path.resolve().as_uri()
88
+
89
+ output = Path(args.output).expanduser().resolve() if args.output else Path(default_output(source)).resolve()
90
+
91
+ command = [
92
+ chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars",
93
+ "--no-first-run", "--no-default-browser-check",
94
+ f"--window-size={args.width},{args.height}",
95
+ f"--screenshot={output}",
96
+ ]
97
+ if args.scale != 1:
98
+ command.append(f"--force-device-scale-factor={args.scale}")
99
+ if args.wait_ms > 0:
100
+ command.append(f"--virtual-time-budget={args.wait_ms}")
101
+ command.append(source)
102
+
103
+ result = subprocess.run(command, text=True, capture_output=True)
104
+ if result.returncode != 0 or not output.is_file():
105
+ message = result.stderr.strip() or result.stdout.strip() or f"chrome exited with code {result.returncode}"
106
+ parser.exit(1, f"html_shot: capture failed: {message}\n")
107
+ print(f"wrote {output} ({args.width * args.scale}x{args.height * args.scale})")
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()