#!/usr/bin/env python3 """genbench3 · 首帧候选(PCE env pce_f3,FLUX.2 klein-9B;只读复用 firstframe_flux)。每个起点(sources.json)出: * style__s:整帧换画风(RESTYLE_V4:纯材质 / 渲染改写,不增删物体;6 个种子,门槛挑); * char___s:只换一个角色的外观 —— 按 ids0 的角色掩码取包围框(长宽比 = W:H,留边),裁出来放大到 FLUX 网格编辑(CHAR_PROMPT:同位置 / 姿势 / 体型 / 轮廓 / 武器,只换外观),缩回原尺寸,再只在掩码内(外扩 2 px、 羽化 1 px,扣掉另一个角色)贴回原帧 ⇒ 掩码外逐像素不变,语义图不会被改; * --combo picks.json:在已通过门槛的画风首帧上再换角色(char 编辑的底图换成那张画风帧,提示词带上画风)。 每张候选旁边写 .json:设置 + 自动指标(layout_metrics vs ids0、struct_metrics vs 原帧、角色显著度 / 填充率 / 外观变化)。 可续跑(已有 png+json 跳过),按 --shard i --nshards n 分卡。 usage: CUDA_VISIBLE_DEVICES=0 $FPY scripts/genbench3/make_f0.py --shard 0 --nshards 8 [--combo outputs/genbench3/picks_pass1.json]""" import argparse import json import os import sys import time import numpy as np from PIL import Image from scipy import ndimage M = "/opt/dlami/nvme/zhiyangdeng/maliang" sys.path.insert(0, f"{M}/code/gc25d") sys.path.insert(0, f"{M}/scripts") import firstframe_flux as FF # noqa: E402 G = f"{M}/outputs/genbench3" STYLE_SEEDS, CHAR_SEEDS = (0, 1, 2, 3, 4, 5), (0, 1) # genbench2 的 12 种(训练外观里都没有)+ 4 种常见游戏画风(pixel / paint 与 v9 的后处理画风相近 = 近分布对照) # 主题画风写成「已有的东西换材质」:genbench2 的原描述(「巧克力墙、棒棒糖树」)等于让 FLUX 往空地上加东西(冒烟 candy / lego) STYLES = { "ukiyoe": "a Japanese ukiyo-e woodblock print: bold black outlines, flat areas of Prussian blue, vermilion and ochre, " "visible wood-grain and washi paper texture, no shading gradients", "stainedglass": "a medieval stained-glass window: every existing surface, object and character rendered as pieces of " "glowing translucent coloured glass separated by thick black lead lines, jewel-toned light", "lego": "a world made of plastic toy bricks, where the existing ground is re-textured as flat toy baseplates with " "studs, existing rocks, plants, walls and buildings become brick-built versions of themselves at the same " "places, characters become toy minifigures, glossy ABS plastic in bright primary colours", "voxel": "voxel art, where every existing surface, object and character is rebuilt from small uniform cubes at the " "same place and size, crisp flat-shaded cube faces, simple saturated colours", "knitted": "a knitted wool and felt craft diorama, where every existing surface, object and character is re-made " "from yarn and felt at the same place, visible stitches and fuzzy fibres, soft pastel textile colours", "porcelain": "Chinese blue-and-white porcelain painting: cobalt-blue brush patterns on glossy white glaze, everything " "rendered only in shades of blue on white", "blueprint": "a technical blueprint drawing: thin white line-art on deep blue grid paper, hatching for shading, no " "colour fills", "chalkboard": "a chalk drawing on a dark green school blackboard: dusty chalk strokes in white and pastel colours, " "smudges and eraser marks", "candy": "a candy and dessert world, where the existing ground is re-textured as smooth pink frosting, existing rocks " "become glossy hard candies, existing plants become small lollipops and gummy shapes, existing walls and " "buildings become chocolate and wafer, with pastel sugary colours", "lava": "a volcanic hellscape, where the existing ground is re-textured as black obsidian and ash with glowing cracks " "of molten lava, existing objects become dark volcanic rock, floating embers, red-orange light from below", "mosaic": "an ancient Roman floor mosaic: everything made of small square stone and glass tesserae with visible grout " "lines, earthy terracotta, ochre and teal colours", "papyrus": "an ancient Egyptian tomb wall painting on papyrus: flat ochre, turquoise and lapis colours, thick dark " "outlines, cracked aged plaster texture", "winter": "a snowy winter night: thick fresh snow covering the existing ground, roofs and plants, cold blue moonlight " "and falling snowflakes", "watercolor": FF.STYLE_PRESETS["watercolor painting"], "pixelart": FF.STYLE_PRESETS["pixel-art retro game"], "neon": "a neon cyberpunk night: the existing surfaces become dark, wet and reflective, lit by glowing magenta and " "cyan neon light, existing objects and buildings get glowing neon edges, haze and rain", } NEAR_DIST = ("pixelart", "watercolor") # 训练里有 pixel / paint 后处理:近分布 LOOKS = { "player": { "silverknight": "a knight in polished silver plate armour with a bright blue cape and a plumed silver helmet", "firemage": "a fire mage in flowing crimson-and-gold robes with a pointed hood and glowing orange runes", "ninja": "a ninja dressed entirely in black with a long red scarf and a black face mask", "ranger": "an elven ranger in a green hooded cloak and brown leather armour with long blond hair", }, "boss": { "stonegolem": "a hulking golem made of grey granite boulders held together by glowing cyan crystals", "skeletonking": "a giant skeletal undead king in bone armour with a tattered purple cloak and a golden crown", "icetroll": "a giant ice troll with pale blue skin, white shaggy fur and frost crystals on its shoulders", "demonlord": "a demon warlord in jagged black-and-crimson armour with large curved horns and glowing yellow eyes", }, } WHO = {"player": "the player hero (the smaller human character)", "boss": "the boss (the large enemy monster)"} PARTS = {"player": FF.PLAYER_PARTS, "boss": FF.BOSS_PARTS} CHAR_PROMPT = ("This is a close-up crop of a top-down isometric action RPG game screenshot. Redesign only {who} in the " "centre of the image as {look}. Keep exactly the same position, pose, body size, silhouette and facing " "direction, and keep the weapon in the same hand at the same angle. Keep the camera angle, lighting, shadows, " "the background and any other character exactly unchanged.{style}") COMBO_STYLE = " The whole image is in the art style of {style}; draw the redesigned character in that same art style." # 画风提示词:firstframe_flux 的 v1 会在空地上加东西(冒烟 candy:巧克力墙 / 棒棒糖 = 与语义图矛盾)⇒ 改成纯材质 / 渲染改写 # v4(冒烟:同种子下大轮廓多出的比 v3 少,浮世绘 coarse precision 0.58 → 0.74) RESTYLE_V4 = ("Re-render this exact top-down isometric action RPG game screenshot in a new art style: {style}. This is a " "pure material and rendering change of the existing image: every region, object, building, wall, rock, " "plant, path and character stays exactly where it is with exactly the same outline, size and pose, and the " "two characters keep their weapons. Do not add anything new and do not remove anything; large empty areas " "of ground stay empty and are only re-textured. Same camera, same composition.") PAD, MIN_CROP_W, FEATHER = 1.7, 176, 1.5 # 贴回区域 = 胶囊掩码外扩 CORE px ∪(掩码外 REACH·角色尺寸以内、FLUX 改动 ΔE > DIFF_DE 的连通区):语义图的角色是胶囊代理, # 真实轮廓会伸出掩码(熔岩巨人的拳头)—— 只贴掩码内会留下旧角色的残块(冒烟 icetroll) CORE, REACH, DIFF_DE = 2, 0.18, 14.0 AR = FF.W_OUT / FF.H_OUT # ------------------------------------------------------------------ 指标 ---- def ring(mask, other, r0=3, r1=12): """角色掩码外 r0..r1 px 的一圈(扣掉另一个角色)= 角色周围的背景。""" out = ndimage.binary_dilation(mask, iterations=r1) & ~ndimage.binary_dilation(mask, iterations=r0) return out & ~other def char_stats(rgb, ids): """每个角色:显著度 = 掩码内 Lab 均值与周围一圈的 ΔE;填充率 = 掩码内与周围背景(一圈的中位色)ΔE < 12 的比例 (越高 ⇒ 掩码里画的是背景,角色没填满语义轮廓)。""" L = FF._lab(rgb) out = {} for role, parts in PARTS.items(): m = np.isin(ids, parts) o = np.isin(ids, PARTS["boss" if role == "player" else "player"]) rg = ring(m, o) if m.sum() < 50 or rg.sum() < 50: continue bg = np.median(L[rg], 0) out[role] = {"salience": round(float(np.linalg.norm(L[m].mean(0) - L[rg].mean(0))), 2), "bg_fill": round(float((np.linalg.norm(L[m] - bg, axis=-1) < 12).mean()), 4), "px": int(m.sum())} return out def coarse_struct(src, out): """粗尺度(½ 分辨率 + σ2 模糊,压掉画风纹理,只剩物体轮廓)的 struct_metrics:precision 低 ⇒ 多出了物体 / 轮廓。""" def half(x): return np.asarray(Image.fromarray(x).resize((x.shape[1] // 2, x.shape[0] // 2), Image.BILINEAR)) a, b = (ndimage.gaussian_filter(half(x).astype(np.float32), (2, 2, 0)).clip(0, 255).astype(np.uint8) for x in (src, out)) return {f"coarse_{k}": round(v, 4) for k, v in FF.struct_metrics(a, b, tol=2).items()} def metrics(rgb, src, ids): m = {"layout": {k: (round(v, 4) if isinstance(v, float) else v) for k, v in FF.layout_metrics(rgb, ids).items()}, "struct": {k: round(v, 4) for k, v in FF.struct_metrics(src, rgb).items()}, "chars": char_stats(rgb, ids)} m["struct"].update(coarse_struct(src, rgb)) return m # ------------------------------------------------------------------ 角色编辑 ---- def crop_box(mask): ys, xs = np.nonzero(mask) cy, cx = (ys.min() + ys.max()) / 2, (xs.min() + xs.max()) / 2 bw, bh = xs.max() - xs.min() + 1, ys.max() - ys.min() + 1 w = int(round(min(FF.W_OUT, max(MIN_CROP_W, PAD * max(bw, bh * AR))))) h = int(round(w / AR)) if h > FF.H_OUT: h, w = FF.H_OUT, int(round(FF.H_OUT * AR)) x0 = int(np.clip(round(cx - w / 2), 0, FF.W_OUT - w)) y0 = int(np.clip(round(cy - h / 2), 0, FF.H_OUT - h)) return y0, y0 + h, x0, x0 + w def char_edit(ff, base, ids, role, look, seed, style=None): import torch m = np.isin(ids, PARTS[role]) other = np.isin(ids, PARTS["boss" if role == "player" else "player"]) y0, y1, x0, x1 = crop_box(m) crop = base[y0:y1, x0:x1] prompt = CHAR_PROMPT.format(who=WHO[role], look=look, style=COMBO_STYLE.format(style=style) if style else "") t0 = time.time() with torch.no_grad(): big = ff.net(prompt, [FF.to_gen(crop)], FF.DEFAULT_STEPS["flux2"], seed) torch.cuda.synchronize() sec = time.time() - t0 ed = FF.area_resize(big, x1 - x0, y1 - y0) mc, oc = m[y0:y1, x0:x1], other[y0:y1, x0:x1] ys, xs = np.nonzero(mc) reach = max(4, int(round(REACH * max(ys.max() - ys.min(), xs.max() - xs.min())))) diff = ndimage.gaussian_filter(np.linalg.norm(FF._lab(ed) - FF._lab(crop), axis=-1), 1.0) > DIFF_DE near = ndimage.binary_dilation(mc, iterations=reach) lab, _ = ndimage.label(diff & near) touch = np.unique(lab[ndimage.binary_dilation(mc, iterations=CORE) & (lab > 0)]) grown = np.isin(lab, touch[touch > 0]) # 与掩码相连的改动区(旧角色伸出掩码的部分) reg = ndimage.binary_fill_holes(ndimage.binary_dilation(mc, iterations=CORE) | grown) reg &= ~ndimage.binary_dilation(oc, iterations=1) a = np.clip(ndimage.gaussian_filter(reg.astype(np.float32), FEATHER), 0, 1) a[oc] = 0.0 a[mc] = 1.0 out = base.copy() out[y0:y1, x0:x1] = (a[..., None] * ed + (1 - a[..., None]) * crop).round().clip(0, 255).astype(np.uint8) Lb, Lo = FF._lab(base), FF._lab(out) info = {"prompt": prompt, "box": [y0, y1, x0, x1], "sec": round(sec, 2), "reach_px": reach, "grown_px": int((grown & ~mc).sum()), "region_px": int(reg.sum()), "mask_px": int(mc.sum()), "change_dE": round(float(np.linalg.norm(Lo[m] - Lb[m], axis=-1).mean()), 2)} return out, ed, info # ------------------------------------------------------------------ 任务 ---- def jobs(sources, combo): out = [] for s in sources: tag = s["tag"] if combo is None: for st in STYLES: for seed in STYLE_SEEDS: out.append({"tag": tag, "kind": "style", "style": st, "seed": seed, "name": f"style_{st}_s{seed}"}) for role, looks in LOOKS.items(): for lk in looks: for seed in CHAR_SEEDS: out.append({"tag": tag, "kind": "char", "role": role, "look": lk, "seed": seed, "name": f"char_{role}_{lk}_s{seed}"}) else: for c in combo.get(tag, []): # {"base": style 候选名, "style": st, "role":..., "look":...} for seed in CHAR_SEEDS: out.append({"tag": tag, "kind": "combo", "base": c["base"], "style": c["style"], "role": c["role"], "look": c["look"], "seed": seed, "name": f"combo_{c['style']}+{c['role']}_{c['look']}_s{seed}"}) return out def main(): ap = argparse.ArgumentParser() ap.add_argument("--shard", type=int, default=0) ap.add_argument("--nshards", type=int, default=1) ap.add_argument("--combo", default=None, help="picks json:{tag: [{base, style, role, look}]}") ap.add_argument("--only", default=None, help="正则,只跑 tag/name 匹配的(冒烟用)") a = ap.parse_args() sources = json.load(open(f"{G}/sources.json")) combo = json.load(open(a.combo)) if a.combo else None todo = [j for i, j in enumerate(jobs(sources, combo)) if i % a.nshards == a.shard] if a.only: import re todo = [j for j in todo if re.search(a.only, f"{j['tag']}/{j['name']}")] todo = [j for j in todo if not os.path.exists(f"{G}/cands/{j['tag']}/{j['name']}.json")] print(f"[make_f0] shard {a.shard}/{a.nshards}: {len(todo)} 张待生成", flush=True) if not todo: return ff = FF.FirstFrameFlux("klein9b") print(f"[make_f0] FLUX 加载 {ff.load_sec:.0f}s", flush=True) cache = {} for j in todo: tag = j["tag"] if tag not in cache: d = f"{G}/sources/{tag}" src = np.asarray(Image.open(f"{d}/f0.png").convert("RGB")) ids = np.load(f"{d}/ids0.npy") cache[tag] = (src, ids) mpath = f"{d}/f0_metrics.json" if not os.path.exists(mpath): json.dump(metrics(src, src, ids), open(mpath, "w"), indent=1) src, ids = cache[tag] od = f"{G}/cands/{tag}" os.makedirs(od, exist_ok=True) info = dict(j) if j["kind"] == "style": out = ff.generate(RESTYLE_V4.format(style=STYLES[j["style"]]), [FF.to_gen(src)], seed=j["seed"]) info.update(prompt=ff.last["prompt"], sec=ff.last["sec"], near_dist=j["style"] in NEAR_DIST) else: base = src if j["kind"] == "char" else np.asarray(Image.open(f"{od}/{j['base']}.png").convert("RGB")) out, ed, ci = char_edit(ff, base, ids, j["role"], LOOKS[j["role"]][j["look"]], j["seed"], STYLES[j["style"]] if j["kind"] == "combo" else None) info.update(ci) Image.fromarray(ed).save(f"{od}/{j['name']}_crop.png") Image.fromarray(out).save(f"{od}/{j['name']}.png") info["metrics"] = metrics(out, src, ids) json.dump(info, open(f"{od}/{j['name']}.json", "w"), indent=1) print(f"[make_f0] {tag} {j['name']} {info['sec']}s", flush=True) if __name__ == "__main__": main()