@ganziliang/desktop-pet 0.1.0

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,425 @@
1
+ #!/usr/bin/env python3
2
+ """把生图产出的动作序列图(sprite sheet)切成能直接给桌宠用的对齐精灵图。
3
+
4
+ 生图模型画出来的 sheet 有三个坑,这个脚本专门治它们:
5
+ 1. 背景不是干净的纯色,甚至换一张图就换个底色
6
+ -> 自动从四角取中位数当背景色,再「从边缘 flood fill」抠。
7
+ 只删和画布边缘连通的区域,所以角色身上的白色袜子/鞋子不会被误伤。
8
+ 2. 各帧的角色身高/站位有细微差别,直接切会抖
9
+ -> 全部帧共用一个缩放系数(取身高中位数),并且共用同一条纵向基线,
10
+ 既消掉忽大忽小,又保留姿势本身的自然起伏(走路该有的上下颠簸)。
11
+ 3. 摆臂抬腿会让包围盒左右跑,按包围盒居中会「左右滑步」
12
+ -> 用 alpha 掩膜做互相关(IoU),找出每帧相对基准帧的最优水平偏移。
13
+
14
+ 用法:
15
+ python scripts/spritesheet.py 原始图.png 输出图.png --frames 4 --height 620
16
+ python scripts/spritesheet.py 原始图.png 输出图.png --frames 4 --debug --dump-frames
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import sys
25
+
26
+ import numpy as np
27
+ from PIL import Image, ImageDraw, ImageFilter
28
+ from scipy import ndimage
29
+
30
+ # ---------------------------------------------------------------- 背景抠除
31
+
32
+
33
+ def border_color(arr: np.ndarray, ring: int = 4) -> np.ndarray:
34
+ """取画布四边一圈像素的中位数当背景色(比取四个角更抗单点噪声)。"""
35
+ top = arr[:ring].reshape(-1, 3)
36
+ bottom = arr[-ring:].reshape(-1, 3)
37
+ left = arr[:, :ring].reshape(-1, 3)
38
+ right = arr[:, -ring:].reshape(-1, 3)
39
+ all_px = np.concatenate([top, bottom, left, right], axis=0)
40
+ return np.median(all_px, axis=0)
41
+
42
+
43
+ def key_out(
44
+ arr: np.ndarray, bg: np.ndarray, lo: float, hi: float, spill: bool
45
+ ) -> np.ndarray:
46
+ """返回 float32 的 alpha(0..1)。"""
47
+ dist = np.sqrt(((arr - bg) ** 2).sum(axis=2))
48
+ near = dist < lo
49
+ ramp = np.clip((dist - lo) / max(1.0, hi - lo), 0.0, 1.0)
50
+
51
+ # 绿幕另外用「绿度」判一次:双马尾和身体之间那种被压暗的绿,颜色距离早就超阈值了,
52
+ # 但绿度依然很高。而且那种空隙常被头发/身体围成闭合区域,根本连不到画布边框,
53
+ # 所以绿幕必须允许「全局判绿」,不能只靠边框连通。
54
+ global_green = None
55
+ if spill:
56
+ green = arr[..., 1] - np.maximum(arr[..., 0], arr[..., 2])
57
+ g_lo, g_hi = 45.0, 95.0
58
+ global_green = green > g_lo
59
+ near = near | global_green
60
+ ramp = np.maximum(ramp, np.clip((green - g_lo) / (g_hi - g_lo), 0.0, 1.0))
61
+
62
+ # 1) 生图的底色往往有渐变/噪点,直接连通会碎成很多小岛(实测能碎成几十块)。
63
+ # 先闭运算把背景缝起来,再标连通域,只取「碰到画布边框」的那些 ——
64
+ # 也就是真正的背景;角色内部颜色相近的区域(白袜子)不会和边框相连。
65
+ # padding 必须填 True:否则 binary_closing 会把画布外的像素当背景,
66
+ # 边界一圈会被侵蚀掉,边框上就永远取不到背景标签了。
67
+ pad = 2
68
+ padded = np.pad(near, pad, mode="constant", constant_values=True)
69
+ closed = ndimage.binary_closing(padded, structure=np.ones((3, 3), bool), iterations=pad)
70
+ closed = closed[pad:-pad, pad:-pad]
71
+ labels, count = ndimage.label(closed)
72
+ if count:
73
+ border = np.concatenate([labels[0], labels[-1], labels[:, 0], labels[:, -1]])
74
+ touches = set(np.unique(border).tolist()) - {0}
75
+ bg_mask = np.isin(labels, list(touches)) & near
76
+ else:
77
+ bg_mask = np.zeros_like(near)
78
+ if global_green is not None:
79
+ bg_mask = bg_mask | global_green # 闭合的绿幕空隙也要去掉
80
+
81
+ # 2) 边界一圈用羽化值(半透明像素),里层一律全不透明
82
+ ring = ndimage.binary_dilation(bg_mask, structure=np.ones((3, 3), bool), iterations=2)
83
+ ring = ring & ~bg_mask
84
+ alpha = np.where(bg_mask, 0.0, np.where(ring, ramp, 1.0)).astype(np.float32)
85
+
86
+ # 3) 绿幕残留:边缘半透明像素把背景色「拔」出来,免得缩图后挂一圈绿边
87
+ if spill:
88
+ g = arr[..., 1]
89
+ cap = np.maximum(arr[..., 0], arr[..., 2])
90
+ arr[..., 1] = np.where((alpha < 0.98) & (g > cap), cap, g)
91
+
92
+ return alpha
93
+
94
+
95
+ def decontaminate(rgb: np.ndarray, alpha: np.ndarray, bg: np.ndarray) -> np.ndarray:
96
+ """半透明像素里混了背景色,反解回去,否则缩小后边缘会挂一圈底色。"""
97
+ a = alpha[..., None]
98
+ safe = np.clip(a, 1e-3, 1.0)
99
+ out = np.where(a < 0.99, (rgb - (1.0 - a) * bg) / safe, rgb)
100
+ return np.clip(out, 0, 255)
101
+
102
+
103
+ def resize_premultiplied(rgba: np.ndarray, size: tuple[int, int]) -> np.ndarray:
104
+ """预乘 alpha 再缩放。
105
+
106
+ 直接对 RGBA 做 LANCZOS 缩放会把「完全透明像素的 RGB」也蹭进边缘 ——
107
+ 抠完背景的透明区往往还留着绿/白,缩完就会挂一圈彩边。
108
+ 先乘上 alpha 再缩、缩完除回去,才是正确做法。
109
+ """
110
+ arr = rgba.astype(np.float32)
111
+ a = arr[..., 3:4] / 255.0
112
+ prem = np.concatenate([arr[..., :3] * a, arr[..., 3:4]], axis=2)
113
+ chans = [
114
+ np.asarray(Image.fromarray(prem[..., i], "F").resize(size, Image.LANCZOS))
115
+ for i in range(4)
116
+ ]
117
+ out = np.stack(chans, axis=2)
118
+ a2 = np.clip(out[..., 3:4] / 255.0, 0.0, 1.0)
119
+ rgb = np.where(a2 > 0.004, out[..., :3] / np.clip(a2, 0.004, 1.0), 0.0)
120
+ return np.concatenate([np.clip(rgb, 0, 255), np.clip(out[..., 3:4], 0, 255)], axis=2)
121
+
122
+
123
+ def to_rgba(path: str, bg_hex: str | None, lo: float, hi: float) -> tuple[np.ndarray, np.ndarray]:
124
+ img = Image.open(path)
125
+
126
+ # 生图有时会直接给透明底的 PNG(比后处理抠图干净得多,抗锯齿是模型自己画的),
127
+ # 有就直接用,不要再去猜背景色。
128
+ if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
129
+ rgba = np.asarray(img.convert("RGBA"))
130
+ if (rgba[..., 3] < 10).mean() > 0.15:
131
+ print(f" 源图自带透明通道({100 * (rgba[..., 3] < 10).mean():.1f}% 透明),直接使用")
132
+ return rgba.copy(), rgba[..., 3].astype(np.float32) / 255.0
133
+
134
+ arr = np.asarray(img.convert("RGB")).astype(np.float32)
135
+
136
+ if bg_hex:
137
+ bg = np.array([int(bg_hex[i : i + 2], 16) for i in (0, 2, 4)], dtype=np.float32)
138
+ else:
139
+ bg = border_color(arr.astype(np.uint8)).astype(np.float32)
140
+
141
+ greenish = bg[1] > max(bg[0], bg[2]) + 40
142
+ alpha = key_out(arr.copy(), bg, lo, hi, spill=greenish)
143
+ rgb = decontaminate(arr, alpha, bg)
144
+
145
+ rgba = np.dstack([rgb, alpha * 255.0]).astype(np.uint8)
146
+ print(
147
+ f" 背景色 rgb({int(bg[0])},{int(bg[1])},{int(bg[2])})"
148
+ f" -> 抠掉 {100 * (alpha < 0.5).mean():.1f}% 的像素"
149
+ )
150
+ return rgba, alpha
151
+
152
+
153
+ # ---------------------------------------------------------------- 切帧
154
+
155
+
156
+ def figure_runs(alpha: np.ndarray, frames: int, thresh: float = 0.08) -> list[tuple[int, int]]:
157
+ cols = alpha.max(axis=0) > thresh
158
+ runs: list[list[int]] = []
159
+ start = None
160
+ for x, on in enumerate(cols):
161
+ if on and start is None:
162
+ start = x
163
+ elif not on and start is not None:
164
+ runs.append([start, x])
165
+ start = None
166
+ if start is not None:
167
+ runs.append([start, len(cols)])
168
+ runs = [r for r in runs if r[1] - r[0] > 3]
169
+ if not runs:
170
+ raise SystemExit("没找到任何角色像素,检查背景色阈值(--lo/--hi)")
171
+
172
+ # 生图偶尔会把两帧粘在一起(中缝没留空),按宽度把最宽的劈开
173
+ while len(runs) < frames:
174
+ idx = int(np.argmax([r[1] - r[0] for r in runs]))
175
+ a, b = runs.pop(idx)
176
+ mid = (a + b) // 2
177
+ runs[idx:idx] = [[a, mid], [mid, b]]
178
+ print(f" [warn] 有帧粘在一起,按宽度对半劈开分帧")
179
+ # 也可能切出了碎片(头发丝断开),把距离最近的合并
180
+ while len(runs) > frames:
181
+ gaps = [runs[i + 1][0] - runs[i][1] for i in range(len(runs) - 1)]
182
+ i = int(np.argmin(gaps))
183
+ runs[i] = [runs[i][0], runs[i + 1][1]]
184
+ runs.pop(i + 1)
185
+ return [(a, b) for a, b in runs]
186
+
187
+
188
+ def bbox_of(alpha: np.ndarray, x0: int, x1: int, thresh: float = 0.35) -> tuple[int, int, int, int]:
189
+ sub = alpha[:, x0:x1] > thresh
190
+ rows = np.where(sub.any(axis=1))[0]
191
+ cols = np.where(sub.any(axis=0))[0]
192
+ return int(x0 + cols[0]), int(rows[0]), int(x0 + cols[-1] + 1), int(rows[-1] + 1)
193
+
194
+
195
+ # ---------------------------------------------------------------- 对齐
196
+
197
+
198
+ def best_shift(ref: np.ndarray, cur: np.ndarray, guess: int, radius: int) -> int:
199
+ """在 guess ± radius 里找让两帧重合度(IoU)最高的水平偏移。
200
+
201
+ guess 必须先把「两张掩膜里角色分别站在什么位置」对齐掉,
202
+ 否则两者根本不相交,IoU 恒为 0,argmax 会一路滑到搜索边界。
203
+ """
204
+ best_dx, best_iou = guess, -1.0
205
+ ref_sum = ref.sum()
206
+ for dx in range(guess - radius, guess + radius + 1):
207
+ shifted = np.roll(cur, dx, axis=1)
208
+ if dx > 0:
209
+ shifted[:, :dx] = 0
210
+ elif dx < 0:
211
+ shifted[:, dx:] = 0
212
+ inter = float((ref * shifted).sum())
213
+ union = ref_sum + float(shifted.sum()) - inter
214
+ iou = inter / union if union > 0 else 0.0
215
+ if iou > best_iou:
216
+ best_iou, best_dx = iou, dx
217
+ return best_dx
218
+
219
+
220
+ def build_sheet(
221
+ rgba: np.ndarray,
222
+ alpha: np.ndarray,
223
+ frames: int,
224
+ target: int,
225
+ pad_x: int,
226
+ pad_y: int,
227
+ debug: bool,
228
+ norm_axis: str = "h",
229
+ align: str = "iou",
230
+ ) -> tuple[Image.Image, list[dict], dict]:
231
+ runs = figure_runs(alpha, frames)
232
+ boxes = [bbox_of(alpha, a, b) for a, b in runs]
233
+ heights = [y1 - y0 for (_x0, y0, _x1, y1) in boxes]
234
+ widths = [x1 - x0 for (x0, _y0, x1, _y1) in boxes]
235
+ med_h = float(np.median(heights))
236
+ med_w = float(np.median(widths))
237
+ # 竖构图(站/跑)按身高归一化;横构图(游泳)身体是横着的,得按身长归一化,
238
+ # 否则会把「躺平的角色」按它那点高度放大,尺寸和立绘对不上。
239
+ med_ref = med_h if norm_axis == "h" else med_w
240
+ scale = target / med_ref
241
+
242
+ info = []
243
+ for i, ((x0, y0, x1, y1), (ra, rb)) in enumerate(zip(boxes, runs)):
244
+ info.append(
245
+ {"i": i, "box": (x0, y0, x1, y1), "run": (ra, rb), "h": y1 - y0, "w": x1 - x0}
246
+ )
247
+ if debug:
248
+ print(
249
+ f" frame{i}: box=({x0},{y0})-({x1},{y1}) {x1 - x0}x{y1 - y0} "
250
+ f"槽位宽={rb - ra} 高差={y1 - y0 - med_h:+.0f}px"
251
+ )
252
+ axis_name = "身高" if norm_axis == "h" else "身长"
253
+ print(f" {axis_name}中位数 {med_ref:.0f}px,统一缩放 ×{scale:.4f} -> {target}px")
254
+
255
+ # 整张图一起缩放:所有帧共用同一个系数,才不会忽大忽小
256
+ new_w = max(1, int(round(rgba.shape[1] * scale)))
257
+ new_h = max(1, int(round(rgba.shape[0] * scale)))
258
+ scaled = resize_premultiplied(rgba, (new_w, new_h))
259
+ sa = scaled[..., 3] / 255.0
260
+
261
+ boxes2 = [bbox_of(sa, int(round(a * scale)), int(round(b * scale))) for a, b in runs]
262
+
263
+ # 纵向:所有帧共用同一条带,站位差异原样保留(该有的起伏不抹掉)
264
+ band_top = max(0, min(b[1] for b in boxes2) - pad_y)
265
+ band_bottom = min(new_h, max(b[3] for b in boxes2) + pad_y)
266
+ band_h = band_bottom - band_top
267
+
268
+ band_masks = []
269
+ for (x0, _y0, x1, _y1) in boxes2:
270
+ m = sa[band_top:band_bottom].copy()
271
+ m[:, : max(0, x0 - 4)] = 0.0
272
+ m[:, x1 + 4 :] = 0.0
273
+ band_masks.append(m)
274
+
275
+ # 横向:以「身高最接近中位数」的那帧为基准,互相关对齐。
276
+ # 只拿上半身参与匹配:腿脚每帧摆幅最大,放进来会把对齐往四肢上带,反而左右滑步。
277
+ # 横躺的角色(游泳)不能用 IoU 对齐:手臂前伸本来就是动画本身,
278
+ # 按轮廓重合去对齐会把「前伸」这个动作抵消掉,所以这类只做包围盒粗对齐。
279
+ focus_h = band_h if align == "center" else max(8, int(0.6 * band_h))
280
+ focus = [m[:focus_h] for m in band_masks]
281
+ ref_idx = int(np.argmin([abs(h - med_ref) for h in (heights if norm_axis == "h" else widths)]))
282
+ ref_cx = (boxes2[ref_idx][0] + boxes2[ref_idx][2]) / 2.0
283
+ radius = max(12, int(0.05 * new_w / frames))
284
+ dxs = []
285
+ for i, m in enumerate(focus):
286
+ cx_i = (boxes2[i][0] + boxes2[i][2]) / 2.0
287
+ if i == ref_idx:
288
+ dx = 0
289
+ else:
290
+ # 先用包围盒中心粗对齐,再在附近找精调量
291
+ guess = int(round(ref_cx - cx_i))
292
+ dx = guess if align == "center" else best_shift(focus[ref_idx], m, guess, radius)
293
+ dxs.append(dx)
294
+ if debug:
295
+ tag = "基准" if i == ref_idx else f"粗对齐 {int(round(ref_cx - cx_i)):+d} 精调 {dx - int(round(ref_cx - cx_i)):+d}"
296
+ print(f" frame{i}: 水平对齐 {dx:+d}px ({tag})")
297
+
298
+ # 对齐后的包围盒(都在基准帧的坐标系里),据此定帧宽和中轴
299
+ left = [boxes2[i][0] + dxs[i] for i in range(frames)]
300
+ right = [boxes2[i][2] + dxs[i] for i in range(frames)]
301
+ half = max(max(abs(left[i] - ref_cx), abs(right[i] - ref_cx)) for i in range(frames))
302
+ frame_w = int(np.ceil(2 * half)) + 2 * pad_x
303
+ center = frame_w / 2.0
304
+
305
+ out = Image.new("RGBA", (frame_w * frames, band_h), (0, 0, 0, 0))
306
+ for i, m in enumerate(band_masks):
307
+ # 只留当前这帧的像素,别的帧先抹掉,免得被一起贴进来
308
+ cut = scaled[band_top:band_bottom].copy()
309
+ cut[..., 3] = np.where(m > 0, cut[..., 3], 0)
310
+ piece = Image.fromarray(cut.astype(np.uint8), "RGBA")
311
+ # 贴图只是平移,不改变内容自身的坐标。
312
+ # dxs[i] 表示「把这帧移到基准帧位置上需要平移多少」,于是这帧内部
313
+ # 真正对应基准帧中心的那个点位于 ref_cx - dxs[i],把它放到帧中心即可。
314
+ paste_x = int(round(center - ref_cx + dxs[i]))
315
+ out.paste(piece, (i * frame_w + paste_x, 0), piece)
316
+
317
+ return out, info, {
318
+ "frameW": frame_w,
319
+ "frameH": band_h,
320
+ "refPx": int(round(med_ref * scale)),
321
+ "axis": norm_axis,
322
+ }
323
+
324
+
325
+ # ---------------------------------------------------------------- main
326
+
327
+
328
+ def main() -> None:
329
+ ap = argparse.ArgumentParser(description="生图序列图 -> 对齐的桌宠精灵图")
330
+ ap.add_argument("src")
331
+ ap.add_argument("dst")
332
+ ap.add_argument("--frames", type=int, default=4, help="序列图里有几帧")
333
+ ap.add_argument(
334
+ "--size", "--height", dest="size", type=int, default=620,
335
+ help="输出里角色的特征尺寸(px),默认按身高算",
336
+ )
337
+ ap.add_argument(
338
+ "--norm-axis", choices=("h", "w"), default="h",
339
+ help="按哪个轴归一化:h=身高(站立/跑步),w=身长(游泳这类横躺的动作)",
340
+ )
341
+ ap.add_argument(
342
+ "--align", choices=("iou", "center"), default="iou",
343
+ help="帧间水平对齐:iou=轮廓互相关(站立类),center=包围盒居中(横躺类,"
344
+ "否则会把手臂前伸这个动作本身抵消掉)",
345
+ )
346
+ ap.add_argument("--key", default=None, help="写进 sprites.json 的键名,默认从输出文件名推导")
347
+ ap.add_argument("--manifest", default=None,
348
+ help="精灵图元信息清单(帧数/帧尺寸/角色参考尺寸),默认写到输出同目录的 sprites.json")
349
+ ap.add_argument("--no-manifest", action="store_true", help="不写清单")
350
+ ap.add_argument("--bg", default=None, help="手动指定背景色 RRGGBB,默认自动识别")
351
+ ap.add_argument("--lo", type=float, default=34.0, help="颜色距离低于此值算背景")
352
+ ap.add_argument("--hi", type=float, default=92.0, help="高于此值算完全前景(中间做羽化)")
353
+ ap.add_argument("--pad-x", type=int, default=10)
354
+ ap.add_argument("--pad-y", type=int, default=8)
355
+ ap.add_argument("--debug", action="store_true")
356
+ ap.add_argument("--dump-frames", action="store_true", help="额外把每一帧单独存出来方便肉眼查")
357
+ args = ap.parse_args()
358
+
359
+ print(f"[1/3] 抠背景 {os.path.basename(args.src)}")
360
+ rgba, alpha = to_rgba(args.src, args.bg, args.lo, args.hi)
361
+ frac = float((alpha < 0.5).mean())
362
+ if frac > 0.85:
363
+ print(f" [warn] 抠掉了 {frac * 100:.0f}% 的像素,背景可能和角色颜色太接近,"
364
+ f"调小 --lo 或换一张源图")
365
+
366
+ print("[2/3] 切帧 + 对齐")
367
+ sheet, info, meta = build_sheet(
368
+ rgba, alpha, args.frames, args.size, args.pad_x, args.pad_y, args.debug,
369
+ norm_axis=args.norm_axis, align=args.align,
370
+ )
371
+
372
+ print("[3/3] 输出")
373
+ os.makedirs(os.path.dirname(os.path.abspath(args.dst)), exist_ok=True)
374
+ sheet.save(args.dst)
375
+ fw = sheet.width // args.frames
376
+ print(f" {args.dst} {sheet.width}x{sheet.height} 单帧 {fw}x{sheet.height}")
377
+
378
+ if not args.no_manifest:
379
+ key = args.key or os.path.splitext(os.path.basename(args.dst))[0]
380
+ if key.startswith("pet-"):
381
+ key = key[4:]
382
+ mpath = args.manifest or os.path.join(os.path.dirname(os.path.abspath(args.dst)), "sprites.json")
383
+ entry = {
384
+ "file": os.path.basename(args.dst),
385
+ "frames": args.frames,
386
+ "frameW": fw,
387
+ "frameH": sheet.height,
388
+ "refPx": meta["refPx"],
389
+ "axis": meta["axis"],
390
+ "padX": args.pad_x,
391
+ "padY": args.pad_y,
392
+ }
393
+ data = {}
394
+ if os.path.exists(mpath):
395
+ try:
396
+ with open(mpath, encoding="utf-8") as fh:
397
+ data = json.load(fh)
398
+ except Exception:
399
+ data = {}
400
+ data[key] = entry
401
+ with open(mpath, "w", encoding="utf-8") as fh:
402
+ json.dump(data, fh, indent=2, ensure_ascii=False)
403
+ print(f" 清单 {mpath} [{key}] frames={entry['frames']} "
404
+ f"帧={fw}x{sheet.height} ref={entry['refPx']}px axis={entry['axis']}")
405
+
406
+ if args.dump_frames:
407
+ root, ext = os.path.splitext(args.dst)
408
+ for i in range(args.frames):
409
+ sheet.crop((i * fw, 0, (i + 1) * fw, sheet.height)).save(f"{root}-f{i}{ext}")
410
+ # 拼一张放大预览图,方便一眼看对齐效果
411
+ preview = Image.new("RGBA", (fw, sheet.height * args.frames), (240, 240, 245, 255))
412
+ for i in range(args.frames):
413
+ preview.alpha_composite(
414
+ sheet.crop((i * fw, 0, (i + 1) * fw, sheet.height)).convert("RGBA"), (0, i * sheet.height)
415
+ )
416
+ ImageDraw.Draw(preview).line(
417
+ [(0, i * sheet.height), (fw, i * sheet.height)], fill=(255, 120, 170, 255), width=2
418
+ )
419
+ preview_path = f"{root}-preview{ext}"
420
+ preview.save(preview_path)
421
+ print(f" 预览(竖排每帧,方便看有没有左右滑步): {preview_path}")
422
+
423
+
424
+ if __name__ == "__main__":
425
+ sys.exit(main())