Files
gplan-2/tools/pixelize_candidates.py
T

184 lines
7.3 KiB
Python

#!/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")