teawhite's picture
Add files using upload-large-folder tool
51ac7cc verified
Raw History Blame Contribute Delete
18.1 kB
#!/usr/bin/env python3
"""genbench4 · 换场景首帧的语义一致性门槛(PCE env pce_f3,Qwen3-VL-32B,genbench3/vlm_gate.QwenVL:Yes / No 首 token 的 P(yes))。
整图题(图 1 = 真实帧 0,图 2 = 候选):
* layout:同一关卡换成新场景后布局不变,且**没有东西换了类别**(空地不许变墙 / 悬崖、石头不许变房子……;同类里换成该场景
的东西可以 —— 冒烟 v1 的措辞「不管东西变成了哪一种」把石头 → 城墙也放过了);
* chars :两个角色都在、同位置、同姿势 / 体型(只记录;门槛用下面的逐角色放大比对 charcrop);only2:画面里只有这两个角色;
scene:(只看图 2)是不是该场景。
逐角色放大比对 charcrop:同一个框(角色掩码包围框外扩 50 %)裁原帧 / 候选:「同一个角色还在、同位置 / 姿势 / 体型吗」;基线 =
原帧对原帧,基线 < 0.5 的角色不判(VLM 在原帧里就认不出:被叶子挡住 / 太小 / 叠在一起)。
逐区域题(按帧 0 的语义图切,**原帧问同样的题作基线**,缓存在 sources/<tag>/regions.json):
* open:terrain / paving 占 ≥ 92 % 且离角色 ≥ 12 px 的 96×96 格(最多 10 个,均匀取):「只是可走的地面 / 地板,里面没有
立着的墙、建筑、悬崖、石阵、树、大物体」;
* obj :architecture / rock / vegetation / prop 的连通块(≥ 300 px,最多 8 个,按面积),裁出包围框(外扩、放大到短边 ≥ 336,
品红描出该块的语义轮廓):「描出来的东西是不是 {该类的通称}」。
区域分 = 原帧答 Yes(P ≥ 0.5)的区域里候选也答 Yes 的比例(obj 按面积加权)⇒ open_ok / obj_ok。
自动:语义边界边缘召回 ≥ 0.4 × 原帧(只防糊成一片;场景换光照 / 材质后 0.4–0.7 是常态,genbench3 的 0.8 不适用)。
genbench3 的「角色仍填满语义轮廓」(bg_fill)不用:换成暗场景(熔岩 / 地牢)后深色 Boss 与背景对比变低,bg_fill 涨到 0.4–0.7
而角色其实都在(冒烟目测)⇒ 角色在不在只靠 VLM chars。门槛值见 GATE(冒烟 outputs/genbench4_smoke 目测标定:区域题抓到的
都是真违规 —— 空地角落的树篱 / 枯灌木 / 城墙块、铺地上多出的门 / 火把 / 旗帜、石头变树篱 / 木架)。
输出 <cand>.vlm.json;--merge ⇒ outputs/genbench4/gate.json(每个 (起点, 场景) 槽在通过的候选里取分最高的)[+ --jobs ⇒ jobs.json]。
usage: CUDA_VISIBLE_DEVICES=0 $FPY scripts/genbench4/gate.py --shard 0 --nshards 8 ;$FPY scripts/genbench4/gate.py --merge --jobs"""
import argparse
import glob
import json
import os
import re
import sys
import time
import numpy as np
from PIL import Image
from scipy import ndimage
M = "/opt/dlami/nvme/zhiyangdeng/maliang"
G = os.environ.get("GB4", f"{M}/outputs/genbench4")
sys.path[:0] = [f"{M}/code/gc25d", f"{M}/scripts/genbench3", f"{M}/scripts/genbench4"]
from scenes import SCENES # noqa: E402
PLAYER, BOSS = (10, 12, 13, 14, 15, 16), (11, 17, 18, 19, 20, 21)
HEAD = ("Image 1 is a screenshot from a top-down isometric action RPG game. It shows a smaller player hero and a large "
"boss monster. ")
Q = {
"layout": HEAD + "Image 2 is supposed to be the same level with exactly the same layout, re-themed as {title}. Ignore "
"the art style, materials, colours and lighting. Each thing may turn into a different thing of the same "
"kind (a building into another building, a plant into another plant, a rock into another rock, a floor into "
"another floor), but nothing may change its kind: open ground or floor must not become a wall, building, "
"cliff, rock pile or tree, and a rock or plant must not become a building. Does image 2 keep every building "
"or wall, path or floor, open ground area, rock, plant and prop of image 1 in the same place with the same "
"footprint, size and kind, with nothing large added or removed? Answer Yes or No.",
"chars": HEAD + "Ignore the art style and the characters' appearance. Are both characters of image 1 (the player hero "
"and the boss monster) clearly present in image 2, at the same positions, with the same poses and sizes? "
"Answer Yes or No.",
"only2": HEAD + "Does image 2 contain exactly these two characters (one smaller player hero and one large boss "
"monster) and no other people, creatures or characters? Answer Yes or No.",
"scene": "This is a screenshot from a top-down isometric action RPG game. Does this level clearly show {vlm}? "
"Answer Yes or No.",
}
CROP = "This is a close-up crop from a top-down isometric game level. "
Q_OPEN = (CROP + "Does this crop show only open walkable ground or floor (for example dirt, sand, snow, grass, mud, ash, "
"shallow water, ice, tiles, flagstones or planks, possibly with cracks, puddles, pebbles, small debris or small "
"tufts), with no walls, buildings, towers, cliffs, rock formations, trees, large plants, fences or large objects "
"standing in it? Answer Yes or No.")
Q_OBJ = {
3: "part of a building, wall, tower, gate, hut, pillar, stairs or other built structure",
4: "a rock, boulder, stone block, crystal formation, ice block, or a pile of rubble or stones (a natural lump, not a "
"building or wall)",
5: "a plant: a tree, bush, hedge, grass, reeds, cactus, mushroom, fungus, moss or other vegetation (living, dead or "
"frozen)",
6: "a small object or prop, such as a crate, barrel, cart, wagon, lamp, brazier, statue, sign, fence, tombstone, boat, "
"container or piece of debris (not a building, rock or plant)",
}
Q_OBJ_T = CROP + "Is the thing outlined in magenta {what}? Answer Yes or No."
# 逐角色放大比对(整图 chars 题对小 / 重叠 / 被遮的角色常常连原帧都答 No ⇒ 全源失败,冒烟目测:train_v9_05 原帧里两人藏在竹叶后、
# train_v9_10 两人又小又叠在一起):同一个框裁原帧 / 候选,基线 = 原帧对原帧;基线 < 0.5 的角色(VLM 在原帧里就认不出)不判
WHO = {"player": "the player hero (a smaller human character)", "boss": "the boss (a large enemy monster)"}
Q_CHAR = ("Image 1 is a close-up of {who} in a top-down isometric action RPG game. Image 2 is a close-up of exactly the same "
"spot after the whole level was re-themed. Ignore colours, lighting, materials and art style. Is the same "
"character still clearly there in image 2, at the same position, with the same pose, size and body shape? "
"Answer Yes or No.")
CELL, OPEN_FRAC, CHAR_GAP, MAX_OPEN = 96, 0.92, 12, 10
MIN_COMP, MAX_COMP = 300, 8
GATE = {"layout": 0.3, "chars": 0.5, "only2": 0.5, "scene": 0.5, "open_ok": 0.8, "obj_ok": 0.7, "edge_rel": 0.4}
# ------------------------------------------------------------------ 区域 ----
def regions(ids):
"""[{kind: open|obj, cls, box (y0,y1,x0,x1), area, mask (obj 才有,box 内的 bool)}]。"""
H, W = ids.shape
chars = ndimage.binary_dilation(np.isin(ids, PLAYER + BOSS), iterations=CHAR_GAP)
openm = np.isin(ids, (1, 2)) & ~chars
cells = []
oy, ox = (H % CELL) // 2, (W % CELL) // 2
for y in range(oy, H - CELL + 1, CELL):
for x in range(ox, W - CELL + 1, CELL):
if openm[y:y + CELL, x:x + CELL].mean() >= OPEN_FRAC:
cells.append({"kind": "open", "cls": 1, "box": (y, y + CELL, x, x + CELL), "area": CELL * CELL})
if len(cells) > MAX_OPEN:
cells = [cells[i] for i in np.linspace(0, len(cells) - 1, MAX_OPEN).round().astype(int)]
comps = []
for c in (3, 4, 5, 6):
lab, n = ndimage.label(ids == c, structure=np.ones((3, 3)))
for k, sl in enumerate(ndimage.find_objects(lab), 1):
m = lab[sl] == k
if m.sum() < MIN_COMP:
continue
y0, y1, x0, x1 = sl[0].start, sl[0].stop, sl[1].start, sl[1].stop
py, px = max(12, (y1 - y0) // 4), max(12, (x1 - x0) // 4)
Y0, Y1, X0, X1 = max(0, y0 - py), min(H, y1 + py), max(0, x0 - px), min(W, x1 + px)
mm = np.zeros((Y1 - Y0, X1 - X0), bool)
mm[y0 - Y0:y1 - Y0, x0 - X0:x1 - X0] = m
comps.append({"kind": "obj", "cls": c, "box": (Y0, Y1, X0, X1), "area": int(m.sum()), "mask": mm})
comps = sorted(comps, key=lambda r: -r["area"])[:MAX_COMP]
return cells + comps
def crop_img(img, r, min_side=336):
y0, y1, x0, x1 = r["box"]
c = img.crop((x0, y0, x1, y1))
if r["kind"] == "obj": # 品红描出该块的语义轮廓(原帧 / 候选同一条线)
edge = r["mask"] & ~ndimage.binary_erosion(r["mask"], iterations=1)
edge = ndimage.binary_dilation(edge, iterations=1)
a = np.asarray(c).copy()
a[edge] = (255, 0, 255)
c = Image.fromarray(a)
s = max(1.0, min_side / max(1, min(c.size)))
return c.resize((int(c.width * s), int(c.height * s)), Image.LANCZOS)
def char_boxes(ids, pad=0.5, min_side=64):
H, W = ids.shape
out = {}
for role, parts in (("player", PLAYER), ("boss", BOSS)):
m = np.isin(ids, parts)
if m.sum() < 150:
continue
ys, xs = np.nonzero(m)
cy, cx = (ys.min() + ys.max()) / 2, (xs.min() + xs.max()) / 2
h = max(min_side, (ys.max() - ys.min() + 1) * (1 + pad))
w = max(min_side, (xs.max() - xs.min() + 1) * (1 + pad))
out[role] = (int(max(0, cy - h / 2)), int(min(H, cy + h / 2)), int(max(0, cx - w / 2)), int(min(W, cx + w / 2)))
return out
def char_items(src, img, boxes):
from vlm_gate import zoom
return [([zoom(src, b, 336), zoom(img, b, 336)], Q_CHAR.format(who=WHO[r])) for r, b in boxes.items()]
def region_items(img, regs):
return [([crop_img(img, r)], Q_OPEN if r["kind"] == "open" else Q_OBJ_T.format(what=Q_OBJ[r["cls"]])) for r in regs]
def region_score(regs, p_orig, p_cand):
out = {}
for kind in ("open", "obj"):
idx = [i for i, r in enumerate(regs) if r["kind"] == kind and p_orig[i] >= 0.5]
if not idx:
out[f"{kind}_ok"], out[f"{kind}_n"] = None, 0
continue
w = np.array([regs[i]["area"] if kind == "obj" else 1.0 for i in idx], float)
out[f"{kind}_ok"] = round(float((w * (np.asarray(p_cand)[idx] >= 0.5)).sum() / w.sum()), 4)
out[f"{kind}_n"] = len(idx)
return out
def orig_regions(vlm, tag, src, regs, boxes):
"""原帧的区域题 + 角色比对题(原帧对原帧)基线,缓存。"""
path = f"{G}/sources/{tag}/regions.json"
if os.path.exists(path):
out = json.load(open(path))
if "char_base" in out:
return out
p = []
else:
p = vlm.yes_prob(region_items(src, regs)) if regs else []
out = {"regions": [{k: ([int(x) for x in v] if k == "box" else v) for k, v in r.items() if k != "mask"} for r in regs],
"p_orig": [round(float(x), 4) for x in p]}
pc = vlm.yes_prob(char_items(src, src, boxes)) if boxes else []
out["char_base"] = {r: round(float(v), 4) for r, v in zip(boxes, pc)}
tmp = f"{path}.{os.getpid()}.tmp" # 多个分片同时算同一个源:原子写,免得读到半个文件
json.dump(out, open(tmp, "w"), indent=1)
os.replace(tmp, path)
return out
# ------------------------------------------------------------------ 跑 ----
def run(a):
from vlm_gate import QwenVL
files = sorted(f for f in glob.glob(f"{G}/cands/*/*.json") if not f.endswith(".vlm.json"))
if a.only:
files = [f for f in files if re.search(a.only, f)]
files = [f for i, f in enumerate(files) if i % a.nshards == a.shard]
def todo(f):
v = f[:-5] + ".vlm.json"
return not os.path.exists(v) or "charcrop" not in json.load(open(v))
files = [f for f in files if todo(f)]
print(f"[gate] shard {a.shard}/{a.nshards}: {len(files)} 张", flush=True)
if not files:
return
vlm = QwenVL()
print(f"[gate] Qwen3-VL-32B 加载 {vlm.load_s:.0f}s", flush=True)
cache = {}
for f in files:
info = json.load(open(f))
tag = info["tag"]
if tag not in cache:
src = Image.open(f"{G}/sources/{tag}/f0.png").convert("RGB")
ids = np.load(f"{G}/sources/{tag}/ids0.npy")
regs, boxes = regions(ids), char_boxes(ids)
cache = {tag: (src, regs, boxes, orig_regions(vlm, tag, src, regs, boxes))}
src, regs, boxes, ro = cache[tag]
cand = Image.open(f[:-5] + ".png").convert("RGB")
vpath = f[:-5] + ".vlm.json"
t0 = time.time()
if os.path.exists(vpath): # 只补角色比对题
out = json.load(open(vpath))
else:
sc = SCENES[info["scene"]]
glob_it = [(q, [cand] if q == "scene" else [src, cand], Q[q].format(**sc)) for q in Q]
p = vlm.yes_prob([(ims, tx) for _, ims, tx in glob_it] + region_items(cand, regs))
res = {q: round(float(v), 4) for (q, _, _), v in zip(glob_it, p)}
pc = [round(float(x), 4) for x in p[len(glob_it):]]
out = {"p_yes": res, "region": region_score(regs, ro["p_orig"], pc), "p_regions": pc}
pch = vlm.yes_prob(char_items(src, cand, boxes)) if boxes else []
out["charcrop"] = {r: round(float(v), 4) for r, v in zip(boxes, pch)}
out["sec"] = round(time.time() - t0, 2)
json.dump(out, open(vpath, "w"), indent=1)
print(f"[gate] {tag} {info['name']} {out['p_yes']} {out['region']} {out['charcrop']}", flush=True)
def auto_ok(info, o):
m = info["metrics"]
return {"edge": m["layout"].get("edge_recall", 0) >= GATE["edge_rel"] * o["layout"]["edge_recall"]}
def merge(a):
rows = []
for f in sorted(glob.glob(f"{G}/cands/*/*.json")):
if f.endswith(".vlm.json") or not os.path.exists(f[:-5] + ".vlm.json"):
continue
info = json.load(open(f))
o = json.load(open(f"{G}/sources/{info['tag']}/f0_metrics.json"))
vj = json.load(open(f[:-5] + ".vlm.json"))
if "region" not in vj: # 冒烟 v1 的旧格式(没有区域题)
continue
v, rg = vj["p_yes"], vj["region"]
base = json.load(open(f"{G}/sources/{info['tag']}/regions.json")).get("char_base", {})
why = auto_ok(info, o)
for q in ("layout", "only2", "scene"):
why[q] = v[q] >= GATE[q]
cc = vj.get("charcrop", {})
why["chars"] = all(cc.get(r, 0) >= GATE["chars"] for r, b in base.items() if b >= 0.5)
for k in ("open_ok", "obj_ok"):
why[k] = rg.get(k) is None or rg[k] >= GATE[k]
ro, ob = rg.get("open_ok"), rg.get("obj_ok")
rows.append({"tag": info["tag"], "name": info["name"], "scene": info["scene"], "gen": info["gen"], "seed": info["seed"],
"checks": why, "vlm": v, "region": rg, "charcrop": vj.get("charcrop"), "pass": all(why.values()),
"coarse_p": info["metrics"]["struct"]["coarse_edge_precision"],
"edge_rel": round(info["metrics"]["layout"].get("edge_recall", 0) / o["layout"]["edge_recall"], 4),
"score": round(v["layout"] + v["scene"] + min(list(vj.get("charcrop", {}).values()) or [1]) + (1 if ro is None else ro)
+ (1 if ob is None else ob), 4)})
picks = {}
for r in rows:
k = (r["tag"], r["scene"])
if k not in picks or (r["pass"], r["score"]) > (picks[k]["pass"], picks[k]["score"]):
picks[k] = r
sel = list(picks.values())
by_scene, by_gen, fails = {}, {}, {}
for r in sel:
by_scene.setdefault(r["scene"], []).append(r["pass"])
for r in rows:
by_gen.setdefault(r["gen"], []).append(r["pass"])
for k, ok in r["checks"].items():
fails[k] = fails.get(k, 0) + (not ok)
out = {"gate": GATE, "rows": rows, "picks": {f"{t}/{s}": r["name"] for (t, s), r in picks.items() if r["pass"]},
"failed_slots": sorted(f"{t}/{s}" for (t, s), r in picks.items() if not r["pass"]),
"pass_rate_cands_by_gen": {g: f"{sum(v)}/{len(v)}" for g, v in sorted(by_gen.items())},
"check_fail_counts": fails,
"slot_pass_by_scene": {s: f"{sum(v)}/{len(v)}" for s, v in sorted(by_scene.items())},
"picked_gen": {g: sum(1 for r in sel if r["pass"] and r["gen"] == g) for g in sorted(by_gen)}}
json.dump(out, open(f"{G}/gate.json", "w"), indent=1)
print(json.dumps({k: out[k] for k in ("pass_rate_cands_by_gen", "check_fail_counts", "slot_pass_by_scene", "picked_gen")},
indent=1))
print("slots pass", len(out["picks"]), "/", len(sel))
if a.jobs:
jobs = []
for s in json.load(open(f"{G}/sources.json")):
jobs.append({"tag": s["tag"], "variant": "orig", "kind": "orig", "f0": f"{G}/sources/{s['tag']}/f0.png"})
for sc in SCENES:
r = picks.get((s["tag"], sc))
if r and r["pass"]:
jobs.append({"tag": s["tag"], "variant": sc, "kind": "scene", "scene": sc, "gen": r["gen"],
"seed": r["seed"], "f0": f"{G}/cands/{s['tag']}/{r['name']}.png"})
json.dump(jobs, open(f"{G}/jobs.json", "w"), indent=1)
print(len(jobs), "个 rollout 任务(含 orig)")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--shard", type=int, default=0)
ap.add_argument("--nshards", type=int, default=1)
ap.add_argument("--only", default=None)
ap.add_argument("--merge", action="store_true")
ap.add_argument("--jobs", action="store_true", help="--merge 时顺便写 jobs.json")
a = ap.parse_args()
merge(a) if a.merge else run(a)
if __name__ == "__main__":
main()