From 4271216b73e677589683a84ffb8218326502b2f3 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Tue, 29 Sep 2026 03:59:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(t1):=20=E7=B4=A0=E6=9D=90=E7=AE=A1?= =?UTF-8?q?=E7=BA=BF=E6=AD=A3=E5=BC=8F=E5=8C=96=E2=80=94=E2=80=94pixelize?= =?UTF-8?q?=5Fcandidates.py=20--fixer+=E4=B9=9D=E5=BC=A0=2048px=20?= =?UTF-8?q?=E7=AB=99=E6=A1=A9=E6=A7=BD=E4=BD=8D=E6=A1=A3=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=99=A8+=E6=9C=BA=E6=A3=80=E8=84=9A=E6=9C=AC=20=E2=91=A0--fix?= =?UTF-8?q?er:=20rust=20pixelfixer(D:/code/pixel-art-fixer,MIT)=E6=94=B6?= =?UTF-8?q?=E7=BD=91=E6=A0=BC(=E7=BD=91=E6=A0=BC=E6=A3=80=E6=B5=8B+?= =?UTF-8?q?=E9=87=8D=E5=BB=BA)+=E5=B0=81=E9=97=AD=E7=81=B0=E8=A2=8B?= =?UTF-8?q?=E6=B8=85=E9=99=A4=E6=94=B6=E7=BC=96(=E4=B8=89=E9=97=B8=3D?= =?UTF-8?q?=E8=BF=91=E5=BA=95=E8=89=B2=E2=88=A7=E4=B8=AD=E6=80=A7=E7=81=B0?= =?UTF-8?q?=E2=88=A7=E7=BB=84=E4=BB=B6=E4=B8=8D=E8=A7=A6=E5=9B=BE=E7=BC=98?= =?UTF-8?q?,=E9=98=B2=E8=AF=AF=E4=BC=A4=E7=9B=94=E7=94=B2=E4=B8=8E?= =?UTF-8?q?=E5=BD=B1;09-28=20=E6=89=8B=E5=B7=A5=E6=89=B9=E6=AC=A1=E6=AD=A3?= =?UTF-8?q?=E5=BC=8F=E5=8C=96,=E9=81=97=E7=95=99=E9=A1=B9=E6=B8=85?= =?UTF-8?q?=E5=81=BF)=20=E2=91=A1--standee48:=20=E4=B9=9D=E5=BC=A0=2048px?= =?UTF-8?q?=20=E6=A7=BD=E4=BD=8D=E6=A1=A3(=E5=85=AB=E6=95=8C=20pixel-1+?= =?UTF-8?q?=E4=B8=BB=E8=A7=92=20normal=5F1x=E2=86=92character=5Fenemy=5F*/?= =?UTF-8?q?character=5Fhero=5Fportrait)=E2=80=94=E2=80=94=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=AF=94=E4=BE=8B=E6=9C=80=E5=A4=A7=E4=BD=93=E9=87=8F?= =?UTF-8?q?=E5=BD=92=2048(=E7=9B=B8=E5=AF=B9=E4=BD=93=E9=87=8F=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=3D=E5=AD=97=E5=BD=A2=E5=9B=9E=E9=80=80=E5=90=8C?= =?UTF-8?q?=E6=9E=84)/=E5=BA=95=E4=B8=AD=E9=94=9A=E8=84=9A=E8=B4=B4?= =?UTF-8?q?=E5=BA=95=E7=BC=98/=E5=9D=97=E5=86=85=E4=BC=97=E6=95=B0?= =?UTF-8?q?=E6=8A=95=E7=A5=A8=E9=99=8D=E9=87=87=E6=A0=B7(=E9=9B=B6?= =?UTF-8?q?=E6=8F=92=E5=80=BC,=E8=BE=93=E5=87=BA=E8=89=B2=E2=8A=82?= =?UTF-8?q?=E6=BA=90=E8=89=B2=E6=9D=BF;NEAREST=20=E7=9B=B4=E7=BC=A9=200.25?= =?UTF-8?q?x=20=E7=B3=8A,goblin=20A/B=20=E5=AE=9E=E8=AF=81,=E4=BB=A3?= =?UTF-8?q?=E6=8B=8D=E6=9D=BF=E5=BE=85=E8=BF=BD=E8=AE=A4)/=E8=B6=85?= =?UTF-8?q?=E5=AE=BD=E6=8C=89=E5=AE=BD=E6=94=B6=E5=85=9C=E5=BA=95=20?= =?UTF-8?q?=E2=91=A2tools/standee=5Fasset=5Fcheck.py=20=E6=9C=BA=E6=A3=80:?= =?UTF-8?q?=20=E9=AB=98=2048/=E5=AE=BD=E2=89=A475/=E5=BA=95=E9=94=9A/?= =?UTF-8?q?=E2=89=A432=20=E8=89=B2/alpha=20=E4=BA=8C=E5=80=BC=20=E4=BA=94?= =?UTF-8?q?=E6=96=AD=E8=A8=80,=E5=BF=85=E9=9C=80=E9=9B=86=3D=E5=85=AB?= =?UTF-8?q?=E6=95=8C+=E4=B8=BB=E8=A7=92=E4=B8=89=E6=80=81(attack/hit=20?= =?UTF-8?q?=E7=BC=BA=E4=BD=8D=3DT3=20=E8=8C=83=E5=9B=B4=E6=9A=82=20MISS)?= =?UTF-8?q?=20=E4=BA=A7=E7=89=A9=E4=B9=9D=E5=BC=A0=E5=B7=B2=E8=90=BD=20wor?= =?UTF-8?q?ktree=20game/assets/art/character/(gitignored=20=E4=B8=8D?= =?UTF-8?q?=E5=85=A5=E5=BA=93);=E6=9C=BA=E6=A3=80=209/9=20=E8=BF=87;?= =?UTF-8?q?=E8=BD=BB=E9=97=B8=3D=E5=86=92=E7=83=9F=20576/0+=E4=B8=89?= =?UTF-8?q?=E6=8E=A2=E9=92=88=20PASS=20=E9=9B=B6=E9=80=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/pixelize_candidates.py | 192 ++++++++++++++++++++++++++++++++++- tools/standee_asset_check.py | 79 ++++++++++++++ 2 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 tools/standee_asset_check.py diff --git a/tools/pixelize_candidates.py b/tools/pixelize_candidates.py index ec1fdcf7..8ddc9a95 100644 --- a/tools/pixelize_candidates.py +++ b/tools/pixelize_candidates.py @@ -9,12 +9,25 @@ v3 演进(2026-09-27 用户四问题:脏点/模糊/信息丢失/白边): - 白边 = 吃描边: 主色距离门 + 「底色↔主色线段」反锯齿混合带桥(限层深,防深入本体) - 教训: 不做第二遍低膨胀洪泛——JPEG 噪点在描边开 1-2px 缺口会洪灌本体 (duergar 甲面被吃空实证);屏障只跑一遍 dil=3 -用法: python tools/pixelize_candidates.py <候选批次目录> +用法: + python tools/pixelize_candidates.py <候选批次目录> # v3 批量像素化(现行行为) + python tools/pixelize_candidates.py <目录> --fixer # +rust fixer 收网格+封闭灰袋清除 + python tools/pixelize_candidates.py <目录> --standee48 [--standee-out ] + # 九张 48px 站桩槽位档(八敌 pixel-1+主角 normal; + # 底中锚/NEAREST/统一比例保体量/画布高恒 48) +--fixer(2026-09-29 迭代 6 T1 管线正式化,收编 09-28 手工批次): rust pixelfixer(Retro-Diffusion, + MIT, D:/code/pixel-art-fixer)对原图先做网格检测+重建(收网格),v3 去底后再跑封闭灰袋清除 + (三闸=近底色∧中性灰∧组件不触图缘——防误伤盔甲与影);--fixer-exe 可换引擎路径。 """ -import sys, os +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 的一半(超宽才按宽收,现八敌+主角均不触) + SIZES = {"goblin": 96, "giant_spider": 96, "hook_horror": 96, "bugbear": 128, "duergar": 128, "ogre_chief": 192, "drider": 192, "duergar_king": 192} @@ -317,7 +330,166 @@ def strip(spr, path): sheet = sheet.resize((sheet.width * 2, sheet.height * 2), Image.NEAREST) sheet.save(path) -def main(root): +def remove_closed_gray_bags(im): + """封闭灰袋清除(09-28 批手工步骤收编):本体轮廓内困住的底色/灰袋,图缘洪泛够不着。 + + 三闸全过才除(防误伤盔甲与影): ①近底色(四角底色距离门) ②中性灰(低饱和) + ③连通组件不触图缘(封闭——贴缘的地面影属开放区,洪泛已处理)。 + """ + 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=()): + """九张 48px 站桩槽位档:源=八敌 pixel-1+主角 normal_1x(extra_hero_states 追加 attack/hit)。 + + 口径(代拍板待追认): 敌八种统一比例(最大体量归 48 高,相对体量语义=字形回退 ▂▆█ 同构); + 主角自适比例(48/56);NEAREST 零插值;内容 bbox 裁边后底中锚贴 (w,48) 透明画布。 + """ + 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)) + max_h = max(im.height for _, im, _ in jobs) + f_enemy = STANDEE_SLOT_H / float(max_h) # 敌统一比例(体量语义) + made = [] + for key, im, out_name in jobs: + l, t, r, b = _bbox_alpha(im) + im = im.crop((l, t, r, b)) + f = f_enemy if key != "hero_normal" and not key.startswith("hero_") else STANDEE_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, STANDEE_SLOT_H)) + canvas = Image.new("RGBA", (nw, STANDEE_SLOT_H), (0, 0, 0, 0)) + canvas.alpha_composite(body, (0, STANDEE_SLOT_H - body.height)) # 底中锚:脚贴画布底缘 + outp = os.path.join(out_dir, out_name) + canvas.save(outp) + print("standee48 %-14s %sx%s -> %s" % (key, nw, STANDEE_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) @@ -327,8 +499,13 @@ def main(root): 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)) + 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) @@ -359,4 +536,9 @@ def main(root): print("总览 -> 0-像素化预览.png") if __name__ == "__main__": - main(sys.argv[1] if len(sys.argv) > 1 else "artwork/remote-gen-2026-09-26") + _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) diff --git a/tools/standee_asset_check.py b/tools/standee_asset_check.py new file mode 100644 index 00000000..8297245e --- /dev/null +++ b/tools/standee_asset_check.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""站桩槽位素材机检(M-3 迭代 6 T1——48px 档契约断言;重闸素材面闸)。 + +四断言(契约=计划书 C4/圣经 §8.2): + 尺寸 高恒 48(槽位源档 ×2 整数放大=盒高 96);宽 1..75(盒宽 150 半值)。 + 锚点 底行有不透明像素(底中锚——纵向比例自底边口径,脚贴画布底缘)。 + 色数 ≤32(fixer 批 32 色冻结;NEAREST 采样不产生新色)。 + 无插值 alpha 二值(仅 0/255——双线性/混合会在边缘产生中间 α)。 + +用法: python tools/standee_asset_check.py [角色素材目录] + 默认 game/assets/art/character/;必需集=八敌+主角 portrait/attack/hit。 +""" +import os +import sys +from collections import Counter +from PIL import Image + +REQUIRED = ["character_enemy_%s.png" % k for k in + ("bugbear", "drider", "duergar", "duergar_king", "giant_spider", + "goblin", "hook_horror", "ogre_chief")] + \ + ["character_hero_portrait.png", "character_hero_attack.png", "character_hero_hit.png"] +SLOT_H = 48 +W_MAX = 75 +COLOR_MAX = 32 + + +def check(path): + problems = [] + im = Image.open(path).convert("RGBA") + w, h = im.size + if h != SLOT_H: + problems.append("高应 48,实得 %d" % h) + if not (1 <= w <= W_MAX): + problems.append("宽应 1..%d,实得 %d" % (W_MAX, w)) + px = im.load() + colors = set() + alphas = set() + bottom_opaque = 0 + for y in range(h): + for x in range(w): + c = px[x, y] + alphas.add(c[3]) + if c[3] > 0: + colors.add(c[:3]) + if y == h - 1: + bottom_opaque += 1 + if bottom_opaque < 1: + problems.append("底行无不透明像素(底中锚失守——脚未贴画布底缘)") + if len(colors) > COLOR_MAX: + problems.append("色数 %d > %d" % (len(colors), COLOR_MAX)) + if not alphas <= {0, 255}: + problems.append("alpha 非二值(插值痕迹:%s)" % sorted(alphas - {0, 255})[:5]) + return problems, (w, h, len(colors)) + + +def main(): + root = sys.argv[1] if len(sys.argv) > 1 else os.path.join("game", "assets", "art", "character") + missing = [f for f in REQUIRED if not os.path.isfile(os.path.join(root, f))] + n_fail = 0 + for f in REQUIRED: + p = os.path.join(root, f) + if not os.path.isfile(p): + print("MISS %-36s 缺位" % f) + n_fail += 1 + continue + problems, info = check(p) + if problems: + n_fail += 1 + for pr in problems: + print("FAIL %-36s %s" % (f, pr)) + else: + print("PASS %-36s %dx%d 色数%d" % (f, info[0], info[1], info[2])) + print("机检完成:%d 件必需,%d 件未过" % (len(REQUIRED), n_fail)) + sys.exit(1 if n_fail else 0) + + +if __name__ == "__main__": + main()