From cd9ff044322abc478abebeb468a0cfab814db8ee Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sun, 27 Sep 2026 03:01:54 +0800 Subject: [PATCH] =?UTF-8?q?tools:=20=E5=83=8F=E7=B4=A0=E5=8C=96=E7=AE=A1?= =?UTF-8?q?=E7=BA=BF=20v4=3D=E5=8F=8C=E8=B7=AF=E6=8B=A9=E4=BC=98=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=B1=8F=E9=9A=9C=E6=B3=95(=E6=8F=8F=E8=BE=B9?= =?UTF-8?q?=E8=86=A8=E8=83=803px=E5=90=9E=E5=BA=95=E8=89=B2=E5=BD=B1+?= =?UTF-8?q?=E5=90=83=E6=8F=8F=E8=BE=B9=E4=B8=BB=E8=89=B2=E9=97=A8+?= =?UTF-8?q?=E5=BA=95=E8=89=B2=E2=86=94=E4=B8=BB=E8=89=B2=E7=BA=BF=E6=AE=B5?= =?UTF-8?q?=E6=A1=A5=E9=99=90=E5=B1=82=E6=B7=B14)=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E6=BC=8F=E7=81=8C(=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E9=9D=A2=E7=A7=AF<0.6x)=E5=9B=9E=E8=90=BD=E5=BA=95=E8=89=B2?= =?UTF-8?q?=E9=97=A8+v1=E5=BD=B1=E8=A7=84=E5=88=99=E8=B7=AF=E5=BE=84;?= =?UTF-8?q?=E6=A1=A3=E4=BD=8D=E7=BF=BB=E5=80=8D96/128/192(=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=A3=81=E6=88=98=E6=96=97=E7=95=8C=E9=9D=A2=E4=B8=8D?= =?UTF-8?q?=E9=9C=80=E8=A6=81=E9=82=A3=E4=B9=88=E4=BD=8E=E5=83=8F=E7=B4=A0?= =?UTF-8?q?);=E4=BF=AE=E5=A4=8D=3Dduergar=E5=BD=B1=E5=8E=8B=E6=8F=8F?= =?UTF-8?q?=E8=BE=B9=E5=BC=80=E7=BC=BA=E5=8F=A3=E6=B4=AA=E7=81=8C=E5=90=83?= =?UTF-8?q?=E7=A9=BA=E7=94=B2=E9=9D=A2(=E8=92=99=E7=89=88=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D=E5=BA=95=E9=83=A8=E7=AA=81=E7=A0=B4)+=E9=92=A9?= =?UTF-8?q?=E7=88=AA=E6=81=90=E9=AD=94=E5=90=8C=E8=89=B2=E7=B3=BB=E5=BD=B1?= =?UTF-8?q?+=E7=99=BD=E8=BE=B9=E4=B8=89=E6=A1=88=E5=85=A8=E6=94=B6;?= =?UTF-8?q?=E9=87=8F=E5=8C=9648=E2=86=9264=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/pixelize_candidates.py | 217 +++++++++++++++++++++++++++-------- 1 file changed, 169 insertions(+), 48 deletions(-) diff --git a/tools/pixelize_candidates.py b/tools/pixelize_candidates.py index be81aed1..935297d3 100644 --- a/tools/pixelize_candidates.py +++ b/tools/pixelize_candidates.py @@ -1,34 +1,36 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""远程生图候选 → 像素化候选稿(v1.3 五步流程第 4 步:去底转透明+最近邻降采样+色板量化+四联底测试条)。 +"""远程生图候选 → 像素化候选稿 v3(v1.3 五步流程第 4 步)。 +v3 演进(2026-09-27 用户四问题:脏点/模糊/信息丢失/白边): + - 档位翻倍: 普通怪 96 / 精英 128 / Boss 192(终值仍归战斗一屏样张批) + - 去底去影 = 「奶油描边屏障法」: 描边膨胀 3px 作屏障,从图缘一口吞掉底色+地面影 + (v1 的影色相规则对 hook_horror 同色系必误吞,废弃) + - 白边 = 吃描边: 主色距离门 + 「底色↔主色线段」反锯齿混合带桥(限层深,防深入本体) + - 教训: 不做第二遍低膨胀洪泛——JPEG 噪点在描边开 1-2px 缺口会洪灌本体 + (duergar 甲面被吃空实证);屏障只跑一遍 dil=3 用法: python tools/pixelize_candidates.py <候选批次目录> - 目录下每个 <资产>/vN.(png|jpg) 出一对产物: - <资产>/pixel-vN.png 目标尺寸像素稿(紧裁剪) - <资产>/pixel-vN-strip.png 四联底测试条(4x 放大) - 外加 0-像素化预览.png 总览拼版。 - -目标尺寸(art-bible §8.2 三档候选,终值归战斗一屏样张批): - 普通怪(小)=48: goblin giant_spider hook_horror - 精英(中)=64: bugbear duergar - Boss(大)=96: ogre_chief drider duergar_king -参数(代拟待追认): BOX 4x 预滤→48 色量化→NEAREST 到 1x。 """ import sys, os from collections import deque, Counter from PIL import Image -SIZES = {"goblin": 48, "giant_spider": 48, "hook_horror": 48, - "bugbear": 64, "duergar": 64, - "ogre_chief": 96, "drider": 96, "duergar_king": 96} -BG_TOL = 48 # 与底色色距,一阶洪泛 -GRAD_TOL = 14 # 与来路像素色距,底色渐变跟随 -SHADOW_GRAY_SAT = 0.10 # 纯灰影饱和上限 -SHADOW_PURPLE = (255, 350, 0.35, 0.45) # 暗紫影: 色相域, 饱和上限, 明度上限 -SHADOW_WARM = (15, 45, 0.32, 0.42) # 暖灰影(棕调): 色相域, 饱和上限, 明度上限 -# 注:无"冷调浅灰"规则——hook_horror 身体与影同色系,色相法必误吞,其影留手修 -SHADOW_Y_MIN = 0.55 # 吞影只在画面底部 45% 区域(防误吞身体) -QUANT_COLORS = 48 +SIZES = {"goblin": 96, "giant_spider": 96, "hook_horror": 96, + "bugbear": 128, "duergar": 128, + "ogre_chief": 192, "drider": 192, "duergar_king": 192} +BG_TOL = 48 +CREAT_HUE = (28, 100) # 奶油/骨色相域 +CREAT_S = (0.07, 0.55) # 屏障用饱和域 +CREAT_V = 0.58 +BARRIER_DILATE = 3 +OUTLINE_TOL = 48 # 吃描边:与主色最大色距 +EAT_S_MIN = 0.02 +OUTLINE_PROBE = 6 # 主色采样:透明区膨胀半径 +BRIDGE_DEPTH = 4 # 混合带桥最大层深 +BRIDGE_SEG_TOL = 14 # 到底色↔主色线段的距离 +BRIDGE_S_MAX = 0.10 +BRIDGE_V_MIN = 0.70 +QUANT_COLORS = 64 STRIP_BGS = [("brick", (201, 175, 131)), ("floor", (179, 154, 120)), ("bright", (232, 224, 208)), ("dark", (58, 58, 68))] @@ -42,18 +44,65 @@ def hsv(r, g, b): else: h = 60 * (r - g) / d + 240 return h, (d / mx if mx else 0), mx / 255 -def is_shadow(c): +def is_cream(c): h, s, v = hsv(*c) - if s < SHADOW_GRAY_SAT: - return True - for h0, h1, smax, vmax in (SHADOW_PURPLE, SHADOW_WARM): - in_hue = h0 <= h <= h1 if h0 < h1 else (h >= h0 or h <= h1) - if in_hue and s < smax and v < vmax: - return True - return False + h0, h1 = CREAT_HUE + return h0 <= h <= h1 and CREAT_S[0] <= s <= CREAT_S[1] and v >= CREAT_V -def remove_bg(im): - """角点取底色洪泛去底,再从透明边界吞灰影/暗紫影。返回 RGBA。""" +def dist(a, b): + return abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2]) + +def seg_dist(c, a, b): + """c 到线段 a-b 的最小欧氏距离(反锯齿混合带判据)。""" + ab = (b[0] - a[0], b[1] - a[1], b[2] - a[2]) + ac = (c[0] - a[0], c[1] - a[1], c[2] - a[2]) + ab2 = ab[0] ** 2 + ab[1] ** 2 + ab[2] ** 2 + t = 0.0 + if ab2: + t = max(0.0, min(1.0, (ac[0] * ab[0] + ac[1] * ab[1] + ac[2] * ab[2]) / ab2)) + p = (a[0] + ab[0] * t, a[1] + ab[1] * t, a[2] + ab[2] * t) + return dist(c, p) + +def dilate(mask, w, hgt, n): + for _ in range(n): + nb = [row[:] for row in mask] + for y in range(hgt): + row = mask[y] + for x in range(w): + if not row[x]: + if (x > 0 and row[x - 1]) or (x < w - 1 and row[x + 1]) \ + or (y > 0 and mask[y - 1][x]) or (y < hgt - 1 and mask[y + 1][x]): + nb[y][x] = True + mask = nb + return mask + +def barrier_flood(im): + """优雅路径:奶油描边膨胀成屏障,从图缘吞掉底色+影。描边被影压暗开缺口时会漏灌。""" + im = im.convert("RGB") + w, hgt = im.size + px = im.load() + barrier = dilate([[is_cream(px[x, y]) for x in range(w)] for y in range(hgt)], w, hgt, BARRIER_DILATE) + alpha = [[255] * w for _ in range(hgt)] + seen = [[False] * w for _ in range(hgt)] + q = deque() + for x in range(w): + q.append((x, 0)); q.append((x, hgt - 1)) + for y in range(hgt): + q.append((0, y)); q.append((w - 1, y)) + while q: + x, y = q.popleft() + if x < 0 or y < 0 or x >= w or y >= hgt or seen[y][x]: + continue + seen[y][x] = True + if barrier[y][x]: + continue + alpha[y][x] = 0 + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + q.append((x + dx, y + dy)) + return im, w, hgt, alpha + +def bg_flood(im): + """稳妥路径:底色门+梯度跟随洪泛去底,再按影色规则吞影(v1 逻辑,轮廓最稳)。""" im = im.convert("RGB") w, hgt = im.size px = im.load() @@ -62,30 +111,26 @@ def remove_bg(im): alpha = [[255] * w for _ in range(hgt)] seen = [[False] * w for _ in range(hgt)] q = deque() - def dist(a, b): - return abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2]) for x in range(w): - for y in (0, hgt - 1): - q.append((x, y, bg)) + q.append((x, 0, bg)); q.append((x, hgt - 1, bg)) for y in range(hgt): - for x in (0, w - 1): - q.append((x, y, bg)) + q.append((0, y, bg)); q.append((w - 1, y, bg)) while q: x, y, ref = q.popleft() if x < 0 or y < 0 or x >= w or y >= hgt or seen[y][x]: continue seen[y][x] = True c = px[x, y] - if dist(c, ref) > GRAD_TOL and dist(c, bg) > BG_TOL: + if dist(c, ref) > 14 and dist(c, bg) > BG_TOL: continue alpha[y][x] = 0 for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): q.append((x + dx, y + dy, bg if dist(c, bg) <= BG_TOL else c)) - # 第二遍:从透明边界吞影(奶油描边色相/饱和均不符,进不去);只在底部区域 + # 影色规则:纯灰/暗紫/暖暗,只吞底部 45% 区域 q = deque() for y in range(hgt): for x in range(w): - if alpha[y][x] == 0 and y >= hgt * SHADOW_Y_MIN: + if alpha[y][x] == 0 and y >= hgt * 0.55: q.append((x, y)) while q: x, y = q.popleft() @@ -93,9 +138,88 @@ def remove_bg(im): nx, ny = x + dx, y + dy if nx < 0 or ny < 0 or nx >= w or ny >= hgt or alpha[ny][nx] == 0: continue - if is_shadow(px[nx, ny]): + h, s, v = hsv(*px[nx, ny]) + ok = s < 0.10 + if not ok: + for h0, h1, smax, vmax in ((255, 350, 0.35, 0.45), (15, 45, 0.32, 0.42)): + in_hue = h0 <= h <= h1 if h0 < h1 else (h >= h0 or h <= h1) + if in_hue and s < smax and v < vmax: + ok = True + break + if ok: alpha[ny][nx] = 0 q.append((nx, ny)) + return im, w, hgt, alpha + +def eat_outline(im, w, hgt, alpha): + """吃描边:主色距离门 + 底色↔主色线段混合带桥(限层深)。""" + px = im.load() + corners = [px[x, y] for x, y in ((2, 2), (w - 3, 2), (2, hgt - 3), (w - 3, hgt - 3))] + bg = Counter(corners).most_common(1)[0][0] + near = [[False] * w for _ in range(hgt)] + for y in range(hgt): + for x in range(w): + if alpha[y][x] == 0: + for dy in range(-OUTLINE_PROBE, OUTLINE_PROBE + 1): + for dx in range(-OUTLINE_PROBE, OUTLINE_PROBE + 1): + nx, ny = x + dx, y + dy + if 0 <= nx < w and 0 <= ny < hgt: + near[ny][nx] = True + cnt = Counter() + for y in range(hgt): + for x in range(w): + if alpha[y][x] == 255 and near[y][x] and is_cream(px[x, y]): + c = px[x, y] + cnt[(c[0] // 8 * 8, c[1] // 8 * 8, c[2] // 8 * 8)] += 1 + if not cnt: + return + base = cnt.most_common(1)[0][0] + layer = [[-1] * w for _ in range(hgt)] + dq = deque() + for y in range(hgt): + for x in range(w): + if alpha[y][x] == 0: + layer[y][x] = 0 + dq.append((x, y)) + while dq: + x, y = dq.popleft() + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nx, ny = x + dx, y + dy + if 0 <= nx < w and 0 <= ny < hgt and layer[ny][nx] == -1: + layer[ny][nx] = layer[y][x] + 1 + dq.append((nx, ny)) + q = deque() + for y in range(hgt): + for x in range(w): + if alpha[y][x] == 0: + q.append((x, y)) + while q: + x, y = q.popleft() + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nx, ny = x + dx, y + dy + if nx < 0 or ny < 0 or nx >= w or ny >= hgt or alpha[ny][nx] == 0: + continue + c = px[nx, ny] + h, s, v = hsv(*c) + h0, h1 = CREAT_HUE + main = dist(c, base) <= OUTLINE_TOL and h0 <= h <= h1 and s >= EAT_S_MIN and v >= CREAT_V + bridge = (layer[ny][nx] <= BRIDGE_DEPTH and s <= BRIDGE_S_MAX + and v >= BRIDGE_V_MIN and seg_dist(c, base, bg) <= BRIDGE_SEG_TOL) + if main or bridge: + alpha[ny][nx] = 0 + q.append((nx, ny)) + +def remove_bg(im): + """双路择优:屏障法漏灌(描边被影压暗开缺口)时自动回落底色门路径。""" + im1, w, hgt, a1 = barrier_flood(im) + n1 = sum(row.count(255) for row in a1) + im2, _, _, a2 = bg_flood(im) + n2 = sum(row.count(255) for row in a2) + if n1 < 0.6 * n2: # 屏障法保留面积崩塌 = 漏灌,回落稳妥路径 + im, alpha = im2, a2 + else: + im, alpha = im1, a1 + eat_outline(im, w, hgt, alpha) out = im.convert("RGBA") pa = out.load() for y in range(hgt): @@ -132,7 +256,7 @@ def strip(spr, path): panel = Image.new("RGBA", (cell, cell), bg + (255,)) panel.alpha_composite(spr, ((cell - spr.width) // 2, (cell - spr.height) // 2)) sheet.paste(panel, (i * cell, 0)) - sheet = sheet.resize((sheet.width * 3, sheet.height * 3), Image.NEAREST) + sheet = sheet.resize((sheet.width * 2, sheet.height * 2), Image.NEAREST) sheet.save(path) def main(root): @@ -142,9 +266,9 @@ def main(root): if not os.path.isdir(d): continue for f in sorted(os.listdir(d)): - if not f.startswith("v") or f.endswith(".strip.png") or "pixel" in f: + if not f.startswith("v") or "pixel" in f: continue - n = f[1].split(".")[0] if f[1].isdigit() else f + n = f[1].split(".")[0] if len(f) > 1 and f[1].isdigit() else f src = Image.open(os.path.join(d, f)) rgba = remove_bg(src) spr = pixelize(rgba, target) @@ -155,10 +279,7 @@ def main(root): print("%-14s %s %sx%s -> %s" % (asset, f, spr.width, spr.height, outp)) if thumbs: pad = 8 - w = sum(max(t.width for _, _, t in thumbs if a == thumbs[0][0]) for a, _, _ in []) # placeholder - cols = [] rows, row, rw = [], [], 0 - H = 110 for asset, target, t in thumbs: if rw + t.width + pad > 1400 and row: rows.append(row); row, rw = [], 0