#!/usr/bin/env python3 """teaser · 素材合成(maliang venv,CPU):把 rollout.py 的结果(或 genbench 的旧结果)拼成能直接剪进 teaser 的视频 / 索引。 视频一律经 ffmpeg 管道编码(系统 PATH 里的 ffmpeg,没有就用 imageio_ffmpeg 自带的二进制):libx264 · yuv420p · BT.709(limited, 标记写进码流)· crf 16 · +faststart。读视频也走 ffmpeg(按码流里的色彩标记转回 RGB,与写入对称)。 case = 下面任何一种(load_case 自动识别): * rollout.py 的输出目录 //(gen.mp4 + ids.npz + meta.json;meta 里有标签 scene / map / label / title); * genbench3/4 的 roll_bidir|roll_dmd///(gen.mp4 + meta.json;语义 = /sources.json 里该源链的 cond 拼接, bidir 只有 81 帧 = 链首 clip); * sample_user.py 的输出(.json + _gen.mp4;给目录或 json 路径;ids = json 里 inputs.ids.path)。 标签:场景名(右 / 角标)= case 的 label,否则 SCENE_EN[scene](scenes.py 的 36 个场景 + genbench4 的 10 个),否则把键名美化; 地图名(左)= MAP_EN[map](procmap 主题),否则美化。命令行 --scene_label / --map_label 覆盖。 子命令 side 单个 case:语义色图 | 生成 并排(深色底、1 px 细边框、面板上方小字标签:左 = 地图主题,右 = 场景名),--legend 加图例。 gen 单个 case:只出生成视频(--fit crop 铺满 / pad 留边),左下角标 = 场景名,--inset 右上角语义小窗。 grid N 个 case 同步播放的宫格(--cols,--what gen|sem,--labels scene|map|name|none,--margin / --gap)。 reel 若干 mp4(或 case 目录 ⇒ 它的 gen.mp4)顺序拼接:相邻之间交叉淡化(--mode cross)或经黑场(--mode black),--fade 帧数。 index 一批结果 → /contact_XX.jpg(分页缩略图:帧 40 + 右上语义小窗 + 名字)+ /thumbs/*.jpg + /index.md(表格 + 路径)。 默认画布 1920×1080、16 fps;side / gen / grid 都取 case 的全部帧(81),--frames N 截短,--fade_io N 首尾各 N 帧淡入淡出(默认 0,交给剪辑); 语义色图默认描 1 px 类别边界(procmap 的干净多边形更像"地图"),--flat 关掉(真实数据的碎植被描线会显脏)。 side 想要更紧的画面:--canvas 1920x640 --margin 24 --gap 24。 usage($PY = .venv/bin/python;不用 GPU): $PY scripts/teaser/compose.py side outputs/teaser/roll/ -o x.mp4 [--legend] [--map_label Village] [--scene_label "Ice Cave"] $PY scripts/teaser/compose.py gen outputs/teaser/roll/ -o x.mp4 [--inset] [--fit crop|pad] [--no_badge] $PY scripts/teaser/compose.py grid outputs/teaser/roll/a outputs/teaser/roll/b ... -o g.mp4 --cols 3 [--what gen] [--labels scene] $PY scripts/teaser/compose.py reel a.mp4 b.mp4 outputs/teaser/roll/c -o r.mp4 [--fade 8] [--mode cross|black] $PY scripts/teaser/compose.py index 'outputs/teaser/roll/*' -o outputs/teaser/roll/_index [--per_page 48] [--cols 8] glob 参数要加引号(脚本自己展开);grid / reel / index 的 case 也可以写在文件里:@list.txt(每行一个)。 """ from __future__ import annotations import argparse import glob import json import os import shutil import subprocess import sys import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont M = "/opt/dlami/nvme/zhiyangdeng/maliang" if f"{M}/code/gc25d" not in sys.path: sys.path.insert(0, f"{M}/code/gc25d") import geometry as GEO # noqa: E402 DATA_ROOT = f"{M}/data/gc25d_v10" FPS = 16 CANVAS = (1920, 1080) BG = (12, 14, 19) PANEL_BG = (20, 23, 30) BORDER = (64, 70, 84) FG = (236, 238, 244) MUTED = (146, 153, 170) FONT_LAT = "/usr/share/fonts/google-noto-vf/NotoSans[wght].ttf" FONT_CJK = "/usr/share/fonts/google-droid-sans-fonts/DroidSansFallbackFull.ttf" # 只有 CJK,拉丁字母走 Noto # 语义调色板:静态类 / 玩家 core 10 / Boss core 11 沿用 geometry.PALETTE;肢体部件 12–21 给同色系(genbench4/showcase.py 同款) PAL = GEO.PALETTE.copy() PAL[[12, 13, 14, 15, 16]] = [(255, 120, 255), (200, 0, 200), (255, 80, 200), (170, 60, 255), (230, 150, 255)] PAL[[17, 18, 19, 20, 21]] = [(255, 110, 80), (200, 0, 30), (255, 70, 40), (230, 40, 90), (255, 150, 120)] LEGEND = [("sky", (0,)), ("terrain", (1,)), ("paving", (2,)), ("architecture", (3,)), ("rock", (4,)), ("vegetation", (5,)), ("prop", (6,)), ("player", (10, 12, 13, 14, 15, 16)), ("boss", (11, 17, 18, 19, 20, 21))] SCENE_EN = { "castle": "Medieval Castle", "dungeon": "Dungeon", "lava": "Lava Realm", "icecave": "Ice Cavern", "graveyard": "Haunted Graveyard", "swamp": "Swamp", "alien": "Alien Planet", "wildwest": "Wild West Town", "mushroom": "Mushroom Forest", "ruins": "Ruined City", "egypt": "Egyptian Temple", "chinese_palace": "Imperial Palace", "jungle_temple": "Jungle Temple", "moonbase": "Moon Base", "mars": "Mars Colony", "crystal_cavern": "Crystal Cavern", "candy": "Candy Land", "greek": "Greek Acropolis", "sky_islands": "Sky Kingdom", "halloween": "Halloween Patch", "christmas": "Christmas Village", "savanna": "Savanna", "beach": "Tropical Beach", "toxic": "Toxic Wasteland", "utopia": "Future City", "elven": "Elven City", "farm": "Countryside Farm", "pirate": "Pirate Cove", "samurai": "Samurai Town", "steppe_camp": "Steppe War Camp", "clockwork": "Clockwork World", "underworld": "Underworld", "bamboo": "Bamboo Valley", "coral_dry": "Dry Coral Seabed", "autumn_harvest": "Harvest Fair", "volcano_forge": "Dwarven Forge", "orig": "Original", } MAP_EN = {"village": "Village", "forest": "Forest", "plaza": "Plaza", "canyon": "Canyon", "hedgemaze": "Hedge Maze", "stonecircle": "Stone Circle", "pillarhall": "Pillar Hall", "skyislands": "Sky Islands", "cityblock": "City Blocks", "mountainpass": "Mountain Pass", "racetrack": "Race Track", "glyph": "Giant Letters", "chess": "Chessboard"} # ------------------------------------------------------------------ 小工具 ---- def pretty(key: str | None) -> str: """键名 → 显示名(下划线 / 连字符 → 空格,首字母大写)。""" if not key: return "" return " ".join(w[:1].upper() + w[1:] for w in str(key).replace("-", "_").split("_") if w) def even(x: float) -> int: return int(round(x / 2)) * 2 def expand(items: list[str]) -> list[str]: """命令行的 case / mp4 列表:@文件(每行一个)、glob 自己展开(保持给出的顺序,glob 内按名字排序)。""" out = [] for it in items: if it.startswith("@"): with open(it[1:], encoding="utf-8") as fh: out += expand([ln.strip() for ln in fh if ln.strip() and not ln.startswith("#")]) elif any(c in it for c in "*?["): out += sorted(glob.glob(it)) else: out.append(it) return out # ------------------------------------------------------------------ ffmpeg ---- def ffmpeg_exe() -> str: exe = shutil.which("ffmpeg") if exe: return exe import imageio_ffmpeg return imageio_ffmpeg.get_ffmpeg_exe() class VideoWriter: """RGB uint8 帧 → ffmpeg 管道 → H.264 yuv420p(BT.709 limited,标记写进码流)。先写 .part.mp4,close 成功才改名 ⇒ 中途崩了不会留下看似完整的 mp4。W、H 须为偶数(yuv420p)。""" def __init__(self, path: str, w: int, h: int, fps: int = FPS, crf: int = 16, preset: str = "slow"): assert w % 2 == 0 and h % 2 == 0, (w, h) self.path, self.w, self.h, self.n = path, w, h, 0 os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True) self.tmp = path + ".part.mp4" cmd = [ffmpeg_exe(), "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{w}x{h}", "-r", str(fps), "-i", "-", "-vf", "scale=out_color_matrix=bt709:out_range=tv", "-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv", "-movflags", "+faststart", self.tmp] self.p = subprocess.Popen(cmd, stdin=subprocess.PIPE) def write(self, fr: np.ndarray) -> None: assert fr.dtype == np.uint8 and fr.shape == (self.h, self.w, 3), (fr.dtype, fr.shape, (self.h, self.w)) self.p.stdin.write(np.ascontiguousarray(fr).tobytes()) self.n += 1 def close(self) -> None: self.p.stdin.close() rc = self.p.wait() if rc != 0 or self.n == 0: raise RuntimeError(f"ffmpeg 写 {self.path} 失败(rc={rc},{self.n} 帧)") os.replace(self.tmp, self.path) def __enter__(self): return self def __exit__(self, et, ev, tb): if et is None: self.close() else: self.p.kill() self.p.wait() if os.path.exists(self.tmp): os.remove(self.tmp) def write_video(path: str, frames, crf: int = 16, fps: int = FPS, preset: str = "slow") -> int: """frames:uint8 [F,H,W,3] 或逐帧迭代器(尺寸取第一帧)。返回帧数。""" it = iter(frames) first = next(it) with VideoWriter(path, first.shape[1], first.shape[0], fps=fps, crf=crf, preset=preset) as vw: vw.write(first) for fr in it: vw.write(fr) return vw.n def probe(path: str) -> tuple[int, int]: """mp4 的 (W, H)(PyAV 读头,不解码)。""" import av with av.open(path) as c: s = c.streams.video[0] return s.codec_context.width, s.codec_context.height def iter_video(path: str, n: int | None = None): """mp4 → 逐帧 RGB uint8(ffmpeg 解码,按码流的色彩标记转 RGB)。""" w, h = probe(path) cmd = [ffmpeg_exe(), "-loglevel", "error", "-i", path, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"] if n is not None: cmd[5:5] = ["-frames:v", str(n)] p = subprocess.Popen(cmd, stdout=subprocess.PIPE) sz = w * h * 3 try: while True: buf = p.stdout.read(sz) if len(buf) < sz: break yield np.frombuffer(buf, np.uint8).reshape(h, w, 3) finally: p.stdout.close() p.wait() def read_video(path: str, n: int | None = None) -> np.ndarray: return np.stack(list(iter_video(path, n))) # ------------------------------------------------------------------ 语义着色 ---- def colorize(ids: np.ndarray, edges: bool = True) -> np.ndarray: """uint8 [H,W](或 [...,H,W])→ RGB:PAL 着色,255 = 黑底灰斜纹(同 geometry.colorize_ids),edges ⇒ 类别边界压暗成 1 px 描线。""" ids = np.asarray(ids) out = PAL[ids] unk = ids == GEO.UNKNOWN_ID if unk.any(): h, w = ids.shape[-2:] yy, xx = np.mgrid[0:h, 0:w] out[unk & np.broadcast_to(((xx + yy) // 4) % 2 == 1, ids.shape)] = GEO.HATCH_GREY if edges: b = np.zeros(ids.shape, bool) b[..., :, :-1] |= ids[..., :, :-1] != ids[..., :, 1:] b[..., :-1, :] |= ids[..., :-1, :] != ids[..., 1:, :] out[b] = (out[b].astype(np.uint16) * 9 // 16).astype(np.uint8) return out def sem_panel(ids: np.ndarray, w: int, h: int, edges: bool = True) -> np.ndarray: """一帧 ids → w×h 的色图:先最近邻缩放 id 再着色(描线在目标分辨率上仍是 1 px)。""" if ids.shape != (h, w): ids = cv2.resize(ids, (w, h), interpolation=cv2.INTER_NEAREST) return colorize(ids, edges) def fit(img: np.ndarray, w: int, h: int) -> np.ndarray: if img.shape[:2] == (h, w): return img up = w > img.shape[1] return cv2.resize(img, (w, h), interpolation=cv2.INTER_LANCZOS4 if up else cv2.INTER_AREA) # ------------------------------------------------------------------ 文字 ---- _FONTS: dict = {} def font(size: int, weight: str = "Medium", cjk: bool = False): k = (size, weight, cjk) if k not in _FONTS: if cjk: f = ImageFont.truetype(FONT_CJK, size) else: f = ImageFont.truetype(FONT_LAT, size) f.set_variation_by_name(weight) _FONTS[k] = f return _FONTS[k] def _runs(s: str): out = [] for ch in s: c = ord(ch) >= 0x2E80 if out and out[-1][0] == c: out[-1][1] += ch else: out.append([c, ch]) return out def text_w(s: str, size: int, weight: str = "Medium") -> float: return sum(font(size, weight, c).getlength(r) for c, r in _runs(s)) def draw_text(d: ImageDraw.ImageDraw, x: float, y: float, s: str, size: int, fill, weight: str = "Medium") -> float: """中英混排(CJK 走 Droid,其余 Noto Sans),(x, y) = 左上,基线 y + 0.8·size。返回右端 x。""" for c, r in _runs(s): d.text((x, y + 0.8 * size), r, font=font(size, weight, c), fill=fill, anchor="ls") x += font(size, weight, c).getlength(r) return x def pill(s: str, size: int, fg=FG, bg=(0, 0, 0), alpha: float = 0.62, weight: str = "SemiBold", pad=None): """文字小胶囊 → (rgb uint8 [h,w,3], a float32 [h,w,1]),贴在视频上用 paste_rgba。""" px, py = pad or (round(size * 0.55), round(size * 0.28)) w, h = int(np.ceil(text_w(s, size, weight))) + 2 * px, int(round(size * 1.05)) + 2 * py im = Image.new("RGBA", (w, h), (0, 0, 0, 0)) d = ImageDraw.Draw(im) d.rounded_rectangle((0, 0, w - 1, h - 1), radius=h // 2 if h < 3 * size else 8, fill=(*bg, int(255 * alpha))) draw_text(d, px, py - round(size * 0.02), s, size, (*fg, 255), weight) a = np.asarray(im, np.float32) return a[..., :3].astype(np.uint8), a[..., 3:] / 255.0 def paste_rgba(dst: np.ndarray, patch, x: int, y: int) -> None: rgb, a = patch h, w = rgb.shape[:2] h, w = min(h, dst.shape[0] - y), min(w, dst.shape[1] - x) if h <= 0 or w <= 0: return roi = dst[y:y + h, x:x + w].astype(np.float32) dst[y:y + h, x:x + w] = (roi * (1 - a[:h, :w]) + rgb[:h, :w] * a[:h, :w]).astype(np.uint8) def frame_rect(img: np.ndarray, x: int, y: int, w: int, h: int, color=BORDER, t: int = 1) -> None: """画在面板外侧的细边框(不压住画面)。""" cv2.rectangle(img, (x - t, y - t), (x + w + t - 1, y + h + t - 1), color, t, lineType=cv2.LINE_8) # ------------------------------------------------------------------ case ---- def _sources_json(gen_dir: str) -> str | None: """genbench 的 /roll_// → /sources.json。""" p = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(gen_dir))), "sources.json") return p if os.path.isfile(p) else None def _chain_ids(root_sources: str, tag: str, n: int) -> np.ndarray: """genbench 源链的 raw id(= eval_rollout.load_chain 的拼法:cond[clip] + cond_ext[:96−81]),取前 n 帧。""" src = {s["tag"]: s for s in json.load(open(root_sources))}[tag] sem, total = [], 0 for cid in src["chain"]: with np.load(f"{DATA_ROOT}/cond/{cid}.npz") as z: sem.append(z["ids"]) total += len(sem[-1]) ext = f"{DATA_ROOT}/cond_ext/{cid}.npz" if total < n and os.path.exists(ext): with np.load(ext) as z: sem.append(z["ids"][:96 - 81]) total += len(sem[-1]) if total >= n: break return np.concatenate(sem, 0)[:n] def _load_npz_ids(path: str) -> np.ndarray: if path.endswith(".npy"): return np.load(path) with np.load(path) as z: return z["ids"] if "ids" in z.files else z[z.files[0]] def load_case(path: str) -> dict: """→ {name, dir, gen(mp4), ids()(惰性读全部帧 raw id), scene, map, label, title, seed, sec, frames_npz, sheet, side, meta, kind}。""" p = os.path.abspath(path.rstrip("/")) if os.path.isdir(p) and os.path.isfile(os.path.join(p, "meta.json")): meta = json.load(open(os.path.join(p, "meta.json"))) gen = os.path.join(p, "gen.mp4") if meta.get("kind") == "teaser_rollout": lab = meta.get("labels", {}) ids_path = os.path.join(p, "ids.npz") if not os.path.exists(ids_path): # 早期结果没存 ids.npz ⇒ 回到输入路径,按 rollout 的规则变换 ids_path = meta["inputs"]["ids"]["path"] res = meta.get("res", "832x480") def ids_fn(ids_path=ids_path, res=res): a = _load_npz_ids(ids_path) w, h = (int(v) for v in res.split("x")) if a.shape[1:] == (h, w): return a if a.shape[1:] == (GEO.SRC_H, GEO.SRC_W) and res == GEO.RES_TAG: return GEO.resample_ids(a) return np.stack([cv2.resize(f, (w, h), interpolation=cv2.INTER_NEAREST) for f in a]) return {"kind": "teaser", "name": meta["name"], "dir": p, "gen": gen, "ids": ids_fn, "scene": lab.get("scene"), "map": lab.get("map"), "label": lab.get("label"), "title": lab.get("title"), "seed": meta.get("seed"), "sec": meta.get("timing", {}).get("gpu_sec"), "frames_npz": os.path.join(p, "frames.npz"), "sheet": os.path.join(p, "sheet.jpg"), "side": os.path.join(p, "side.mp4"), "meta": meta} if "tag" in meta and "variant" in meta: # genbench3 / 4 的 roll_// sj = _sources_json(p) src = {s["tag"]: s for s in json.load(open(sj))}[meta["tag"]] if sj else {} n = int(meta.get("n_frames", 81)) scene_src = str(src.get("scene", "")).split("_", 1)[-1] if src else "" return {"kind": "genbench", "name": f"{meta['tag']}__{meta['variant']}", "dir": p, "gen": gen, "ids": (lambda sj=sj, tag=meta["tag"], n=n: _chain_ids(sj, tag, n)), "scene": meta["variant"], "map": scene_src, "label": None, "title": None, "seed": meta.get("seed"), "sec": meta.get("sec"), "frames_npz": os.path.join(p, "frames.npz"), "sheet": None, "side": None, "meta": meta} raise ValueError(f"{p}/meta.json 认不出格式(既不是 teaser rollout 也不是 genbench roll)") js = p if p.endswith(".json") else None if js is None and os.path.isdir(p): cands = [f for f in sorted(glob.glob(os.path.join(p, "*.json"))) if os.path.exists(f[:-5] + "_gen.mp4")] js = cands[0] if cands else None if js is None: raise FileNotFoundError(f"{path}:不是 rollout 目录(gen.mp4 + meta.json),也没有 sample_user 的 .json + _gen.mp4") meta = json.load(open(js)) stem = js[:-5] name = meta.get("name") or os.path.basename(stem) return {"kind": "sample_user", "name": name, "dir": os.path.dirname(js), "gen": stem + "_gen.mp4", "ids": (lambda ip=meta["inputs"]["ids"]["path"]: _load_npz_ids(ip)), "scene": None, "map": name.split("__")[0] if "__" in name else None, "label": None, "title": None, "seed": meta.get("seed"), "sec": meta.get("timing", {}).get("total_sec"), "frames_npz": None, "sheet": stem + "_sheet.jpg", "side": stem + ".mp4", "meta": meta} SCENE_EN.update({ # 量产第二批场景(scenes.py 第二批 35 个) "dunes": "Sand Dunes", "oasis": "Desert Oasis", "badlands": "Red-Rock Badlands", "salt_flats": "Salt Flats", "arabian": "Arabian Nights", "volcano_rim": "Volcano Crater", "demon_citadel": "Demon Citadel", "ash_wasteland": "Ash Wasteland", "phoenix_temple": "Temple of Fire", "enchanted_forest": "Enchanted Forest", "dark_forest": "Cursed Forest", "rainforest": "Rainforest", "redwood": "Redwood Forest", "snow_forest": "Snowy Pine Forest", "autumn_maple": "Autumn Maples", "cherry_blossom": "Cherry Blossoms", "dino_jungle": "Dinosaur Jungle", "circuit_board": "Circuit Board World", "toy_room": "Toy Room", "origami": "Origami World", "synthwave": "Synthwave Grid", "glass_palace": "Glass Palace", "honey_hive": "Honey Hive", "cosmic": "Cosmic Nebula", "dragon_lair": "Dragon's Hoard", "floating_library": "Magic Library", "ink_landscape": "Ink-Wash Landscape", "venice": "Canal City", "rice_terraces": "Rice Terraces", "tulip_fields": "Tulip Fields", "arctic_base": "Arctic Station", "space_station": "Overgrown Space Station", "battlefield": "Ancient Battlefield", "tibetan": "Mountain Monastery", "fairground": "Haunted Fairground"}) LOOK_EN = {"photoreal": "Photoreal", "cinematic": "Cinematic", "cartoon": "Cartoon", "pixel": "Pixel Art", "anime": "Anime", "painterly": "Hand-Painted", "lowpoly": "Low-Poly", "voxel": "Voxel"} def scene_label(c: dict, override: str | None = None) -> str: """scene 形如 "<场景>@<画风>"(scenes.py 的派生场景)⇒ "Lava Realm · Pixel Art"。""" if override is not None: return override sc = c.get("scene") or "" if "@" in sc: b, lk = sc.split("@", 1) return f"{SCENE_EN.get(b, pretty(b))} · {LOOK_EN.get(lk, pretty(lk))}" return c.get("label") or SCENE_EN.get(sc, pretty(sc)) def map_label(c: dict, override: str | None = None) -> str: if override is not None: return override return MAP_EN.get(c.get("map") or "", pretty(c.get("map"))) def case_frames(c: dict, n: int | None) -> tuple[np.ndarray, np.ndarray]: """→ (生成 [F,H,W,3], ids [F,h,w]),F = min(视频帧数, ids 帧数, n)。""" gen = read_video(c["gen"], n) ids = c["ids"]() f = min(len(gen), len(ids)) return gen[:f], ids[:f] def fade_io(frames, n_total: int, k: int): for i, fr in enumerate(frames): a = min(1.0, (i + 1) / k, (n_total - i) / k) if k > 0 else 1.0 yield fr if a >= 1.0 else (fr.astype(np.float32) * a).astype(np.uint8) # ------------------------------------------------------------------ (a) side / gen ---- def legend_strip(ids_present: set[int], size: int = 20) -> Image.Image: items = [(n, ids) for n, ids in LEGEND if ids_present & set(ids)] sw = round(size * 0.9) widths = [sw + 8 + text_w(n, size) + 22 for n, _ in items] im = Image.new("RGBA", (int(sum(widths)) + 2, size + 10), (0, 0, 0, 0)) d = ImageDraw.Draw(im) x = 0 for (n, ids), w in zip(items, widths): d.rounded_rectangle((x, 4, x + sw, 4 + sw), 3, fill=tuple(int(v) for v in PAL[ids[0]])) if len(ids) > 1: # 角色:下半格 = 肢体部件色 d.rectangle((x, 4 + sw // 2, x + sw, 4 + sw), fill=tuple(int(v) for v in PAL[ids[1]])) draw_text(d, x + sw + 8, 1, n, size, (*MUTED, 255)) x += w return im def cmd_side(a) -> None: c = load_case(a.case) W, H = a.canvas gen, ids = case_frames(c, a.frames) gap, mx = a.gap, a.margin pw = (W - 2 * mx - gap) // 2 ph = round(pw * gen.shape[1] / gen.shape[2]) cap = round(24 * W / 1920) # 标签字号(宽 1920 ⇒ 24) y0 = (H - ph) // 2 + (cap if a.legend else 0) // 2 xs = (mx, mx + pw + gap) base = Image.new("RGB", (W, H), BG) d = ImageDraw.Draw(base) for x, (head, sub) in zip(xs, (("SEMANTIC MAP", map_label(c, a.map_label)), ("GENERATED", scene_label(c, a.scene_label)))): xe = draw_text(d, x, y0 - cap * 2, head, cap, FG, "SemiBold") if sub: draw_text(d, xe + cap * 0.6, y0 - cap * 2, "· " + sub, cap, MUTED, "Medium") if a.title: draw_text(d, (W - text_w(a.title, cap + 8, "SemiBold")) / 2, y0 - cap * 2 - (cap + 8) * 2.4, a.title, cap + 8, FG, "SemiBold") if a.legend: lg = legend_strip(set(np.unique(ids[::8]).tolist()), round(cap * 0.82)) base.paste(lg, (xs[0], y0 + ph + round(cap * 0.8)), lg) if a.note: draw_text(d, (W - text_w(a.note, round(cap * 0.8))) / 2, H - cap * 2.6, a.note, round(cap * 0.8), MUTED) base = np.asarray(base).copy() for x in xs: frame_rect(base, x, y0, pw, ph) nf = len(gen) def frames(): for i in range(nf): out = base.copy() out[y0:y0 + ph, xs[0]:xs[0] + pw] = sem_panel(ids[i], pw, ph, not a.flat) out[y0:y0 + ph, xs[1]:xs[1] + pw] = fit(gen[i], pw, ph) yield out n = write_video(a.out, fade_io(frames(), nf, a.fade_io), crf=a.crf) print(f"side → {a.out}({W}×{H},{n} 帧)") def cmd_gen(a) -> None: c = load_case(a.case) W, H = a.canvas gen = read_video(c["gen"], a.frames) ids = c["ids"]()[:len(gen)] if a.inset else None h, w = gen.shape[1:3] s = max(W / w, H / h) if a.fit == "crop" else min(W / w, H / h) sw, sh = even(w * s), even(h * s) if a.fit == "crop": sw, sh = max(W, sw), max(H, sh) ox, oy = (W - sw) // 2, (H - sh) // 2 lab = scene_label(c, a.scene_label) badge = pill(lab, round(H * 0.03)) if lab and not a.no_badge else None m = round(H * 0.035) iw = even(W * a.inset_frac) ih = even(iw * h / w) ins_cap = pill("semantic input", round(H * 0.017), alpha=0.55, weight="Medium") if a.inset else None def frames(): for i in range(len(gen)): big = cv2.resize(gen[i], (sw, sh), interpolation=cv2.INTER_LANCZOS4) if a.fit == "crop": out = big[-oy:-oy + H, -ox:-ox + W].copy() else: out = np.empty((H, W, 3), np.uint8) out[:] = BG out[oy:oy + sh, ox:ox + sw] = big if a.inset: x, y = W - m - iw, m out[y:y + ih, x:x + iw] = sem_panel(ids[i], iw, ih, not a.flat) frame_rect(out, x, y, iw, ih, (235, 235, 240), 2) paste_rgba(out, ins_cap, x + 6, y + ih - ins_cap[0].shape[0] - 6) if badge is not None: paste_rgba(out, badge, m, H - m - badge[0].shape[0]) yield out n = write_video(a.out, fade_io(frames(), len(gen), a.fade_io), crf=a.crf) print(f"gen → {a.out}({W}×{H},{n} 帧,fit={a.fit}{',语义小窗' if a.inset else ''})") # ------------------------------------------------------------------ (b) grid ---- def grid_layout(n: int, cols: int, W: int, H: int, aspect: float, margin: int, gap: int): rows = (n + cols - 1) // cols tw = (W - 2 * margin - (cols - 1) * gap) / cols th = tw / aspect if rows * th + (rows - 1) * gap + 2 * margin > H: th = (H - 2 * margin - (rows - 1) * gap) / rows tw = th * aspect tw, th = int(tw), int(th) gw, gh = cols * tw + (cols - 1) * gap, rows * th + (rows - 1) * gap x0, y0 = (W - gw) // 2, (H - gh) // 2 return rows, tw, th, [(x0 + (k % cols) * (tw + gap), y0 + (k // cols) * (th + gap)) for k in range(n)] def cmd_grid(a) -> None: cases = [load_case(p) for p in expand(a.cases)] if not cases: raise SystemExit("grid:没有 case") W, H = a.canvas cols = a.cols or int(np.ceil(np.sqrt(len(cases) * 16 / 9 * 480 / 832))) gens = [read_video(c["gen"], a.frames) if a.what == "gen" else None for c in cases] idss = [c["ids"]() if a.what == "sem" else None for c in cases] lens = [len(g) if g is not None else len(i) for g, i in zip(gens, idss)] nf = min(lens) h0, w0 = (gens[0].shape[1:3] if gens[0] is not None else idss[0].shape[1:3]) rows, tw, th, pos = grid_layout(len(cases), cols, W, H, w0 / h0, a.margin, a.gap) fs = max(13, round(th * 0.075)) labels = [] for c in cases: s = {"scene": scene_label(c), "map": map_label(c), "name": c["name"], "none": ""}[a.labels] labels.append(pill(s, fs, alpha=0.6) if s else None) def frames(): for i in range(nf): out = np.empty((H, W, 3), np.uint8) out[:] = BG for k, (x, y) in enumerate(pos): tile = fit(gens[k][i], tw, th) if a.what == "gen" else sem_panel(idss[k][i], tw, th, not a.flat) out[y:y + th, x:x + tw] = tile if labels[k] is not None: paste_rgba(out, labels[k], x + round(fs * 0.5), y + th - labels[k][0].shape[0] - round(fs * 0.5)) yield out n = write_video(a.out, fade_io(frames(), nf, a.fade_io), crf=a.crf) print(f"grid → {a.out}({len(cases)} 个 case,{rows}×{cols},格 {tw}×{th},{W}×{H},{n} 帧)") # ------------------------------------------------------------------ (c) reel ---- def n_frames(path: str) -> int: import av with av.open(path) as c: n = c.streams.video[0].frames return n or sum(1 for _ in iter_video(path)) def cmd_reel(a) -> None: srcs = [p if p.endswith(".mp4") else load_case(p)["gen"] for p in expand(a.clips)] W, H = a.canvas if a.canvas is not None else probe(srcs[0]) k = a.fade def norm(fr): if fr.shape[:2] == (H, W): return fr h, w = fr.shape[:2] s = min(W / w, H / h) sw, sh = even(w * s), even(h * s) out = np.empty((H, W, 3), np.uint8) out[:] = 0 out[(H - sh) // 2:(H - sh) // 2 + sh, (W - sw) // 2:(W - sw) // 2 + sw] = cv2.resize( fr, (sw, sh), interpolation=cv2.INTER_LANCZOS4 if s > 1 else cv2.INTER_AREA) return out def scale(fr, t): return (fr.astype(np.float32) * t).astype(np.uint8) def frames(): tail = None for ci, src in enumerate(srcs): clip = [norm(f) for f in iter_video(src)] first, last = ci == 0, ci == len(srcs) - 1 if len(clip) <= (0 if first else k) + (0 if last else k): raise ValueError(f"{src} 只有 {len(clip)} 帧,不够 --fade {k}") if a.mode == "cross": head = 0 if tail is not None: for j in range(k): t = (j + 1) / (k + 1) yield (tail[j].astype(np.float32) * (1 - t) + clip[j].astype(np.float32) * t).astype(np.uint8) head = k end = len(clip) if last else len(clip) - k yield from clip[head:end] tail = clip[end:] else: # 经黑场:每段(首段除外)淡入、(末段除外)淡出 n = len(clip) for j, fr in enumerate(clip): t = 1.0 if not first and j < k: t = min(t, (j + 1) / (k + 1)) if not last and j >= n - k: t = min(t, (n - j) / (k + 1)) yield fr if t >= 1.0 else scale(fr, t) total = sum(n_frames(s) for s in srcs) - (k * (len(srcs) - 1) if a.mode == "cross" else 0) n = write_video(a.out, fade_io(frames(), total, a.fade_io), crf=a.crf) print(f"reel → {a.out}({len(srcs)} 段,{W}×{H},{n} 帧 = {n / FPS:.1f} s,{a.mode} 过渡 {k} 帧)") # ------------------------------------------------------------------ (d) index ---- def thumb(c: dict, tw: int = 416, th: int = 240, frame: int = 40) -> np.ndarray: """帧 frame 的生成画面 + 右上角 1/3 宽语义小窗(frames.npz 有就不解码视频)。""" rgb = ids = None fz = c.get("frames_npz") if fz and os.path.exists(fz): with np.load(fz) as z: idx = list(z["idx"]) if frame in idx: rgb, ids = z["rgb"][idx.index(frame)], z["ids"][idx.index(frame)] if rgb is None: g = read_video(c["gen"], frame + 1) frame = min(frame, len(g) - 1) rgb, ids = g[frame], c["ids"]()[frame] out = fit(rgb, tw, th).copy() iw, ih = tw // 3, th // 3 out[3:3 + ih, tw - iw - 3:tw - 3] = sem_panel(ids, iw, ih) cv2.rectangle(out, (tw - iw - 4, 2), (tw - 3, 3 + ih), (235, 235, 240), 1) return out def cmd_index(a) -> None: paths = expand(a.roots) cases = [] for p in paths: try: cases.append(load_case(p)) except (FileNotFoundError, ValueError, KeyError): continue if not cases: raise SystemExit(f"index:{a.roots} 下没有认得出的结果") cases.sort(key=lambda c: c["name"]) os.makedirs(os.path.join(a.out, "thumbs"), exist_ok=True) tw, th, lab_h, cols = a.thumb_w, round(a.thumb_w * 480 / 832), 26, a.cols tiles = [] for c in cases: t = thumb(c, tw, th) tp = os.path.join(a.out, "thumbs", c["name"] + ".jpg") cv2.imwrite(tp, cv2.cvtColor(t, cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, 88]) c["thumb"] = tp strip = Image.new("RGB", (tw, lab_h), BG) d = ImageDraw.Draw(strip) sub = " · ".join(s for s in (scene_label(c), map_label(c)) if s) x = draw_text(d, 6, 4, c["name"][:46], 14, FG, "SemiBold") if sub and x + 12 + text_w(sub, 13) < tw: draw_text(d, tw - 6 - text_w(sub, 13), 5, sub, 13, MUTED) tiles.append(np.concatenate([t, np.asarray(strip)], 0)) pages = [] for pi in range(0, len(tiles), a.per_page): chunk = tiles[pi:pi + a.per_page] rows = (len(chunk) + cols - 1) // cols sheet = np.empty((rows * (th + lab_h + 6) + 6, cols * (tw + 6) + 6, 3), np.uint8) sheet[:] = BG for k, t in enumerate(chunk): x, y = 6 + (k % cols) * (tw + 6), 6 + (k // cols) * (th + lab_h + 6) sheet[y:y + th + lab_h, x:x + tw] = t pp = os.path.join(a.out, f"contact_{len(pages):02d}.jpg") cv2.imwrite(pp, cv2.cvtColor(sheet, cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, 88]) pages.append(pp) rel = lambda p: os.path.relpath(p, a.out) if p else "" # noqa: E731 lines = [f"# {a.title or 'teaser 结果索引'}", "", f"{len(cases)} 个结果 · 缩略图 = 帧 40(2.5 s)生成画面,右上角小窗 = 同帧语义条件。contact sheet:" + "、".join(f"[{os.path.basename(p)}]({rel(p)})" for p in pages), "", "| # | 缩略图 | name | 场景 | 地图 | seed | GPU s | 文件 |", "|---|---|---|---|---|---|---|---|"] for k, c in enumerate(cases): files = [f"[gen]({rel(c['gen'])})"] for key in ("side", "sheet"): if c.get(key) and os.path.exists(c[key]): files.append(f"[{key}]({rel(c[key])})") sec = c.get("sec") lines.append(f"| {k} | ![]({rel(c['thumb'])}) | `{c['name']}` | {scene_label(c)} | {map_label(c)} | {c.get('seed', '')} | " f"{'' if sec is None else f'{sec:.0f}'} | {' · '.join(files)} |") lines += ["", "绝对路径:", ""] + [f"- `{c['name']}`: `{c['dir']}`" for c in cases] with open(os.path.join(a.out, "index.md"), "w", encoding="utf-8") as fh: fh.write("\n".join(lines) + "\n") print(f"index → {a.out}/index.md({len(cases)} 个,{len(pages)} 页 contact sheet)") # ------------------------------------------------------------------ CLI ---- def _wh(s: str) -> tuple[int, int]: w, h = (int(v) for v in s.lower().split("x")) assert w % 2 == 0 and h % 2 == 0, "画布宽高须为偶数" return w, h def build_parser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser(description="teaser 素材合成(side / gen / grid / reel / index)") sub = ap.add_subparsers(dest="cmd", required=True) def common(p, canvas=True): p.add_argument("-o", "--out", required=True) p.add_argument("--crf", type=int, default=16) p.add_argument("--fade_io", type=int, default=0, help="首尾各 N 帧从黑淡入 / 淡出到黑") p.add_argument("--frames", type=int, default=None, help="只取前 N 帧(默认全部)") if canvas: p.add_argument("--canvas", type=_wh, default=CANVAS, help="画布 WxH,默认 1920x1080") p.add_argument("--flat", action="store_true", help="语义色图不描类别边界(真实数据的碎植被描线会显脏)") p = sub.add_parser("side", help="单个 case:语义 | 生成 并排") p.add_argument("case") common(p) p.add_argument("--legend", action="store_true") p.add_argument("--map_label", default=None) p.add_argument("--scene_label", default=None) p.add_argument("--title", default=None, help="面板上方居中的一行标题(默认无)") p.add_argument("--note", default=None, help="底部居中的一行小字(默认无)") p.add_argument("--margin", type=int, default=48) p.add_argument("--gap", type=int, default=32) p = sub.add_parser("gen", help="单个 case:只出生成视频 + 角标") p.add_argument("case") common(p) p.add_argument("--fit", choices=("crop", "pad"), default="crop") p.add_argument("--scene_label", default=None) p.add_argument("--no_badge", action="store_true") p.add_argument("--inset", action="store_true", help="右上角语义小窗") p.add_argument("--inset_frac", type=float, default=0.24) p = sub.add_parser("grid", help="N×M 宫格同步播放") p.add_argument("cases", nargs="+") common(p) p.add_argument("--cols", type=int, default=0, help="列数(默认按画布比例自动)") p.add_argument("--what", choices=("gen", "sem"), default="gen") p.add_argument("--labels", choices=("scene", "map", "name", "none"), default="scene") p.add_argument("--margin", type=int, default=0) p.add_argument("--gap", type=int, default=6) p = sub.add_parser("reel", help="顺序拼接 + 淡入淡出") p.add_argument("clips", nargs="+") common(p, canvas=False) p.add_argument("--canvas", type=_wh, default=None, help="默认 = 第一段的尺寸;其它尺寸的段等比缩放居中") p.add_argument("--fade", type=int, default=8, help="相邻两段之间的过渡帧数") p.add_argument("--mode", choices=("cross", "black"), default="cross") p = sub.add_parser("index", help="contact sheet + index.md") p.add_argument("roots", nargs="+", help="case 目录 / glob(加引号)/ @列表") p.add_argument("-o", "--out", required=True, help="输出目录") p.add_argument("--per_page", type=int, default=48) p.add_argument("--cols", type=int, default=8) p.add_argument("--thumb_w", type=int, default=416) p.add_argument("--title", default=None) return ap def main(argv=None) -> None: a = build_parser().parse_args(argv) {"side": cmd_side, "gen": cmd_gen, "grid": cmd_grid, "reel": cmd_reel, "index": cmd_index}[a.cmd](a) if __name__ == "__main__": main()