File size: 17,020 Bytes
bcdd8fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
#!/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()