Download code/gc25d/sample_user.py from teawhite/maliang: direct link, hf CLI and curl.
- Browser
- Download file 17 kB
-
https://huggingface.co/teawhite/maliang/resolve/main/code/gc25d/sample_user.py
- Command line
-
hf download hf://teawhite/maliang/code/gc25d/sample_user.py
-
curl -L -o sample_user.py https://huggingface.co/teawhite/maliang/resolve/main/code/gc25d/sample_user.py
17 kB
| #!/usr/bin/env python3 | |
| """gc25d 离线采样 · 用户模式:自带首帧 + parts id 视频 + 逐 cell 文本 ⇒ 81 帧 W×H(无 GT,不出 PSNR)。泛化测试(genbench)用。 | |
| W×H = geometry.DST_W × DST_H(env GC_RES,默认 832×480);--ckpt 的 metadata | |
| "res"(缺失 ⇒ 832x480)必须等于 GC_RES,--export_clip 的 --data_root 同理。 | |
| 薄封装,复用 sample.py(load_dit / TextTable / model_ids / generate / PartsSideBySide)与 infer.py(load_image_rgb / | |
| parse_actions / encode_first_frame / decode_video / prompt_runs)。采样核心 = sample.generate,与 clip 模式是同一段代码: | |
| 同首帧 latent + 同 ids + 同 prompts + 同种子 ⇒ latents 逐位相同。首帧用 fp32 VAE 单帧编码再转 bf16(infer.encode_first_frame), | |
| 与 prep 存的 latent cell 0 逐位相同(verify_prep 抽查 cell0_bf16_exact_frac = 1.0),所以 clip 自己的帧 0 png 能逐位复现 clip 模式。 | |
| 注意顺序:先 sample.warmup(真实形状空跑一次 DiT 前向)再编码首帧 —— cuDNN attention 的图在首次调用时建,建图结果受之前 | |
| 跑过的 VAE 运算影响(不 warmup 时实测 latents 最大差 0.035),warmup 后与 clip 模式逐位相同(outputs/genbench/usermode_test)。 | |
| 输入 | |
| --first_frame png/jpg:W×H 原样;1280×704(源分辨率)→ geometry.resample_rgb;其它尺寸居中裁成 W:H 再 INTER_AREA。 | |
| --ids npz('ids' 键或唯一数组)/ npy,uint8 [81,H,W] parts raw id,或源分辨率 [81,704,1280](geometry.resample_ids)。 | |
| 取值必须 ⊆ model.valid_raw_ids(ckpt 词表)(ids 词表的 ckpt 先 collapse_parts,与 clip 模式相同),否则报错。 | |
| --prompts json:21 条串(cell 0..20,须都在文本表里);或 | |
| --actions "<spec>"(语法见 infer.py 模块文档,20 块,如 "D:10,A:10")/ [20,5] 的 .npy → text.cell_prompts。 | |
| --export_clip <clip_id>:只导出该 clip 的 帧 0(W×H + 源 1280×704 png)/ ids npz / prompts json / 动作块 npy 到 --out, | |
| 不加载模型(L2 重绘首帧、复现测试的素材)。 | |
| 输出(--out 目录,前缀 --name):<name>.mp4(generated | cond 色图,--thumb 在生成面板右上角嵌输入首帧缩略图)、 | |
| <name>_gen.mp4(纯生成)、<name>_sheet.jpg(帧 0/20/40/60/80)、<name>.json(设置 / 输入 / 条件类别占比 / 耗时)、 | |
| --save_latents ⇒ <name>_latents.pt(bf16 [1,48,21,H/16,W/16])。 | |
| CUDA_VISIBLE_DEVICES=5 .venv/bin/python code/gc25d/sample_user.py --ckpt outputs/sft_v10/step-8000.safetensors \ | |
| --first_frame f0.png --ids ids.npz --actions "D:10,A:10" --out outputs/genbench/x --name d10a10 --thumb | |
| .venv/bin/python code/gc25d/sample_user.py --export_clip <clip_id> --out outputs/genbench/x | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import struct | |
| import time | |
| import sample as S # noqa: E402 (先 import:设好 sys.path / 环境变量) | |
| import cv2 # noqa: E402 | |
| import numpy as np # noqa: E402 | |
| import torch # noqa: E402 | |
| from PIL import Image # noqa: E402 | |
| import geometry as GEO # noqa: E402 | |
| import infer as INF # noqa: E402 | |
| import model as GM # noqa: E402 | |
| import text as TXT # noqa: E402 | |
| import viz as VIZ # noqa: E402 | |
| F_CLIP, N_CELLS, H, W = S.F_CLIP, S.N_CELLS, GEO.DST_H, GEO.DST_W | |
| STATIC_NAMES = ("sky", "terrain", "paving", "architecture", "rock", "vegetation", "prop") | |
| CLASS_NAMES = {**dict(enumerate(STATIC_NAMES)), | |
| **{r: f"player_{n}" for r, n in zip(GM.PLAYER_PART_IDS, GM.PART_NAMES)}, | |
| **{r: f"boss_{n}" for r, n in zip(GM.BOSS_PART_IDS, GM.PART_NAMES)}, GM.UNKNOWN_ID: "unknown"} | |
| THUMB_W, THUMB_H = W // 4, H // 4 | |
| # ------------------------------------------------------------------ 输入 ---- | |
| def ckpt_vocab(path: str) -> str: | |
| """只读 safetensors 头(不碰 10 GB 数据)→ gc_sem_cond 的词表;加载模型前就能校验 ids。""" | |
| with open(path, "rb") as fh: | |
| hdr = json.loads(fh.read(struct.unpack("<Q", fh.read(8))[0])) | |
| v = GM.vocab_from_state(hdr) | |
| if v is None: | |
| raise ValueError(f"{path} 里没有 gc_sem_cond(不是 gc25d 导出?)") | |
| return v | |
| def load_first_frame(path: str) -> tuple[np.ndarray, dict]: | |
| with Image.open(path) as im: | |
| w, h = im.size | |
| mode = "as_is" if (h, w) == (H, W) else "resample_rgb" if (h, w) == (GEO.SRC_H, GEO.SRC_W) else "center_crop_area" | |
| return INF.load_image_rgb(path), {"path": os.path.abspath(path), "src_size": [w, h], "resize": mode} | |
| def load_ids(path: str, vocab: str) -> tuple[np.ndarray, dict]: | |
| """→ (模型词表下的 raw ids uint8 [81,H,W], info)。取值严格 ⊆ valid_raw_ids(vocab),不静默映射。""" | |
| if path.endswith(".npz"): | |
| with np.load(path, allow_pickle=False) as z: | |
| keys = list(z.keys()) | |
| if "ids" not in keys and len(keys) != 1: | |
| raise ValueError(f"{path}:npz 里没有 'ids' 键且不止一个数组:{keys}") | |
| arr = z["ids" if "ids" in keys else keys[0]] | |
| elif path.endswith(".npy"): | |
| arr = np.load(path, allow_pickle=False) | |
| else: | |
| raise ValueError(f"--ids 应为 .npz / .npy,得到 {path}") | |
| if arr.dtype != np.uint8: | |
| raise ValueError(f"{path}:ids 应为 uint8,得到 {arr.dtype}") | |
| info = {"path": os.path.abspath(path), "src_shape": list(arr.shape), "resampled": False} | |
| if arr.shape == (F_CLIP, GEO.SRC_H, GEO.SRC_W) and not GEO.IDENTITY: # 1280×704:源 = 训练分辨率,不重采样 | |
| arr, info["resampled"] = GEO.resample_ids(arr), True | |
| if arr.shape != (F_CLIP, H, W): | |
| raise ValueError(f"{path}:ids 应为 [81,{H},{W}](或源分辨率 [81,704,1280];{GEO.RES_ENV}={GEO.RES_TAG}),得到 {arr.shape}") | |
| if vocab == "ids": | |
| arr = GM.collapse_parts(arr) | |
| cnt = np.bincount(arr.ravel(), minlength=256) | |
| bad = {int(k): int(cnt[k]) for k in np.nonzero(cnt)[0] if int(k) not in GM.valid_raw_ids(vocab)} | |
| if bad: | |
| raise ValueError(f"{path}:ids 含 {vocab!r} 词表外的值 {{值: 像素数}} = {bad}" | |
| f"(允许 {sorted(GM.valid_raw_ids(vocab))};未知区域请写 255)") | |
| return np.ascontiguousarray(arr), info | |
| def load_prompts(args, tt: S.TextTable) -> tuple[list[str], np.ndarray | None, dict]: | |
| """--prompts json(21 条)或 --actions → (prompts, cell 顺序动作块 [21,5] 或 None, info)。""" | |
| if args.prompts: | |
| with open(args.prompts, encoding="utf-8") as fh: | |
| prompts = json.load(fh) | |
| if not (isinstance(prompts, list) and len(prompts) == N_CELLS and all(isinstance(p, str) for p in prompts)): | |
| raise ValueError(f"{args.prompts}:应为 {N_CELLS} 条字符串的 json 列表") | |
| blocks, info = None, {"source": "prompts_json", "path": os.path.abspath(args.prompts)} | |
| else: | |
| blocks20 = INF.parse_actions(args.actions) | |
| blocks, prompts = TXT.cell_blocks(blocks20), TXT.cell_prompts(blocks20) | |
| info = {"source": "actions", "spec": args.actions} | |
| missing = sorted({p for p in prompts if p not in tt.table}) | |
| if missing: | |
| raise KeyError(f"文本表里没有 {missing[:3]}(只能用 text.py 模板的 18 条 + NULL_PROMPT)") | |
| return prompts, blocks, info | |
| def cond_stats(ids: np.ndarray) -> dict: | |
| """条件(模型收到的)类别占比:全部帧 / 帧 0;UNKNOWN 逐帧占比的 min / mean / max。""" | |
| def frac(a): | |
| cnt = np.bincount(a.ravel(), minlength=256) | |
| return {CLASS_NAMES.get(int(k), str(int(k))): float(cnt[k]) / a.size for k in np.nonzero(cnt)[0]} | |
| unk = (ids == GM.UNKNOWN_ID).mean(axis=(1, 2)) | |
| return {"class_frac": frac(ids), "class_frac_frame0": frac(ids[0]), | |
| "unknown_frac_per_frame": {"min": float(unk.min()), "mean": float(unk.mean()), "max": float(unk.max())}} | |
| # ------------------------------------------------------------------ 导出 clip 素材 ---- | |
| def export_clip(args) -> dict: | |
| GEO.check_root_res(args.data_root, "--data_root") | |
| row = S.clip_index(args.data_root).get(args.export_clip) | |
| if row is None: | |
| raise KeyError(f"{args.export_clip} 不在 {args.data_root}/index/clips.jsonl") | |
| c = S.load_clip(args.data_root, args.export_clip) | |
| src0 = INF.read_raw_frames(row["scene"], row["run"], int(row["start"]), 1, "jpg", | |
| raw_root=os.path.join(args.raw_root, row["subset"]))[0] | |
| os.makedirs(args.out, exist_ok=True) | |
| p = {k: os.path.join(args.out, f"{args.export_clip}_{k}") for k in | |
| ("frame0.png", "frame0_src.png", "ids.npz", "prompts.json", "actions.npy")} | |
| for key, img in (("frame0.png", GEO.resample_rgb(src0)), ("frame0_src.png", src0)): | |
| assert cv2.imwrite(p[key], cv2.cvtColor(img, cv2.COLOR_RGB2BGR)), p[key] | |
| np.savez_compressed(p["ids.npz"], ids=c["ids"]) | |
| with open(p["prompts.json"], "w", encoding="utf-8") as fh: | |
| json.dump(c["prompts"], fh, ensure_ascii=False, indent=1) | |
| np.save(p["actions.npy"], c["blocks"][1:]) # [20,5],--actions 可直接吃 | |
| print(f"导出 {args.export_clip}(split={row['split']})→ " + ", ".join(os.path.basename(v) for v in p.values())) | |
| return p | |
| # ------------------------------------------------------------------ 可视化 ---- | |
| class UserSideBySide(S.PartsSideBySide): | |
| """generated | cond(parts 着色);thumb ≠ None ⇒ 生成面板右上角嵌输入首帧缩略图。""" | |
| def __init__(self, *a, thumb: np.ndarray | None = None, **kw): | |
| super().__init__(*a, **kw) | |
| self.thumb = thumb | |
| def frame(self, i: int) -> np.ndarray: | |
| out = super().frame(i) | |
| if self.thumb is not None: | |
| th, tw = self.thumb.shape[:2] | |
| x0, y0 = (self.gen_index + 1) * VIZ.PANEL_W - tw - 6, VIZ.HEADER_H + 6 | |
| out[y0:y0 + th, x0:x0 + tw] = self.thumb | |
| return out | |
| def make_thumb(img: np.ndarray) -> np.ndarray: | |
| t = cv2.resize(img, (THUMB_W, THUMB_H), interpolation=cv2.INTER_AREA) | |
| cv2.rectangle(t, (0, 0), (THUMB_W - 1, THUMB_H - 1), VIZ.ACCENT, 1) | |
| VIZ._put(t, "input frame 0", 4, THUMB_H - 6, VIZ.ACCENT, 0.4) | |
| return t | |
| # ------------------------------------------------------------------ CLI ---- | |
| def build_parser() -> argparse.ArgumentParser: | |
| ap = argparse.ArgumentParser(description="gc25d 离线采样 · 用户模式(自带首帧 / ids / 文本)") | |
| ap.add_argument("--ckpt", default=None) | |
| ap.add_argument("--base", action="store_true") | |
| ap.add_argument("--vocab", choices=tuple(GM.VOCABS), default="parts", help="只对 --base 有意义(--ckpt 按 ckpt 定)") | |
| ap.add_argument("--model_dir", default=S.MODEL_DIR) | |
| ap.add_argument("--data_root", default=S.DATA_ROOT) | |
| ap.add_argument("--raw_root", default=S.RAW_ROOT, help="只有 --export_clip 用") | |
| ap.add_argument("--text_table", default=None, help="默认 <data_root>/text_table_gc25d.pt") | |
| ap.add_argument("--first_frame", default=None) | |
| ap.add_argument("--ids", default=None) | |
| ap.add_argument("--prompts", default=None, help="json:21 条 cell prompt") | |
| ap.add_argument("--actions", default=None, help='动作脚本(如 "D:10,A:10")或 [20,5] .npy') | |
| ap.add_argument("--export_clip", default=None, help="只导出某 clip 的帧 0 / ids / prompts / 动作块,然后退出") | |
| ap.add_argument("--out", required=True, help="输出目录") | |
| ap.add_argument("--name", default=None, help="输出文件前缀(默认 = 首帧文件名)") | |
| ap.add_argument("--title", default=None, help="视频标题栏右侧文字(默认 = name)") | |
| ap.add_argument("--steps", type=int, default=50) | |
| ap.add_argument("--shift", type=float, default=5.0) | |
| ap.add_argument("--cfg", type=float, default=1.0, help="文本 CFG(负支 NULL_PROMPT × 21)") | |
| ap.add_argument("--sem_cfg", type=float, default=1.0, help="语义 CFG(负支 ids 全 255)") | |
| ap.add_argument("--sem_mode", choices=("cond", "unknown"), default="cond") | |
| ap.add_argument("--seed", type=int, default=0) | |
| ap.add_argument("--thumb", action="store_true", help="生成面板右上角嵌输入首帧缩略图") | |
| ap.add_argument("--save_latents", action="store_true") | |
| ap.add_argument("--device", default="cuda") | |
| return ap | |
| def main(argv=None) -> dict: | |
| ap = build_parser() | |
| args = ap.parse_args(argv) | |
| if args.export_clip: | |
| return export_clip(args) | |
| if bool(args.ckpt) == bool(args.base): | |
| ap.error("必须二选一:--ckpt 或 --base") | |
| if not (args.first_frame and args.ids): | |
| ap.error("用户模式需要 --first_frame 和 --ids") | |
| if bool(args.prompts) == bool(args.actions): | |
| ap.error("必须二选一:--prompts <json> 或 --actions <spec>") | |
| if args.sem_mode != "cond" and args.sem_cfg != 1.0: | |
| ap.error("--sem_cfg 只在 --sem_mode cond 下有意义") | |
| t_all = time.time() | |
| name = args.name or os.path.splitext(os.path.basename(args.first_frame))[0] | |
| stem = os.path.join(args.out, name) | |
| # 加载模型(~1 min)之前把输入全部校验完 | |
| if args.ckpt: | |
| GEO.check_ckpt_res(args.ckpt, "--ckpt") # metadata res 必须 = GC_RES(缺失 ⇒ 832x480) | |
| vocab = ckpt_vocab(args.ckpt) if args.ckpt else args.vocab | |
| tt = S.TextTable(args.text_table or os.path.join(args.data_root, "text_table_gc25d.pt")) | |
| img, ff_info = load_first_frame(args.first_frame) | |
| ids, ids_info = load_ids(args.ids, vocab) | |
| prompts, blocks, p_info = load_prompts(args, tt) | |
| ids_model = S.model_ids(ids, vocab, args.sem_mode) # vocab=ids 时 load_ids 已折叠,这里只处理 unknown | |
| os.makedirs(args.out, exist_ok=True) | |
| print(f"[{name}] 首帧 {ff_info['src_size']} ({ff_info['resize']}) · ids {ids_info['src_shape']} · vocab={vocab} · " | |
| f"文本({p_info['source']}):\n " + "\n ".join(INF.prompt_runs(prompts)), flush=True) | |
| timing = {} | |
| dit, model_info = S.load_dit(args.ckpt, args.base, vocab, args.model_dir, args.device) | |
| assert model_info["vocab"] == vocab, (model_info["vocab"], vocab) | |
| t0 = time.time() | |
| vae_pipe = INF.load_vae(args.device, args.model_dir) | |
| timing.update(dit_load_sec=model_info["dit_load_sec"], vae_load_sec=time.time() - t0) | |
| print(f"[model] {args.ckpt or 'base'}:vocab={vocab}(DiT {timing['dit_load_sec']:.0f}s · VAE {timing['vae_load_sec']:.0f}s)", | |
| flush=True) | |
| timing["warmup_sec"] = S.warmup(args, dit, tt, ids_model, prompts) # 必须在 VAE 编码之前(见 sample.warmup) | |
| t0 = time.time() | |
| z0 = INF.encode_first_frame(vae_pipe, img) # fp32 VAE 单帧 → bf16 [1,48,1,H/16,W/16] | |
| torch.cuda.synchronize() | |
| timing["encode_sec"] = time.time() - t0 | |
| latents, timing["sample_sec"] = S.generate(args, dit, tt, z0, ids_model, prompts) | |
| if args.save_latents: | |
| torch.save(latents.cpu(), stem + "_latents.pt") | |
| t0 = time.time() | |
| frames = INF.decode_video(vae_pipe, latents) | |
| timing["decode_sec"] = time.time() - t0 | |
| peak = torch.cuda.max_memory_allocated() / 1e9 | |
| t0 = time.time() | |
| gen_name, ids_name = S.panel_names(args) | |
| sbs = UserSideBySide(frames, ids_model, prompts=prompts, blocks=blocks, title=args.title or name, | |
| gen_name=gen_name, ids_name=ids_name, thumb=make_thumb(img) if args.thumb else None) | |
| VIZ.write_mp4((sbs.frame(i) for i in range(sbs.n_frames)), stem + ".mp4") | |
| VIZ.write_video(stem + "_gen.mp4", frames) | |
| VIZ.contact_sheet(stem + "_sheet.jpg", sbs) | |
| timing["write_sec"] = time.time() - t0 | |
| timing["total_sec"] = time.time() - t_all | |
| info = {"name": name, "mode": "user", "res": GEO.RES_TAG, "vocab": vocab, "sem_mode": args.sem_mode, "steps": args.steps, "shift": args.shift, | |
| "cfg": args.cfg, "sem_cfg": args.sem_cfg, "seed": args.seed, | |
| "inputs": {"first_frame": ff_info, "ids": ids_info, "text": p_info}, | |
| "prompt_runs": INF.prompt_runs(prompts), "prompts": prompts, | |
| "actions_block": None if blocks is None else blocks.tolist(), | |
| "cond": cond_stats(ids_model), "ids_supplied": cond_stats(ids) if args.sem_mode != "cond" else "= cond", | |
| "timing": timing, "peak_mem_gb": peak, "model": model_info, | |
| "out": {"mp4": stem + ".mp4", "gen_mp4": stem + "_gen.mp4", "sheet": stem + "_sheet.jpg", | |
| "latents": stem + "_latents.pt" if args.save_latents else None}} | |
| with open(stem + ".json", "w", encoding="utf-8") as fh: | |
| json.dump(info, fh, ensure_ascii=False, indent=1, default=INF._json_default) | |
| print(f"DONE {stem}.mp4 · warmup {timing['warmup_sec']:.1f}s · 编码 {timing['encode_sec']:.1f}s · 采样 {timing['sample_sec']:.1f}s · 解码 {timing['decode_sec']:.1f}s" | |
| f" · 写 {timing['write_sec']:.1f}s · 总 {timing['total_sec']:.0f}s · 峰值显存 {peak:.1f} GB", flush=True) | |
| return info | |
| if __name__ == "__main__": | |
| main() | |