tools: 像素化候选管线落地——pixelize_candidates.py(去底洪泛+灰/暗紫/暖灰影三规则吞影+BOX4x预滤/48色量化/NEAREST到1x+四联底测试条+总览拼版);敌种三档48/64/96候选值终值归样张批;教训=hook_horror身体与浅影同色系,色相法必误吞,其影留手修
This commit is contained in:
1 parent
5f01911514
commit
aa703b21a9
1 file changed
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""远程生图候选 → 像素化候选稿(v1.3 五步流程第 4 步:去底转透明+最近邻降采样+色板量化+四联底测试条)。
|
||||
|
||||
用法: 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
|
||||
STRIP_BGS = [("brick", (201, 175, 131)), ("floor", (179, 154, 120)),
|
||||
("bright", (232, 224, 208)), ("dark", (58, 58, 68))]
|
||||
|
||||
def hsv(r, g, b):
|
||||
mx, mn = max(r, g, b), min(r, g, b)
|
||||
d = mx - mn
|
||||
h = 0
|
||||
if d:
|
||||
if mx == r: h = (60 * (g - b) / d) % 360
|
||||
elif mx == g: h = 60 * (b - r) / d + 120
|
||||
else: h = 60 * (r - g) / d + 240
|
||||
return h, (d / mx if mx else 0), mx / 255
|
||||
|
||||
def is_shadow(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
|
||||
|
||||
def remove_bg(im):
|
||||
"""角点取底色洪泛去底,再从透明边界吞灰影/暗紫影。返回 RGBA。"""
|
||||
im = im.convert("RGB")
|
||||
w, hgt = im.size
|
||||
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]
|
||||
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))
|
||||
for y in range(hgt):
|
||||
for x in (0, w - 1):
|
||||
q.append((x, 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:
|
||||
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))
|
||||
# 第二遍:从透明边界吞影(奶油描边色相/饱和均不符,进不去);只在底部区域
|
||||
q = deque()
|
||||
for y in range(hgt):
|
||||
for x in range(w):
|
||||
if alpha[y][x] == 0 and y >= hgt * SHADOW_Y_MIN:
|
||||
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
|
||||
if is_shadow(px[nx, ny]):
|
||||
alpha[ny][nx] = 0
|
||||
q.append((nx, ny))
|
||||
out = im.convert("RGBA")
|
||||
pa = out.load()
|
||||
for y in range(hgt):
|
||||
for x in range(w):
|
||||
if alpha[y][x] == 0:
|
||||
pa[x, y] = (0, 0, 0, 0)
|
||||
return out
|
||||
|
||||
def crop_content(im, pad=2):
|
||||
bbox = im.getchannel("A").getbbox()
|
||||
if not bbox:
|
||||
return im
|
||||
l, t, r, b = bbox
|
||||
l, t = max(0, l - pad), max(0, t - pad)
|
||||
r, b = min(im.width, r + pad), min(im.height, b + pad)
|
||||
return im.crop((l, t, r, b))
|
||||
|
||||
def pixelize(im, target):
|
||||
"""BOX 预滤到 4x → 量化 → NEAREST 到 1x(最长边=档位值)。"""
|
||||
src = crop_content(im)
|
||||
scale = target * 4 / max(src.size)
|
||||
pre = src.resize((max(1, round(src.width * scale)), max(1, round(src.height * scale))), Image.BOX)
|
||||
rgb = pre.convert("RGB")
|
||||
flat = rgb.quantize(QUANT_COLORS, method=Image.MEDIANCUT).convert("RGB")
|
||||
out = flat.resize((max(1, round(pre.width / 4)), max(1, round(pre.height / 4))), Image.NEAREST)
|
||||
a = pre.getchannel("A").resize(out.size, Image.NEAREST).point(lambda v: 255 if v > 128 else 0)
|
||||
out.putalpha(a)
|
||||
return out
|
||||
|
||||
def strip(spr, path):
|
||||
cell = max(spr.size) + 8
|
||||
sheet = Image.new("RGBA", (cell * 4, cell), (0, 0, 0, 255))
|
||||
for i, (_, bg) in enumerate(STRIP_BGS):
|
||||
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.save(path)
|
||||
|
||||
def main(root):
|
||||
thumbs = []
|
||||
for asset, target in sorted(SIZES.items()):
|
||||
d = os.path.join(root, asset)
|
||||
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:
|
||||
continue
|
||||
n = f[1].split(".")[0] if f[1].isdigit() else f
|
||||
src = Image.open(os.path.join(d, f))
|
||||
rgba = remove_bg(src)
|
||||
spr = pixelize(rgba, target)
|
||||
outp = os.path.join(d, "pixel-%s.png" % n)
|
||||
spr.save(outp)
|
||||
strip(spr, os.path.join(d, "pixel-%s-strip.png" % n))
|
||||
thumbs.append((asset, target, spr))
|
||||
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
|
||||
row.append((asset, t)); rw += t.width + pad
|
||||
if row: rows.append(row)
|
||||
W = max(sum(t.width + pad for _, t in r) for r in rows)
|
||||
Htot = sum(max(t.height for _, t in r) + pad + 4 for r in rows)
|
||||
sheet = Image.new("RGBA", (W, Htot), (179, 154, 120, 255))
|
||||
y = 0
|
||||
for r in rows:
|
||||
x = 0
|
||||
hh = max(t.height for _, t in r)
|
||||
for asset, t in r:
|
||||
sheet.alpha_composite(t, (x, y + (hh - t.height) // 2))
|
||||
x += t.width + pad
|
||||
y += hh + pad + 4
|
||||
sheet = sheet.resize((sheet.width * 2, sheet.height * 2), Image.NEAREST)
|
||||
sheet.save(os.path.join(root, "0-像素化预览.png"))
|
||||
print("总览 -> 0-像素化预览.png")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1] if len(sys.argv) > 1 else "design/art/candidates/remote-gen-2026-09-26")
|
||||
Reference in new issue
Block a user