From aa703b21a96fba9451c6eaa42bcdac495b5ee1a1 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Sun, 27 Sep 2026 02:18:13 +0800 Subject: [PATCH] =?UTF-8?q?tools:=20=E5=83=8F=E7=B4=A0=E5=8C=96=E5=80=99?= =?UTF-8?q?=E9=80=89=E7=AE=A1=E7=BA=BF=E8=90=BD=E5=9C=B0=E2=80=94=E2=80=94?= =?UTF-8?q?pixelize=5Fcandidates.py(=E5=8E=BB=E5=BA=95=E6=B4=AA=E6=B3=9B+?= =?UTF-8?q?=E7=81=B0/=E6=9A=97=E7=B4=AB/=E6=9A=96=E7=81=B0=E5=BD=B1?= =?UTF-8?q?=E4=B8=89=E8=A7=84=E5=88=99=E5=90=9E=E5=BD=B1+BOX4x=E9=A2=84?= =?UTF-8?q?=E6=BB=A4/48=E8=89=B2=E9=87=8F=E5=8C=96/NEAREST=E5=88=B01x+?= =?UTF-8?q?=E5=9B=9B=E8=81=94=E5=BA=95=E6=B5=8B=E8=AF=95=E6=9D=A1+?= =?UTF-8?q?=E6=80=BB=E8=A7=88=E6=8B=BC=E7=89=88);=E6=95=8C=E7=A7=8D?= =?UTF-8?q?=E4=B8=89=E6=A1=A348/64/96=E5=80=99=E9=80=89=E5=80=BC=E7=BB=88?= =?UTF-8?q?=E5=80=BC=E5=BD=92=E6=A0=B7=E5=BC=A0=E6=89=B9;=E6=95=99?= =?UTF-8?q?=E8=AE=AD=3Dhook=5Fhorror=E8=BA=AB=E4=BD=93=E4=B8=8E=E6=B5=85?= =?UTF-8?q?=E5=BD=B1=E5=90=8C=E8=89=B2=E7=B3=BB,=E8=89=B2=E7=9B=B8?= =?UTF-8?q?=E6=B3=95=E5=BF=85=E8=AF=AF=E5=90=9E,=E5=85=B6=E5=BD=B1?= =?UTF-8?q?=E7=95=99=E6=89=8B=E4=BF=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/pixelize_candidates.py | 183 +++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tools/pixelize_candidates.py diff --git a/tools/pixelize_candidates.py b/tools/pixelize_candidates.py new file mode 100644 index 00000000..be81aed1 --- /dev/null +++ b/tools/pixelize_candidates.py @@ -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")