#!/usr/bin/env python3 """teaser · canvas(白纸作画)量产调度:长地图 × 模板(canvas_edits.TEMPLATES)× 首帧 → 编辑版语义 → DMD 任务(rollout_dmd, 共享队列 outputs/teaser/edit/canvas/jobs.jsonl)→ 结果目录 cases// + index.jsonl → 打分 → showcase。 每个 (长地图, 首帧) 的对照: orig 原版(完整世界,不编辑):live 已经跑过的直接复用(outputs/teaser/edit/live/roll/<地图>__<场景>__orig),没有的新跑; B0 白纸对照(帧 0 看不见的东西全删 + 1 s 时清空,之后什么都不画):白纸类编辑版(W / R / WV / RV / atk)逐笔效果的参照 (同首帧同种子同按键 ⇒ 第一笔之前逐帧一致,之后只差这一笔一笔)。 usage(env:source scripts/teaser/live_env.sh): $PY scripts/teaser/canvas_batch.py tl --gpus 3,4,5 --procs 12 [--only V] $PY scripts/teaser/canvas_batch.py jobs # 追加 / 重写 jobs.jsonl(workers 自己会读到新行) $PY scripts/teaser/canvas_batch.py status $PY scripts/teaser/canvas_batch.py cases --procs 16 # cases// + index.jsonl(mask.npz 在这一步算) $PY scripts/teaser/canvas_batch.py score --procs 24 # 逐事件效果分 → showcase/scores.json + score_summary.json """ from __future__ import annotations import argparse import json import os import shutil import subprocess import sys import time 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) CV = f"{M}/outputs/teaser/edit/canvas" LV = f"{M}/outputs/teaser/edit/live" F0 = f"{M}/outputs/teaser/f0" LF0 = f"{LV}/f0" LOG = f"{M}/outputs/teaser/logs/edit2" P8 = "P9C_default_s8944__001056" MAPS = { # 短名 → 长地图目录 "patchwork_p9c": f"{LV}/maps_v2/patchwork__{P8}__s10__L181", "pillarhall_p9c": f"{LV}/maps_v2/pillarhall__{P8}__s20006__L181", "mountainpass_p9c": f"{LV}/maps_v2/mountainpass__{P8}__s11003__L181", "farmland_p9c": f"{LV}/maps_v2/farmland__{P8}__s9013__L181", "patchwork_v10": f"{LV}/maps/patchwork__R10_s1512__001440__s31__L241", "orchard_v10": f"{LV}/maps/orchard__R10_s2505__001248__s37__L241", "village_v10": f"{LV}/maps/village__R10_s2505__001248__s37__L241", } SCENES = { # 短名 → [(场景, 首帧 png)];第一个 = live 已有原版的那个 "patchwork_p9c": [("icecave@photoreal", f"{F0}/patchwork__{P8}__s10/icecave@photoreal/qwen21_s0.png"), ("salt_flats@cartoon", f"{F0}/patchwork__{P8}__s10/salt_flats@cartoon/klein9b_s0.png"), ("honey_hive@photoreal", f"{F0}/patchwork__{P8}__s10/honey_hive@photoreal/qwen21_s0.png"), ("greek@voxel", f"{F0}/patchwork__{P8}__s10/greek@voxel/klein9b_s0.png")], "pillarhall_p9c": [("icecave@cartoon", f"{F0}/pillarhall__{P8}__s20006/icecave@cartoon/qwen21_s0.png")], "mountainpass_p9c": [("dunes@cartoon", f"{F0}/mountainpass__{P8}__s11003/dunes@cartoon/qwen21_s0.png")], "farmland_p9c": [("synthwave@cartoon", f"{F0}/farmland__{P8}__s9013/synthwave@cartoon/qwen21_s0.png")], "patchwork_v10": [("snow_forest@photoreal", f"{LF0}/patchwork__R10_s1512__001440__s31/snow_forest@photoreal/qwen21_s0.png"), ("alien@photoreal", f"{LF0}/patchwork__R10_s1512__001440__s31/alien@photoreal/qwen21_s1.png"), ("lava@photoreal", f"{LF0}/patchwork__R10_s1512__001440__s31/lava@photoreal/qwen21_s0.png")], "orchard_v10": [("icecave@photoreal", f"{LF0}/orchard__R10_s2505__001248__s37/icecave@photoreal/qwen21_s1.png"), ("alien@photoreal", f"{LF0}/orchard__R10_s2505__001248__s37/alien@photoreal/qwen21_s1.png")], "village_v10": [("snow_forest@photoreal", f"{LF0}/village__R10_s2505__001248__s37/snow_forest@photoreal/qwen21_s0.png"), ("dunes@photoreal", f"{LF0}/village__R10_s2505__001248__s37/dunes@photoreal/qwen21_s0.png")], } # (地图短名, 模板, blank, attack, 场景下标表) PLAN = [ # 1 冒烟:植被地面(不白纸,对照 = 原版) ("patchwork_p9c", "V", False, False, [0]), ("pillarhall_p9c", "V", False, False, [0]), ("patchwork_v10", "V", False, False, [0]), # 白纸对照 ("patchwork_p9c", "B0", True, False, [0, 1, 2, 3]), ("patchwork_v10", "B0", True, False, [0, 1, 2]), ("pillarhall_p9c", "B0", True, False, [0]), ("mountainpass_p9c", "B0", True, False, [0]), ("orchard_v10", "B0", True, False, [0]), ("village_v10", "B0", True, False, [0]), # 2 白纸 → 世界;3 同一时间线、多个世界(patchwork_p9c × 4、patchwork_v10 × 3) ("patchwork_p9c", "W", True, False, [0, 1, 2, 3]), ("patchwork_v10", "W", True, False, [0, 1, 2]), ("pillarhall_p9c", "W", True, False, [0]), ("mountainpass_p9c", "W", True, False, [0]), ("orchard_v10", "W", True, False, [0]), ("village_v10", "W", True, False, [0]), # 擦掉重画 ("patchwork_p9c", "R", True, False, [0, 1]), ("patchwork_v10", "R", True, False, [0, 1]), ("village_v10", "R", True, False, [0]), ("mountainpass_p9c", "R", True, False, [0]), # 4 攻击键建造(白纸 + 每次攻击长出一样东西) ("orchard_v10", "none", True, True, [0]), ("village_v10", "none", True, True, [0]), ] EXTRA = [] # 冒烟之后追加(WV / RV / 新长地图的攻击版),见 canvas_plan2.json if os.path.exists(f"{CV}/canvas_plan2.json"): _p2 = json.load(open(f"{CV}/canvas_plan2.json")) for k, v in _p2.get("maps", {}).items(): MAPS[k] = v for k, v in _p2.get("scenes", {}).items(): SCENES[k] = [tuple(x) for x in v] EXTRA = [tuple(x) for x in _p2.get("plan", [])] def tl_name(mshort, tl, blank, attack): return f"{mshort}__{tl}{'_blank' if blank and tl not in ('B0',) else ''}{'_atk' if attack else ''}" def tag_of(tl, blank, attack): return ("atk" if attack else tl) if tl != "none" else "atk" def roll_name(mshort, scene, tl, blank, attack): return f"{mshort}__{scene}__{tag_of(tl, blank, attack)}" def env(): e = dict(os.environ) e.update(DIFFSYNTH_SKIP_DOWNLOAD="True", PYTHONPATH=f"{M}/code/gc25d:{M}/code/arope:{HERE}", HF_HOME="/opt/dlami/nvme/zhiyangdeng/.cache/huggingface", TMPDIR=f"{M}/.tmp", HF_HUB_OFFLINE="1", PYTHONUNBUFFERED="1", OMP_NUM_THREADS="6", MKL_NUM_THREADS="6", OPENBLAS_NUM_THREADS="6") return e def run_pool(cmds, gpus, procs, tag): os.makedirs(LOG, exist_ok=True) running, k = [], 0 todo = list(cmds) while todo or running: while todo and len(running) < procs: name, argv = todo.pop(0) e = env() e["CUDA_VISIBLE_DEVICES"] = gpus[k % len(gpus)] k += 1 lf = open(os.path.join(LOG, f"canvas_{tag}_{name}.log"), "w") running.append((name, subprocess.Popen(argv, env=e, stdout=lf, stderr=subprocess.STDOUT), time.time())) print(f"[start] {name}", flush=True) for r in list(running): if r[1].poll() is not None: running.remove(r) print(f"[done] {r[0]} rc={r[1].returncode} {time.time() - r[2]:.0f}s", flush=True) time.sleep(2) def all_plan(): return PLAN + EXTRA def cmd_tl(a): cmds, seen = [], set() for mshort, tl, blank, attack, _ in all_plan(): if a.only and tl not in a.only.split(","): continue nm = tl_name(mshort, tl, blank, attack) if nm in seen: continue seen.add(nm) if os.path.exists(f"{CV}/tl/{nm}/events.json") and not a.redo: continue argv = [sys.executable, f"{HERE}/canvas_edits.py", "run", "--map", MAPS[mshort], "--tl", tl, "--name", nm] if blank: argv.append("--blank") if attack: argv.append("--attack") cmds.append((nm, argv)) print(f"{len(cmds)} 条时间线要渲染") run_pool(cmds, a.gpus.split(","), a.procs, "tl") def live_orig(mdir, scene): lname = os.path.basename(mdir) d = f"{LV}/roll/{lname}__{scene}__orig" return d if os.path.exists(f"{d}/meta.json") else None def orig_dir(mshort, scene): d = live_orig(MAPS[mshort], scene) return d or f"{CV}/roll/{mshort}__{scene}__orig" def cmd_jobs(a): rows, seen = [], set() for mshort, tl, blank, attack, sidx in all_plan(): md = MAPS[mshort] nm = tl_name(mshort, tl, blank, attack) td = f"{CV}/tl/{nm}" for si in sidx: scene, png = SCENES[mshort][si] if not os.path.exists(png): print(f"[skip] 没有首帧 {png}") continue common = dict(first=png, prompts=f"{md}/prompts.json", actions=f"{md}/actions.npy", out=f"{CV}/roll", seed=0, labels=dict(scene=scene, map=mshort, long_map=os.path.basename(md))) on = f"{mshort}__{scene}__orig" if on not in seen and live_orig(md, scene) is None: seen.add(on) rows.append(dict(name=on, ids=f"{md}/ids.npz", prio=2, meta=dict(edit="none", long_map=md, scene=scene), **common)) if not os.path.exists(f"{td}/events.json"): continue ev = json.load(open(f"{td}/events.json")) rn = roll_name(mshort, scene, tl, blank, attack) sf = sorted({min(max(e["f"] + d, 0), ev["n_frames"] - 1) for e in ev["events"] for d in (-1, 24)}) pr = 3 if tl == "V" else (1.5 if tl == "B0" else 1) rows.append(dict(name=rn, ids=f"{td}/ids.npz", prio=pr, sheet_frames=sf[:16] or None, meta=dict(edit="canvas", timeline=tl, blank=blank, attack=attack, tl_dir=td, long_map=md, scene=scene, events=ev["events"], f_first_edit=ev["f_first_edit"]), **common)) out = f"{CV}/jobs.jsonl" tmp = out + ".tmp" with open(tmp, "w") as fh: for r in rows: fh.write(json.dumps(r, ensure_ascii=False, default=float) + "\n") os.replace(tmp, out) print(f"→ {out}:{len(rows)} 个任务") def cmd_status(a): subprocess.run([sys.executable, f"{HERE}/rollout_dmd.py", "--jobs", f"{CV}/jobs.jsonl", "--status"], env=env()) # ---------------------------------------------------------------------------- # 结果目录 # ---------------------------------------------------------------------------- def pairs(): """→ [(edited roll dir, orig dir, blank-control dir 或 None, mshort, scene, tl, blank, attack)]。""" out = [] for mshort, tl, blank, attack, sidx in all_plan(): for si in sidx: scene, _ = SCENES[mshort][si] ed = f"{CV}/roll/{roll_name(mshort, scene, tl, blank, attack)}" ctl = f"{CV}/roll/{mshort}__{scene}__B0" if blank and tl != "B0" else None out.append((ed, orig_dir(mshort, scene), ctl, mshort, scene, tl, blank, attack)) return out def link(src, dst): if os.path.exists(dst): os.remove(dst) try: os.link(src, dst) except OSError: shutil.copyfile(src, dst) EDIT_DESC = { "V": "veg ground smoke: paint a green path, write MALIANG / 马良 in grass, grow a lawn (world otherwise unchanged)", "B0": "blank-canvas control: objects not visible at frame 0 removed, visible ones erased at 0.75 s, nothing drawn afterwards", "W": "blank canvas → world: clear, then houses / trees / towers / wall / stone circle / MALIANG stone letters added step by step", "R": "erase and redraw: blank canvas → village (houses, wall, tower) → erase everything → forest + stone circle + obelisk + MALIANG", "WV": "blank canvas → world with vegetation ground painting (green path, MALIANG / 马良 in grass)", "RV": "erase and redraw with vegetation ground painting", "atk": "attack-triggered building on a blank canvas: every attack press spawns an object in front of the player", "WF": "blank canvas → world with grass painting, then a finale burst (one stroke every 1.5 s from 30 s) so the last frames are crowded", } def _case_one(args): import numpy as np ed, od, ctl, mshort, scene, tl, blank, attack = args name = os.path.basename(ed) cd = f"{CV}/cases/{name}" if not os.path.exists(f"{ed}/meta.json") or not os.path.exists(f"{od}/meta.json"): return None os.makedirs(cd, exist_ok=True) m = json.load(open(f"{ed}/meta.json")) for fn in ("gen.mp4", "ids.npz", "first.png", "actions.npy", "prompts.json", "sheet.jpg"): if os.path.exists(f"{ed}/{fn}"): link(f"{ed}/{fn}", f"{cd}/{fn}") ids = np.load(f"{ed}/ids.npz")["ids"] ids_o = np.load(f"{od}/ids.npz")["ids"] mk = dict(mask=ids != ids_o) if ctl and os.path.exists(f"{ctl}/ids.npz"): mk["mask_brush"] = ids != np.load(f"{ctl}/ids.npz")["ids"] np.savez_compressed(f"{cd}/mask.npz", **mk) evs = (m.get("meta") or {}).get("events") or [] tag = tag_of(tl, blank, attack) edit = dict(edit_type=f"canvas_{tag}", description=EDIT_DESC.get(tag, tag), timeline=tl, blank_canvas=blank, attack_trigger=attack, trigger="attack key onsets (cell k attack=1, k-1=0; held ⇒ every 3 cells)" if attack else "timeline (fixed frames)", n_events=len(evs), frames=[e["f"] for e in evs], events=[dict(type=e["type"], frame=e["f"], t_sec=e["t_sec"], cell=e["cell"], label=e["label"], label_zh=e["label_zh"], params={k: v for k, v in e.items() if k not in ("items", "type", "f", "t_sec", "cell", "label", "label_zh", "names")}) for e in evs], base_source=dict(long_map=(m.get("meta") or {}).get("long_map"), tl_dir=(m.get("meta") or {}).get("tl_dir"), first_frame=m["job"]["first"]), paired_original=od, paired_original_case=f"{CV}/cases/{os.path.basename(od)}", blank_control=ctl, mask_keys=dict(mask="ids != paired original ids", mask_brush="ids != blank-control ids (brush strokes only)" if "mask_brush" in mk else None)) json.dump(edit, open(f"{cd}/edit.json", "w"), indent=1, ensure_ascii=False, default=float) meta = dict(name=name, kind="canvas_edit", model="gc25d v10 causal DMD student (stage3 EMA step 500), 4 steps/cell, sink 3 / window 17", ckpt=m["ckpt"], seed=m["seed"], fps=16, n_frames=m["n_frames"], cells=m["cells"], res="832x480", decode=m["decode"], scene=scene, map=mshort, timing=m["timing"], actions="actions.npy uint8 [N,5] = cell 1..N keys (w,a,s,d,attack); cell k = frames 4k-3..4k", prompts="prompts.json N+1 per-cell strings (cell 0 = cell 1)", roll_dir=ed) json.dump(meta, open(f"{cd}/meta.json", "w"), indent=1, ensure_ascii=False, default=float) # 配对原版 / 白纸对照也放成 case(硬链接) for src, kind in ((od, "original"), (ctl, "blank_control")): if not src or not os.path.exists(f"{src}/meta.json"): continue sd = f"{CV}/cases/{os.path.basename(src)}" os.makedirs(sd, exist_ok=True) for fn in ("gen.mp4", "ids.npz", "first.png", "actions.npy", "prompts.json", "sheet.jpg"): if os.path.exists(f"{src}/{fn}") and not os.path.exists(f"{sd}/{fn}"): link(f"{src}/{fn}", f"{sd}/{fn}") mm = json.load(open(f"{src}/meta.json")) json.dump(dict(edit_type="none" if kind == "original" else "canvas_B0", role=kind, source=src, description="unedited long-map rollout (full world)" if kind == "original" else EDIT_DESC["B0"]), open(f"{sd}/edit.json", "w"), indent=1) json.dump(dict(name=os.path.basename(src), kind=f"canvas_{kind}", ckpt=mm["ckpt"], seed=mm["seed"], fps=16, n_frames=mm["n_frames"], cells=mm["cells"], scene=scene, map=mshort, roll_dir=src), open(f"{sd}/meta.json", "w"), indent=1) return dict(name=name, case_dir=cd, kind=f"canvas_{tag}", map=mshort, scene=scene, timeline=tl, blank=blank, attack=attack, n_frames=m["n_frames"], sec=round(m["n_frames"] / 16, 1), n_events=len(evs), events=" → ".join(f"{e['t_sec']:.0f}s {e['label']}" for e in evs), paired_original=os.path.basename(od), blank_control=os.path.basename(ctl) if ctl else None) def cmd_cases(a): from multiprocessing import Pool todo = [p for p in pairs() if os.path.exists(f"{p[0]}/meta.json")] with Pool(a.procs) as pool: rows = [r for r in pool.map(_case_one, todo) if r] sc = {} if os.path.exists(f"{CV}/showcase/scores.json"): sc = json.load(open(f"{CV}/showcase/scores.json")) seen = set() with open(f"{CV}/index.jsonl", "w") as fh: for r in rows: s = sc.get(r["name"]) if isinstance(s, list): v = [x.get("resp@12") for x in s if x.get("resp@12") is not None and x["type"] != "clear"] r["effect_mean@12"] = round(sum(v) / len(v), 1) if v else None fh.write(json.dumps(r, ensure_ascii=False) + "\n") seen.add(r["name"]) for r in rows: for k in ("paired_original", "blank_control"): n = r.get(k) if n and n not in seen: seen.add(n) fh.write(json.dumps(dict(name=n, case_dir=f"{CV}/cases/{n}", kind="canvas_original" if k == "paired_original" else "canvas_B0_control", map=r["map"], scene=r["scene"], n_frames=r["n_frames"]), ensure_ascii=False) + "\n") print(f"{len(rows)} 条编辑版 → {CV}/cases + index.jsonl(共 {len(seen)} 行)") def self_erase_scores(ed, ev, offs=(12, 48)): """清空事件的「自身前后」分:R = 帧 f−1 是物体(3..6)、帧 f+o 变成地面(1 / 2)的像素;resp = R 内 f−1 → f+o 的色差 − 其余 语义没变处的色差(白纸对照里也清空了、原版又是另一个世界时用)。""" import cv2 import numpy as np import compose as CP ids = np.load(os.path.join(ed, "ids.npz"))["ids"] f0 = max(ev["f"] - 1, 0) want = {f0} | {min(ev["f"] + o, len(ids) - 1) for o in offs} fr = {} for k, x in enumerate(CP.iter_video(os.path.join(ed, "gen.mp4"))): if k in want: fr[k] = x.astype(np.int16) if k > max(want): break r = dict(type=ev["type"], f=ev["f"], label=ev["label"], metric="self") for o in offs: f = min(ev["f"] + o, len(ids) - 1) R = np.isin(ids[f0], (3, 4, 5, 6)) & np.isin(ids[f], (1, 2)) R = cv2.erode(R.astype(np.uint8), np.ones((5, 5), np.uint8)) > 0 d = np.abs(fr[f] - fr[f0]).mean(-1) out_m = ~cv2.dilate(R.astype(np.uint8), np.ones((15, 15), np.uint8)).astype(bool) & (ids[f] == ids[f0]) r[f"frac@{o}"] = round(float(R.mean()), 4) r[f"resp@{o}"] = round(float(d[R].mean() - d[out_m].mean()), 2) if R.sum() > 200 and out_m.any() else None return r def _score_one(args): import live_compose as LC ed, od, ctl, mshort, scene, tl, blank, attack = args ref = ctl if ctl and os.path.exists(f"{ctl}/meta.json") else od try: evs = [e for e in LC.load_events(ed)[0]] main = [e for e in evs if e["type"] != "clear"] sc = {(r["f"], r["type"]): r for r in (LC.event_scores(ref, ed, main) if main else [])} for e in evs: if e["type"] != "clear": continue if e["f"] <= 24: # 开头的清空:对原版(原版里这些东西还在) r = LC.event_scores(od, ed, [e])[0] r["metric"] = "vs_orig" else: r = self_erase_scores(ed, e) sc[(e["f"], e["type"])] = r out = [sc[(e["f"], e["type"])] for e in evs] return os.path.basename(ed), dict(ref=os.path.basename(ref), events=out) except Exception as e: # noqa: BLE001 import traceback return os.path.basename(ed), traceback.format_exc() def cmd_score(a): """逐事件效果分(live_compose.event_scores:R = 帧 f+o 编辑版 / 参照语义不同、帧 f−1 相同的像素;resp = R 内两版色差 − R 外色差)。 参照:白纸类 = 白纸对照 B0(只差这一笔一笔),其余 = 原版。""" from multiprocessing import Pool import numpy as np todo = [p for p in pairs() if os.path.exists(f"{p[0]}/meta.json") and p[5] != "B0"] with Pool(a.procs) as pool: res = dict(pool.map(_score_one, todo)) os.makedirs(f"{CV}/showcase", exist_ok=True) flat = {k: (v["events"] if isinstance(v, dict) else v) for k, v in res.items()} json.dump(res, open(f"{CV}/showcase/scores_full.json", "w"), indent=1, ensure_ascii=False) json.dump(flat, open(f"{CV}/showcase/scores.json", "w"), indent=1, ensure_ascii=False) by = {} for k, evs in flat.items(): if isinstance(evs, str): continue for r in evs: key = r["type"] if r["type"] in ("text3d", "vtext"): key += " (cjk)" if "马良" in r["label"] else " (latin)" if r["type"] == "spawn": key += f" ({r['label'].split('→')[-1].strip()})" by.setdefault(key, []).append((r.get("resp@12"), r.get("resp@48"))) summ = {} for k, v in sorted(by.items()): v12 = [x for x, _ in v if x is not None] v48 = [y for _, y in v if y is not None] summ[k] = dict(n=len(v), n_scored=len(v12), median12=round(float(np.median(v12)), 1) if v12 else None, eff_rate12=round(float(np.mean(np.array(v12) >= 15)), 2) if v12 else None, median48=round(float(np.median(v48)), 1) if v48 else None, eff_rate48=round(float(np.mean(np.array(v48) >= 15)), 2) if v48 else None) rank = sorted(((float(np.mean([r["resp@12"] or 0 for r in evs if r["type"] != "clear"] or [0])), k) for k, evs in flat.items() if not isinstance(evs, str)), reverse=True) json.dump(dict(per_type=summ, rank=rank), open(f"{CV}/showcase/score_summary.json", "w"), indent=1, ensure_ascii=False) print(json.dumps(summ, ensure_ascii=False, indent=1)) for s_, k in rank: print(f"{s_:6.1f} {k}") def main(argv=None): ap = argparse.ArgumentParser() ap.add_argument("cmd", choices=["tl", "jobs", "status", "cases", "score"]) ap.add_argument("--gpus", default="3,4,5") ap.add_argument("--procs", type=int, default=12) ap.add_argument("--only", default=None) ap.add_argument("--redo", action="store_true") a = ap.parse_args(argv) dict(tl=cmd_tl, jobs=cmd_jobs, status=cmd_status, cases=cmd_cases, score=cmd_score)[a.cmd](a) if __name__ == "__main__": main()