tools: 像素化管线 v4=双路择优——屏障法(描边膨胀3px吞底色影+吃描边主色门+底色↔主色线段桥限层深4)自动检测漏灌(保留面积<0.6x)回落底色门+v1影规则路径;档位翻倍96/128/192(用户裁战斗界面不需要那么低像素);修复=duergar影压描边开缺口洪灌吃空甲面(蒙版定位底部突破)+钩爪恐魔同色系影+白边三案全收;量化48→64色
This commit is contained in:
1 parent
153976f2ca
commit
cd9ff04432
1 file changed
+169
-48
+169
-48
@@ -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
|
||||
|
||||
Reference in new issue
Block a user