#!/usr/bin/env python3 """teaser · 编辑 case「同一世界、不同游玩」(maliang venv;build 要 GPU,其余 CPU)。产物在 outputs/teaser/edit/app/sameworld/($S)。 两种组(GROUPS) ---------------- * union(任务原意):同一 run(同一游戏世界坐标)里相邻的几条 clip(不同轨迹 / 不同按键),世界坐标原样不动。用它们**足迹的并集** 搭一个 procmap 世界(procmap.World 的足迹 / 净空 / 视野按所有 clip 的全部帧算 —— 把各 clip 的 meta 沿帧轴拼起来,构造 World 时临时把 procmap.F 设成总帧数;主题 = themes_ext 的布局函数,原样调用),遮挡控制也对所有 clip 的全部帧做,然后每条 clip 用 自己的相机 + 胶囊各渲染一份 81 帧 ids ⇒「同一张地图、不同的按键 / 走位」。每条的帧 0 视角不同 ⇒ 首帧各自生成 (f0gen qwen21 默认配方,提示词 / 场景 / 种子一致)。 * start(加做):同一 run、同一相机档位(camera_variant)的几条 clip,按帧 0 玩家位置**平移**到同一个起点(相机矩阵右乘平移: 行向量约定 M' = T(−t)·M;胶囊 / actor_pos / ground_z 同步平移),Boss 去掉 ⇒ 帧 0 的相机完全重合、语义只差玩家姿势 ⇒ **一张首帧**(锚 clip 的首帧)+ 不同的按键 / 轨迹 ⇒ 同一个开局、不同的未来。世界同样按平移后足迹的并集搭。 每条 clip 的地图目录 $S/maps//_/ = procmap 标准文件(ids.npz / prompts.json / actions.npy / source.json / report.json / map0.png / map0_shaded.png / depth0.png / contact.jpg),f0gen 地图模式直接能用;组目录另有 plan.png(俯视总图: 所有 clip 的轨迹各一种颜色 + 帧 0 视野框)与 group.json。 子命令 ------ build [--groups g1 g2](GPU):搭世界 + 渲染每条 clip 的 ids。 f0jobs → $S/f0/jobs.jsonl(f0gen.py --backend qwen21 吃;union 组每条 clip 一张;start 组只出锚 clip 一张)。 f0edits → $S/f0/edits.jsonl(edit_app_f0.py 吃):start 组锚首帧的整图外观编辑(START_LOOKS = night / snow) ⇒ 同一开局 × 外观 × 按键(rolljobs 自动带上:名字后缀 __night / __snow)。 rolljobs → $S/jobs.jsonl(rollout.py 吃;结果 $S/roll/___/)。 compose → $A/showcase/sameworld_.mp4(左 = 俯视总图 + 当前帧各 clip 的位置,右 = 各 clip 的生成视频宫格 + 按键 HUD) + $A/showcase/sheets/sameworld_.jpg。 usage(env 同 SPEC §0;在 $M 下): CUDA_VISIBLE_DEVICES=6 $PY scripts/teaser/edit_sameworld.py build $PY scripts/teaser/edit_sameworld.py f0jobs CUDA_VISIBLE_DEVICES=6 $M/envs/img/bin/python scripts/teaser/f0gen.py --backend qwen21 --jobs outputs/teaser/edit/app/sameworld/f0/jobs.jsonl $PY scripts/teaser/edit_sameworld.py rolljobs # 然后 rollout.py --jobs outputs/teaser/edit/app/sameworld/jobs.jsonl $PY scripts/teaser/edit_sameworld.py compose """ from __future__ import annotations import argparse import json import os import sys import time import numpy as np M = "/opt/dlami/nvme/zhiyangdeng/maliang" HERE = os.path.dirname(os.path.abspath(__file__)) for _p in (f"{M}/code/gc25d", f"{M}/code/arope", HERE): if _p not in sys.path: sys.path.insert(0, _p) A = f"{M}/outputs/teaser/edit/app" S = f"{A}/sameworld" F = 81 COLORS = [(255, 64, 200), (40, 220, 255), (255, 200, 40), (120, 255, 90), (255, 110, 60), (170, 120, 255)] # RGB GROUPS = { # 选组:outputs/teaser/edit/app/sameworld/scan.json(全部 clip 的帧 0 视野中心 / 位移 / Boss 可见率)。第一版用过 R10_s8506 / # P9C_default_s1944(帧 0 视野宽只有 9–11 m,画面里几乎只有一种地面,看不出「同一张地图」)⇒ 换成视野 14–27 m 的组。 # 帧 0 视野重叠的 3 条(同一片地方出发、往不同方向;视野 14 m),有 Boss;v10 透视 "shrine_R10_s5510": dict(mode="union", run="R10_s5510", scene_src="05_Japanese_Spring_Sanctuary", starts=[3840, 3936, 4128], theme="graveyard", seed=5, f0_scene="greek@photoreal"), # 帧 0 视野重叠的 3 条(视野 23 m),Boss 可见率 0.71 "temple_R10_s6507": dict(mode="union", run="R10_s6507", scene_src="06_Overgrown_Tropical_Water_Temple", starts=[2304, 2688, 2784], theme="maze", seed=7, f0_scene="tibetan@photoreal"), # 同一 run 的 4 条,往 4 个方向(−123° / 60° / 148° / −20°;视野 27 m),无 Boss "court_R10_s1512": dict(mode="union", run="R10_s1512", scene_src="01_Sunlit_Mediterranean_Courtyard", starts=[1824, 2112, 2208, 2400], theme="orchard", seed=3, f0_scene="farm@photoreal"), # 同一组 clip 平移到同一起点 ⇒ 一张首帧、4 种按键 "court_R10_s1512_start": dict(mode="start", run="R10_s1512", scene_src="01_Sunlit_Mediterranean_Courtyard", starts=[1824, 2112, 2208, 2400], theme="orchard", seed=3, f0_scene="farm@photoreal"), # R10_s6507 的 4 条,往 4 个方向(159° / −16° / −121° / 61°;视野 24 m),平移到同一起点 "temple_R10_s6507_start": dict(mode="start", run="R10_s6507", scene_src="06_Overgrown_Tropical_Water_Temple", starts=[2304, 2496, 2880, 2976], theme="maze", seed=7, f0_scene="tibetan@photoreal"), } def clip_id(g: dict, start: int) -> str: return f"{g['scene_src']}__{g['run']}__{start:06d}" def translate_meta(meta: dict, t: np.ndarray) -> dict: """世界整体平移 t(cm,xyz):相机矩阵 M' = T(−t)·M(行向量 [X,1]·M),胶囊端点 / actor_pos / ground_z 加 t。""" T = np.eye(4) T[3, :3] = -t out = dict(meta) for k in ("world_to_clip", "world_to_view"): m = np.asarray(meta[k], np.float64).reshape(-1, 4, 4) out[k] = np.einsum("ij,fjk->fik", T, m).reshape(-1, 16) caps = np.array(meta["capsules"], np.float64) caps[..., 0:3] += t caps[..., 3:6] += t out["capsules"] = caps.astype(np.float32) out["actor_pos"] = (np.asarray(meta["actor_pos"], np.float64) + t).astype(np.float32) out["ground_z"] = (np.asarray(meta["ground_z"], np.float64) + t[2]).astype(np.float32) return out def drop_boss(meta: dict) -> dict: out = dict(meta) caps = np.array(meta["capsules"], np.float32) caps[:, 1] = np.nan out["capsules"] = caps ap = np.array(meta["actor_pos"], np.float32) ap[:, 1] = np.nan out["actor_pos"] = ap return out def plan_image(w, srcs: list[dict], dev, max_px: int = 1100): """俯视总图(procmap.plan 的画法)+ 每条 clip 的轨迹(各一色)+ 帧 0 视野框;返回 (BGR 图, 世界→图像素的函数)。""" import cv2 import procmap as PM res = max(w.nx, w.ny) * PM.GRID_CM / max_px nx, ny = int(w.nx * PM.GRID_CM / res), int(w.ny * PM.GRID_CM / res) yy, xx = np.mgrid[0:ny, 0:nx] xy = np.stack([w.x0 + (xx.ravel() + 0.5) * res, w.y0 + (yy.ravel() + 0.5) * res], 1) ro = np.c_[xy, np.full(len(xy), w.z0 + 5000.0)] rd = np.tile(np.array([3e-4, 2e-4, -1.0]) / np.linalg.norm([3e-4, 2e-4, -1.0]), (len(xy), 1)) prims = w.prims() t, k = PM.Caster(prims, dev).cast(ro, rd) cls = w.ground.classify(xy, dev) hit = k >= 0 cls[hit] = [prims[i]["cls"] for i in k[hit]] hgt = np.where(hit, 5000.0 - t, 0) import geometry as GEO img = GEO.PALETTE[cls].astype(np.float32) * (0.75 + 0.25 * np.clip(hgt / 800, 0, 1))[:, None] img = img.reshape(ny, nx, 3).clip(0, 255).astype(np.uint8)[..., ::-1].copy() sc = PM.GRID_CM / res def q(P): return ((w.px(np.asarray(P, float)) + 0.5) * sc).astype(np.float32) return img, q, res def build_group(name: str, g: dict, dev: str = "cuda") -> dict: import cv2 import procmap as PM import themes_ext # noqa: F401 注册主题 import geometry as GEO import model as GM t0 = time.time() gd = f"{S}/maps/{name}" os.makedirs(gd, exist_ok=True) srcs = [PM.load_source(clip_id(g, s)) for s in g["starts"]] if g["mode"] == "start": m0 = srcs[0]["meta"] p0 = np.r_[m0["actor_pos"][0, 0, :2], m0["ground_z"][0]].astype(np.float64) for s in srcs: m = s["meta"] t = p0 - np.r_[m["actor_pos"][0, 0, :2], m["ground_z"][0]].astype(np.float64) s["shift_cm"] = t.tolist() s["meta"] = drop_boss(translate_meta(m, t)) d = np.abs(np.asarray(srcs[1]["meta"]["world_to_clip"][0]) - np.asarray(m0["world_to_clip"][0])).max() print(f"[{name}] 平移后帧 0 相机矩阵最大差 {d:.3g}", flush=True) keys = ("world_to_clip", "world_to_view", "capsules", "actor_pos", "ground_z") merged = dict(srcs[0]) merged["meta"] = {k: np.concatenate([np.asarray(s["meta"][k]) for s in srcs], 0) for k in keys} merged["meta"]["ortho"] = srcs[0]["meta"]["ortho"] n_tot = F * len(srcs) PM.F = n_tot # World 的足迹 / 视野 + 遮挡控制按全部 clip 的全部帧 try: w = PM.World(merged, g["seed"]) PM.THEME_FN[g["theme"]](w) cache = [PM.char_rays(merged["meta"], f) for f in range(n_tot)] occ = PM.control_occlusion(w, dev, cache) finally: PM.F = F print(f"[{name}] 世界 {w.nx * PM.GRID_CM / 100:.0f}×{w.ny * PM.GRID_CM / 100:.0f} m · {len(w.prims())} 图元 · " f"遮挡控制删 {len(occ['removed'])} 个 · {time.time() - t0:.0f}s", flush=True) plan, q, res = plan_image(w, srcs, dev) rows = [] for k, (s, start) in enumerate(zip(srcs, g["starts"])): w.src = s ids, keep = PM.render(w, dev) assert set(np.unique(ids).tolist()) <= GM.valid_raw_ids("parts") od = f"{gd}/{k}_{start:06d}" os.makedirs(od, exist_ok=True) coll = GM.collapse_parts(ids) ppx = [int((coll[f] == GEO.PLAYER_ID).sum()) for f in range(F)] np.savez_compressed(f"{od}/ids.npz", ids=ids) json.dump(s["prompts"], open(f"{od}/prompts.json", "w"), indent=1) np.save(f"{od}/actions.npy", s["blocks"][1:]) row = s["row"] json.dump(dict(clip_id=s["clip_id"], subset=row["subset"], scene=row["scene"], run=row["run"], start=row["start"], theme=g["theme"], theme_desc=PM.THEME_DESC[g["theme"]], seed=g["seed"], ortho=s["meta"]["ortho"], z_ground_cm=w.z0, group=name, group_mode=g["mode"], group_clips=[clip_id(g, x) for x in g["starts"]], shift_cm=s.get("shift_cm"), boss_dropped=g["mode"] == "start", actions_block=s["blocks"].tolist(), world_origin_cm=[float(w.x0), float(w.y0)], generator="scripts/teaser/edit_sameworld.py"), open(f"{od}/source.json", "w"), indent=1) rep = dict(case=f"{name}/{k}_{start:06d}", clip_id=s["clip_id"], player_px_f0=ppx[0], player_px_min=min(ppx), boss_px_f0=int((coll[0] == GEO.BOSS_ID).sum()), n_prims=len(w.prims()), occlusion_control=occ, frame0={c: round(float((coll[0] == i).mean()), 4) for c, i in (("terrain", 1), ("paving", 2), ("architecture", 3), ("rock", 4), ("vegetation", 5), ("prop", 6), ("sky", 0))}, checks_ok=bool(ppx[0] >= 500), sec=round(time.time() - t0, 1)) json.dump(rep, open(f"{od}/report.json", "w"), indent=1) cv2.imwrite(f"{od}/map0.png", PM.frame_rgb(w, ids, 0)[..., ::-1]) cv2.imwrite(f"{od}/map0_shaded.png", PM.shaded0(w, ids, keep[0])[..., ::-1]) cv2.imwrite(f"{od}/depth0.png", PM.depth0(w, ids, keep[0])) tiles = [PM.label(cv2.resize(PM.frame_rgb(w, ids, f), (PM.W // 2, PM.H // 2), interpolation=cv2.INTER_AREA), f"f{f}") for f in (0, 20, 40, 60, 80)] cv2.imwrite(f"{od}/contact.jpg", np.concatenate(tiles, 1)[..., ::-1], [cv2.IMWRITE_JPEG_QUALITY, 90]) P = s["meta"]["actor_pos"][:F, 0, :2] P = P[np.isfinite(P).all(1)] foot0 = PM.frame_ground_poly(s["meta"]["world_to_clip"][0], s["meta"]["world_to_view"][0], w.z0) rows.append(dict(k=k, start=start, clip_id=s["clip_id"], dir=od, path_px=q(P).tolist(), foot0_px=q(foot0).tolist(), disp_cm=(P[-1] - P[0]).tolist(), player_px_f0=ppx[0], shift_cm=s.get("shift_cm"))) print(f"[{name}] clip {k} {s['clip_id']}: 帧 0 玩家 {ppx[0]} px · 最少 {min(ppx)} px", flush=True) for r in rows: # plan:帧 0 视野框 + 轨迹 col = COLORS[r["k"] % len(COLORS)][::-1] cv2.polylines(plan, [np.asarray(r["foot0_px"], np.float32).round().astype(np.int32)], True, col, 1, cv2.LINE_AA) for r in rows: col = COLORS[r["k"] % len(COLORS)][::-1] pp = np.asarray(r["path_px"], np.float32).round().astype(np.int32) cv2.polylines(plan, [pp], False, (0, 0, 0), 5, cv2.LINE_AA) cv2.polylines(plan, [pp], False, col, 3, cv2.LINE_AA) cv2.circle(plan, tuple(pp[0]), 6, col, -1, cv2.LINE_AA) cv2.circle(plan, tuple(pp[0]), 6, (0, 0, 0), 1, cv2.LINE_AA) cv2.imwrite(f"{gd}/plan.png", plan) info = dict(name=name, **g, plan=f"{gd}/plan.png", plan_res_cm_per_px=res, clips=rows, world_extent_m=[round(w.nx * PM.GRID_CM / 100, 1), round(w.ny * PM.GRID_CM / 100, 1)], n_prims=len(w.prims()), occlusion_control=occ, sec=round(time.time() - t0, 1)) json.dump(info, open(f"{gd}/group.json", "w"), indent=1) return info def cmd_build(a) -> None: for name in a.groups or list(GROUPS): build_group(name, GROUPS[name]) def cmd_f0jobs(a) -> None: os.makedirs(f"{S}/f0", exist_ok=True) rows = [] for name, g in GROUPS.items(): gj = f"{S}/maps/{name}/group.json" if not os.path.exists(gj): continue info = json.load(open(gj)) clips = info["clips"] if g["mode"] == "union" else info["clips"][:1] for c in clips: for seed in a.seeds: rows.append({"case": f"{name}__{c['k']}", "map_dir": c["dir"], "scene": g["f0_scene"], "seed": seed, "out": f"{S}/f0/{name}/{c['k']}_{c['start']:06d}/qwen21_s{seed}.png"}) with open(f"{S}/f0/jobs.jsonl", "w") as fh: for r in rows: fh.write(json.dumps(r) + "\n") print(f"{len(rows)} 个首帧任务 → {S}/f0/jobs.jsonl") START_LOOKS = ("night", "snow") # start 组的锚首帧再做外观编辑(edit_app_f0 整图编辑)⇒ 同一开局 × 外观 × 按键 def cmd_f0edits(a) -> None: """start 组锚 clip 的首帧 → edit_app_f0 的整图编辑任务(night / snow),写 $S/f0/edits.jsonl。""" import edit_app as E rows = [] for name, g in GROUPS.items(): gj = f"{S}/maps/{name}/group.json" if g["mode"] != "start" or not os.path.exists(gj): continue c = json.load(open(gj))["clips"][0] src = f"{S}/f0/{name}/{c['k']}_{c['start']:06d}/qwen21_s{a.seed}.png" if not os.path.exists(src): continue for v in START_LOOKS: rows.append({"out": src[:-4] + f"__{v}.png", "src": src, "ids": f"{c['dir']}/ids.npz", "kind": "global", "prompt": E.GLOBAL[v], "seed": E.SEED[v], "variant": v, "group": name}) with open(f"{S}/f0/edits.jsonl", "w") as fh: for r in rows: fh.write(json.dumps(r) + "\n") print(f"{len(rows)} 个锚首帧外观编辑任务 → {S}/f0/edits.jsonl") def cmd_rolljobs(a) -> None: rows = [] for name, g in GROUPS.items(): gj = f"{S}/maps/{name}/group.json" if not os.path.exists(gj): continue info = json.load(open(gj)) anchor = info["clips"][0] looks = [None] + (list(START_LOOKS) if g["mode"] == "start" else []) for c, look in [(c, lk) for lk in looks for c in info["clips"]]: fc = c if g["mode"] == "union" else anchor first = f"{S}/f0/{name}/{fc['k']}_{fc['start']:06d}/qwen21_s{a.seed}" + (f"__{look}" if look else "") + ".png" if not os.path.exists(first): continue ed = {"type": "sameworld", "variant": g["mode"], "group": name, "group_mode": g["mode"], "clip_index": c["k"], "source_clip": c["clip_id"], "group_clips": [x["clip_id"] for x in info["clips"]], "theme": g["theme"], "world_seed": g["seed"], "f0_scene": g["f0_scene"], "f0_seed": a.seed, "f0_backend": "qwen21 shp_afix_rs", "shift_cm": c.get("shift_cm"), "boss_dropped": g["mode"] == "start", "edit_frame": None, "map_dir": c["dir"], "actions": f"{c['dir']}/actions.npy", "plan": info["plan"], "look_edit": look, "label": f"Play {c['k'] + 1}" + (f" ({look})" if look else ""), "scene": g["f0_scene"], "map": g["theme"], "desc": ("same procmap world (union of the clips' footprints), each clip's own camera + capsules + keys" if g["mode"] == "union" else "clips translated to one common start (boss removed): same first frame, different keys")} if look: ed["first_edit"] = {"editor": "Qwen-Image-2.1-Turbo", "kind": "global", "variant": look, "src": first.replace(f"__{look}.png", ".png")} rows.append({"name": f"{name}__{c['k']}_{c['start']:06d}" + (f"__{look}" if look else ""), "first": first, "ids": f"{c['dir']}/ids.npz", "prompts": f"{c['dir']}/prompts.json", "out": f"{S}/roll", "seed": 0, "prio": 1, "scene": g["f0_scene"], "map": g["theme"], "label": ed["label"], "edit": ed}) with open(f"{S}/jobs.jsonl", "w") as fh: for r in rows: fh.write(json.dumps(r, ensure_ascii=False) + "\n") print(f"{len(rows)} 个 rollout 任务 → {S}/jobs.jsonl") def cmd_compose(a) -> None: import edit_compose as EC for name in a.groups or list(GROUPS): if os.path.exists(f"{S}/maps/{name}/group.json"): EC.compose_sameworld(name, a) if GROUPS[name]["mode"] == "start": EC.compose_sameworld_looks(name, START_LOOKS) def main(argv=None) -> None: ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) sp = ap.add_subparsers(dest="cmd", required=True) b = sp.add_parser("build") b.add_argument("--groups", nargs="*", default=None) f = sp.add_parser("f0jobs") f.add_argument("--seeds", type=int, nargs="+", default=[0]) e = sp.add_parser("f0edits") e.add_argument("--seed", type=int, default=0) r = sp.add_parser("rolljobs") r.add_argument("--seed", type=int, default=0, help="用哪个首帧种子") c = sp.add_parser("compose") c.add_argument("--groups", nargs="*", default=None) a = ap.parse_args(argv) {"build": cmd_build, "f0jobs": cmd_f0jobs, "f0edits": cmd_f0edits, "rolljobs": cmd_rolljobs, "compose": cmd_compose}[a.cmd](a) if __name__ == "__main__": main()