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