feat(t1): 素材管线正式化——pixelize_candidates.py --fixer+九张 48px 站桩槽位档生成器+机检脚本

①--fixer: rust pixelfixer(D:/code/pixel-art-fixer,MIT)收网格(网格检测+重建)+封闭灰袋清除收编(三闸=近底色∧中性灰∧组件不触图缘,防误伤盔甲与影;09-28 手工批次正式化,遗留项清偿)
②--standee48: 九张 48px 槽位档(八敌 pixel-1+主角 normal_1x→character_enemy_*/character_hero_portrait)——统一比例最大体量归 48(相对体量语义=字形回退同构)/底中锚脚贴底缘/块内众数投票降采样(零插值,输出色⊂源色板;NEAREST 直缩 0.25x 糊,goblin A/B 实证,代拍板待追认)/超宽按宽收兜底
③tools/standee_asset_check.py 机检: 高 48/宽≤75/底锚/≤32 色/alpha 二值 五断言,必需集=八敌+主角三态(attack/hit 缺位=T3 范围暂 MISS)
产物九张已落 worktree game/assets/art/character/(gitignored 不入库);机检 9/9 过;轻闸=冒烟 576/0+三探针 PASS 零退化
This commit is contained in:
DKChan committed 2026-09-29 03:59:27 +08:00
1 parent f39440daec
commit 4271216b73
2 files changed
+266 -5

No files matched your search

+187 -5
View File
@@ -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 <game/assets/art/character>]
# 九张 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)
+79
View File
@@ -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()