Files
gplan-2/tools/pixelize_candidates.py
T

363 lines
14 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""远程生图候选 → 像素化候选稿 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 <候选批次目录>
"""
import sys, os
from collections import deque, Counter
from PIL import Image, ImageFilter
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 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 "pixel" in f:
continue
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)
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__":
main(sys.argv[1] if len(sys.argv) > 1 else "artwork/remote-gen-2026-09-26")