567 lines
24 KiB
Python
567 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""远程生图候选 → 像素化候选稿 v3(v1.3 五步流程第 4 步)。
|
|
|
|
v3 设计要点(对应四类问题:脏点/模糊/信息丢失/白边):
|
|
- 档位翻倍: 普通怪 96 / 精英 128 / Boss 192(终值仍归战斗一屏样张)
|
|
- 去底去影 = 「奶油描边屏障法」: 描边膨胀 3px 作屏障,从图缘一口吞掉底色+地面影
|
|
(影色相规则对同色系必误吞,禁用)
|
|
- 白边 = 吃描边: 主色距离门 + 「底色↔主色线段」反锯齿混合带桥(限层深,防深入本体)
|
|
- 教训: 不做第二遍低膨胀洪泛——JPEG 噪点在描边开 1-2px 缺口会洪灌本体
|
|
(duergar 甲面被吃空实证);屏障只跑一遍 dil=3
|
|
用法:
|
|
python tools/pixelize_candidates.py <候选批次目录> # v3 批量像素化(现行行为)
|
|
python tools/pixelize_candidates.py <目录> --fixer # +rust fixer 收网格+封闭灰袋清除
|
|
python tools/pixelize_candidates.py <目录> --standee48 [--standee-out <game/assets/art/character>]
|
|
# 九张 48px 站桩槽位档(八敌 pixel-1+主角 normal;
|
|
# 底中锚/NEAREST/统一比例保体量/画布高恒 48)
|
|
--fixer: rust pixelfixer(Retro-Diffusion,
|
|
MIT, D:/code/pixel-art-fixer)对原图先做网格检测+重建(收网格),v3 去底后再跑封闭灰袋清除
|
|
(三闸=近底色∧中性灰∧组件不触图缘——防误伤盔甲与影);--fixer-exe 可换引擎路径。
|
|
"""
|
|
import sys, os, subprocess, tempfile
|
|
from collections import deque, Counter
|
|
from PIL import Image, ImageFilter
|
|
|
|
FIXER_EXE_DEFAULT = r"D:\code\pixel-art-fixer\rust\target\release\pixelfixer.exe"
|
|
FIXER_EXE = FIXER_EXE_DEFAULT # --fixer-exe 可覆写(__main__ 解析)
|
|
STANDEE_SLOT_H = 48 # 主角站桩源档高(×2 整数放大=盒高 96——圣经 §8.2)
|
|
STANDEE_SLOT_W_MAX = 75 # 盒宽 150 的一半(超宽才按宽收,现八敌+主角均不触)
|
|
|
|
# 八敌站桩源档高 = 生物体型,不是强度档位
|
|
# 口径:体量按「这只生物本来多大」定;强度改由别的通道承载(Boss 的紫赤色相独占、
|
|
# 精英的垂直延伸、HUD 意图徽标)。因此下表与 §5.5 的普通/精英/Boss 档位**不对齐**——
|
|
# 例如钩爪恐魔是普通怪却比精英灰矮人大,灰矮人是精英却比普通/精英熊地精小。
|
|
# 数值经实测校准(视觉体量 = 不透明像素面积,不看高度——蜘蛛宽扁,按高度排会失真):
|
|
# 地精 828 < 灰矮人 1014 < 巨蜘蛛 1390 < 钩爪恐魔 1538
|
|
# < 熊地精 2145 < 食人魔酋长 2886 < 蛛化卓尔精灵 3513 < 灰矮人石君王 4385
|
|
STANDEE_TIER_H = {
|
|
"goblin": 36, "giant_spider": 38,
|
|
"duergar": 46, "hook_horror": 58,
|
|
"bugbear": 62, "ogre_chief": 74,
|
|
"drider": 90, "duergar_king": 94,
|
|
}
|
|
|
|
# 中间产物(pixel-1)的生成分辨率——只决定中间图有多细,**不决定最终体量**。
|
|
# 最终体量归 STANDEE_TIER_H。这里取得比最终档高越大,后续降采样越干净。
|
|
# 注意:ogre_chief 取 192 是分辨率选择(要给它更高的中间精度),不是把它归为 Boss 档。
|
|
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))]
|
|
|
|
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_cream(c):
|
|
h, s, v = hsv(*c)
|
|
h0, h1 = CREAT_HUE
|
|
return h0 <= h <= h1 and CREAT_S[0] <= s <= CREAT_S[1] and v >= CREAT_V
|
|
|
|
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()
|
|
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()
|
|
for x in range(w):
|
|
q.append((x, 0, bg)); q.append((x, hgt - 1, bg))
|
|
for y in range(hgt):
|
|
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) > 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 * 0.55:
|
|
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
|
|
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):
|
|
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 despeckle(spr, min_px=10):
|
|
"""删小于 min_px 的不透明孤立连通域(噪点)。"""
|
|
w, hgt = spr.size
|
|
pa = spr.load()
|
|
seen = [[False] * w for _ in range(hgt)]
|
|
for y0 in range(hgt):
|
|
for x0 in range(w):
|
|
if seen[y0][x0] or pa[x0, y0][3] == 0:
|
|
continue
|
|
comp = []
|
|
q = deque([(x0, y0)])
|
|
seen[y0][x0] = True
|
|
while q:
|
|
x, y = q.popleft()
|
|
comp.append((x, y))
|
|
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 not seen[ny][nx] and pa[nx, ny][3] != 0:
|
|
seen[ny][nx] = True
|
|
q.append((nx, ny))
|
|
if len(comp) < min_px:
|
|
for x, y in comp:
|
|
pa[x, y] = (0, 0, 0, 0)
|
|
return spr
|
|
|
|
def edge_inpaint(spr, passes=2):
|
|
"""边缘色内插:与透明相邻的不透明像素改色为其内部邻居均值(灭白边,不削轮廓)。"""
|
|
w, hgt = spr.size
|
|
for _ in range(passes):
|
|
pa = spr.load()
|
|
fixes = []
|
|
for y in range(hgt):
|
|
for x in range(w):
|
|
c = pa[x, y]
|
|
if c[3] == 0:
|
|
continue
|
|
inner, boundary = [], False
|
|
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)):
|
|
nx, ny = x + dx, y + dy
|
|
if not (0 <= nx < w and 0 <= ny < hgt):
|
|
boundary = True
|
|
continue
|
|
nc = pa[nx, ny]
|
|
if nc[3] == 0:
|
|
boundary = True
|
|
elif all(pa[nx + ddx, ny + ddy][3] != 0
|
|
for ddx, ddy in ((1, 0), (-1, 0), (0, 1), (0, -1))
|
|
if 0 <= nx + ddx < w and 0 <= ny + ddy < hgt):
|
|
inner.append(nc)
|
|
if boundary and inner:
|
|
fixes.append((x, y, tuple(sum(ch[i] for ch in inner) // len(inner) for i in range(3))))
|
|
for x, y, c in fixes:
|
|
r, g, b, a = pa[x, y]
|
|
pa[x, y] = (c[0], c[1], c[2], a)
|
|
return spr
|
|
|
|
def pixelize(im, target):
|
|
"""BOX 预滤到 4x(α腐蚀2px灭混合带) → 量化 → NEAREST → 降噪点 → 边缘内插。"""
|
|
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").filter(ImageFilter.MinFilter(3)).resize(out.size, Image.NEAREST).point(lambda v: 255 if v > 128 else 0)
|
|
out.putalpha(a)
|
|
despeckle(out)
|
|
edge_inpaint(out)
|
|
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 * 2, sheet.height * 2), Image.NEAREST)
|
|
sheet.save(path)
|
|
|
|
def remove_closed_gray_bags(im):
|
|
"""封闭灰袋清除:本体轮廓内困住的底色/灰袋,图缘洪泛够不着。
|
|
|
|
三闸全过才除(防误伤盔甲与影): ①近底色(四角底色距离门) ②中性灰(低饱和)
|
|
③连通组件不触图缘(封闭——贴缘的地面影属开放区,洪泛已处理)。
|
|
"""
|
|
im = im.convert("RGBA")
|
|
w, hgt = im.size
|
|
px = im.load()
|
|
corners = [px[x, y][:3] for x, y in ((1, 1), (w - 2, 1), (1, hgt - 2), (w - 2, hgt - 2))
|
|
if px[x, y][3] > 0]
|
|
if not corners:
|
|
return im
|
|
bg = Counter(tuple(c // 16 * 16 for c in cr) for cr in corners).most_common(1)[0][0]
|
|
|
|
def is_bag(c):
|
|
r, g, b, a = c
|
|
if a == 0:
|
|
return False
|
|
if dist((r, g, b), bg) > BG_TOL: # 闸① 近底色
|
|
return False
|
|
h, s, v = hsv(r, g, b) # 闸② 中性灰
|
|
return s < 0.10
|
|
# 闸③ 组件级:候选像素连通域任一成员触图缘 = 开放区,整域保留
|
|
comp_id = [[-1] * w for _ in range(hgt)]
|
|
touches_edge = []
|
|
cid = 0
|
|
for y0 in range(hgt):
|
|
for x0 in range(w):
|
|
if comp_id[y0][x0] != -1 or not is_bag(px[x0, y0]):
|
|
continue
|
|
q = deque([(x0, y0)])
|
|
comp_id[y0][x0] = cid
|
|
edge_hit = False
|
|
members = []
|
|
while q:
|
|
x, y = q.popleft()
|
|
members.append((x, y))
|
|
if x in (0, w - 1) or y in (0, hgt - 1):
|
|
edge_hit = True
|
|
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 comp_id[ny][nx] == -1 and is_bag(px[nx, ny]):
|
|
comp_id[ny][nx] = cid
|
|
q.append((nx, ny))
|
|
touches_edge.append(edge_hit)
|
|
cid += 1
|
|
n = 0
|
|
for y in range(hgt):
|
|
for x in range(w):
|
|
i = comp_id[y][x]
|
|
if i >= 0 and not touches_edge[i]:
|
|
r, g, b, a = px[x, y]
|
|
px[x, y] = (r, g, b, 0)
|
|
n += 1
|
|
return im
|
|
|
|
|
|
def fixer_grid_snap(src_path):
|
|
"""rust pixelfixer 收网格:网格检测+重建回原生分辨率(全量档,亚秒级)。失败回落原图。"""
|
|
tmp_out = tempfile.mktemp(suffix=".png")
|
|
try:
|
|
r = subprocess.run([FIXER_EXE, "process", str(src_path), tmp_out, "full"],
|
|
capture_output=True, timeout=120)
|
|
if r.returncode == 0 and os.path.isfile(tmp_out):
|
|
return Image.open(tmp_out).convert("RGB")
|
|
print(" [fixer] 失败(code=%s),回落原图: %s" % (r.returncode, r.stderr.decode(errors="replace")[-120:]))
|
|
except Exception as e: # 引擎缺位/超时——回落,管线不断
|
|
print(" [fixer] 异常回落原图: %s" % e)
|
|
finally:
|
|
if os.path.isfile(tmp_out):
|
|
os.remove(tmp_out)
|
|
return Image.open(src_path).convert("RGB")
|
|
|
|
|
|
def _bbox_alpha(im):
|
|
bbox = im.getchannel("A").getbbox()
|
|
return bbox if bbox else (0, 0, im.width, im.height)
|
|
|
|
|
|
def dom_block_resize(im, nw, nh):
|
|
"""块内众数投票降采样(48px 档专用,代拍板待追认):每目标格取源块内众数不透明色,
|
|
透明占比过半则置透明。零插值(输出色 ⊆ 源色板)——大比例缩比(≈0.25×)下比单点
|
|
NEAREST 干净(体量连贯/描边连续,09-29 goblin A/B 实证);NEAREST 仅用于无损域。"""
|
|
w, hgt = im.size
|
|
px = im.load()
|
|
out = Image.new("RGBA", (nw, nh), (0, 0, 0, 0))
|
|
po = out.load()
|
|
for oy in range(nh):
|
|
y0, y1 = int(oy * hgt / nh), max(int(oy * hgt / nh) + 1, int((oy + 1) * hgt / nh))
|
|
for ox in range(nw):
|
|
x0, x1 = int(ox * w / nw), max(int(ox * w / nw) + 1, int((ox + 1) * w / nw))
|
|
cnt = Counter()
|
|
tot = 0
|
|
opaque = 0
|
|
for y in range(y0, y1):
|
|
for x in range(x0, x1):
|
|
c = px[x, y]
|
|
cnt[c] += 1
|
|
tot += 1
|
|
if c[3] > 0:
|
|
opaque += 1
|
|
if opaque * 2 < tot:
|
|
po[ox, oy] = (0, 0, 0, 0)
|
|
else:
|
|
po[ox, oy] = max((c for c in cnt if c[3] > 0), key=lambda c: cnt[c])
|
|
return out
|
|
|
|
|
|
def build_standee48(batch_root, out_dir, extra_hero_states=("attack", "hit")):
|
|
"""出八敌 + 主角的站桩源档。源 = 八敌 pixel-1 + 主角 normal_1x(追加 attack/hit,默认启用)。
|
|
|
|
口径:
|
|
1. 八敌各自按 STANDEE_TIER_H 定高——那是生物体型,不是强度档位。
|
|
(实测可读门槛约 32px,48px 舒适;若全敌共用「48/最大值」一个系数,
|
|
普通怪被压到 21–24px 低于门槛、钩爪恐魔糊成一团——禁用统一比例。)
|
|
2. 主角仍按 STANDEE_SLOT_H(48)定高。
|
|
3. 画布高度 = 该角色自己的档高;内容裁透明边后底中锚贴底缘。
|
|
4. 降采样走 dom_block_resize(块内众数投票,零插值)。
|
|
"""
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
enemy_srcs = {}
|
|
for key in sorted(SIZES.keys()):
|
|
p = os.path.join(batch_root, key, "pixel-1.png")
|
|
if os.path.isfile(p):
|
|
enemy_srcs[key] = Image.open(p).convert("RGBA")
|
|
hero_p = os.path.join(batch_root, "hero-ref", "edit", "normal_1x.png")
|
|
jobs = [(k, im, "character_enemy_%s.png" % k) for k, im in enemy_srcs.items()]
|
|
if os.path.isfile(hero_p):
|
|
jobs.append(("hero_normal", Image.open(hero_p).convert("RGBA"), "character_hero_portrait.png"))
|
|
for st in extra_hero_states:
|
|
sp = os.path.join(batch_root, "hero-ref", "edit", "%s_1x.png" % st)
|
|
if os.path.isfile(sp):
|
|
jobs.append(("hero_%s" % st, Image.open(sp).convert("RGBA"),
|
|
"character_hero_%s.png" % st))
|
|
made = []
|
|
for key, im, out_name in jobs:
|
|
l, t, r, b = _bbox_alpha(im)
|
|
im = im.crop((l, t, r, b))
|
|
is_hero = key == "hero_normal" or key.startswith("hero_")
|
|
slot_h = STANDEE_SLOT_H if is_hero else STANDEE_TIER_H.get(key, STANDEE_SLOT_H)
|
|
f = slot_h / float(im.height)
|
|
nw, nh = max(1, int(round(im.width * f))), max(1, int(round(im.height * f)))
|
|
if nw > STANDEE_SLOT_W_MAX: # 超宽兜底(现无触)——按宽收再贴底
|
|
k2 = STANDEE_SLOT_W_MAX / float(nw)
|
|
nw, nh = STANDEE_SLOT_W_MAX, max(1, int(round(nh * k2)))
|
|
body = dom_block_resize(im, nw, min(nh, slot_h))
|
|
canvas = Image.new("RGBA", (nw, slot_h), (0, 0, 0, 0))
|
|
canvas.alpha_composite(body, (0, slot_h - body.height)) # 底中锚:脚贴画布底缘
|
|
outp = os.path.join(out_dir, out_name)
|
|
canvas.save(outp)
|
|
print("standee %-14s %sx%s -> %s" % (key, nw, slot_h, outp))
|
|
made.append(outp)
|
|
return made
|
|
|
|
|
|
def main(argv):
|
|
root = argv[0] if argv and not argv[0].startswith("--") else "artwork/remote-gen-2026-09-26"
|
|
args = argv[1:] if argv and not argv[0].startswith("--") else argv
|
|
if "--standee48" in args:
|
|
out = args[args.index("--standee-out") + 1] if "--standee-out" in args \
|
|
else os.path.join("game", "assets", "art", "character")
|
|
build_standee48(root, out)
|
|
return
|
|
use_fixer = "--fixer" in args
|
|
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 "pixel" in f:
|
|
continue
|
|
n = f[1].split(".")[0] if len(f) > 1 and f[1].isdigit() else f
|
|
if use_fixer:
|
|
src = fixer_grid_snap(os.path.join(d, f))
|
|
else:
|
|
src = Image.open(os.path.join(d, f))
|
|
rgba = remove_bg(src)
|
|
if use_fixer:
|
|
rgba = remove_closed_gray_bags(rgba)
|
|
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
|
|
rows, row, rw = [], [], 0
|
|
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__":
|
|
_argv = sys.argv[1:]
|
|
if "--fixer-exe" in _argv:
|
|
_i = _argv.index("--fixer-exe")
|
|
FIXER_EXE = _argv[_i + 1]
|
|
_argv = _argv[:_i] + _argv[_i + 2:]
|
|
main(_argv)
|