Files
vllm-omni/examples/offline_inference/diffusion/ar_diffusion_streaming_decode.py
T

349 lines
16 KiB
Python
Raw Normal View History

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Stream LingBot-World 2.0 pixels from one stepwise request.
One ``AsyncOmni.generate()`` drives the whole rollout: each AR block leaves
``post_decode()`` as one video chunk, decoded through that session's own
temporal VAE cache rather than as an isolated clip. That is what this script
shows end to end -- chunk *N + 1* continues chunk *N*, so the streamed timeline
carries the same frame count as an offline whole-clip decode of the same
latents instead of losing each block's opening frame to a restarted decoder.
Only the session's first latent frame expands to a single raw frame; on the
default checkpoint, whose block is three latent frames at a temporal factor of
four, that is 9 frames for the opening chunk and 12 for every later one.
The decode lives inside the pipeline, keyed by ``request_id``, so nothing here
owns a VAE: this file is a client of the served path, and the same chunks reach
a WebSocket client of ``/v1/realtime/video`` unchanged.
Examples::
python examples/offline_inference/diffusion/ar_diffusion_streaming_decode.py \
--image scene.png --prompt "a lit hallway" \
--action-script actions.json --output-dir /tmp/lingbot-stream
``--action-script`` is a JSON list with one camera action list per AR block,
for example ``[[["w"], ["w"], ["w"]], [["a"], [], []]]`` for a checkpoint that
generates three latent frames per block. The frames per block, the temporal
compression and the number of blocks that fit the condition horizon are read
off the checkpoint rather than assumed, so the script can be repointed with
``--model``.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import os
import time
from collections.abc import Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
# Only for type hints -- the real imports stay deferred inside run() so
# --help and the offline validation helpers don't require a CUDA-enabled
# vLLM install.
import numpy as np
_MODEL = "robbyant/lingbot-world-v2-14b-causal-fast-diffusers"
# Mirrors _MAX_RAW_FRAMES in vllm_omni/diffusion/models/lingbot_world/pipeline.py:
# the image-condition horizon is the pipeline's constant, not the checkpoint's.
_MAX_RAW_FRAMES = 117
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Stream decoded LingBot-World 2.0 video chunks from a single stepwise request.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("--model", default=_MODEL, help="Hugging Face model ID or local checkpoint path.")
parser.add_argument("--image", required=True, help="Initial RGB image.")
parser.add_argument("--prompt", required=True, help="Scene prompt for the rollout.")
parser.add_argument(
"--action-script",
required=True,
help=(
"JSON file holding one camera action list per AR block. Both the per-block frame count "
"and the number of blocks that fit are read off the checkpoint, not assumed."
),
)
parser.add_argument("--output-dir", required=True, help="Directory for decoded frames and metadata.")
parser.add_argument("--request-id", default="lingbot-world-stream", help="Request id; also the session id.")
parser.add_argument("--height", type=int, default=480)
parser.add_argument("--width", type=int, default=832)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--gpu-memory-fraction", type=float, default=0.6)
parser.add_argument("--tensor-parallel-size", type=int, default=1)
parser.add_argument("--enforce-eager", action="store_true")
return parser.parse_args(argv)
def _load_action_script(path: Path) -> list[list[list[str]]]:
"""Parse the per-block camera action script, without assuming its geometry.
The shape is the one ``sampling_params.extra_args["camera_action_script"]``
takes: one entry per AR block, each entry one per-frame action list. How
many frames a block holds is a property of the checkpoint, so it is checked
in :func:`_validate_against_checkpoint` once the configs have been read.
"""
try:
value = json.loads(path.read_text())
except json.JSONDecodeError as exc:
raise ValueError(f"--action-script is not valid JSON: {exc.msg}.") from None
if not isinstance(value, list) or not value:
raise ValueError("--action-script must be a non-empty JSON list of per-block action lists.")
script: list[list[list[str]]] = []
for block_index, block in enumerate(value):
if not isinstance(block, list) or not block:
raise ValueError(f"--action-script block {block_index} must be a non-empty list of frame action lists.")
frames: list[list[str]] = []
for frame in block:
if not isinstance(frame, list) or any(not isinstance(action, str) for action in frame):
raise ValueError(f"--action-script block {block_index} frames must contain only action strings.")
frames.append(list(frame))
script.append(frames)
return script
def _model_config_json(model: str, relative_path: str) -> dict[str, Any]:
"""Read one config file out of a local checkpoint or a Hub repo."""
if os.path.isdir(model):
return json.loads((Path(model) / relative_path).read_text())
from huggingface_hub import hf_hub_download
return json.loads(Path(hf_hub_download(repo_id=model, filename=relative_path)).read_text())
def _checkpoint_geometry(model: str) -> tuple[int, int]:
"""Read ``(frames_per_block, temporal_compression)`` off the checkpoint.
The pipeline takes both from the model --
``transformer.config.num_frames_per_block`` and
``vae.config.scale_factor_temporal`` -- so hardcoding 3 and 4 here would
silently build a wrong action script and an inconsistent ``num_frames`` the
moment ``--model`` points at a checkpoint with different geometry.
"""
transformer_config = _model_config_json(model, "transformer/config.json")
vae_config = _model_config_json(model, "vae/config.json")
if "num_frames_per_block" not in transformer_config:
raise ValueError(
f"{model}'s transformer/config.json declares no num_frames_per_block, "
"so this checkpoint does not generate AR blocks and cannot be streamed this way."
)
frames_per_block = int(transformer_config["num_frames_per_block"])
temporal_compression = int(vae_config.get("scale_factor_temporal", 4))
if frames_per_block <= 0 or temporal_compression <= 0:
raise ValueError(
"the checkpoint reports a non-positive block geometry "
f"(num_frames_per_block={frames_per_block}, scale_factor_temporal={temporal_compression})."
)
return frames_per_block, temporal_compression
def _validate_against_checkpoint(
script: list[list[list[str]]], *, frames_per_block: int, temporal_compression: int
) -> int:
"""Check the script against the checkpoint and return the frame count to request."""
for block_index, block in enumerate(script):
if len(block) != frames_per_block:
raise ValueError(
f"--action-script block {block_index} holds {len(block)} frame action lists, but this "
f"checkpoint generates {frames_per_block} latent frames per AR block."
)
max_chunks = ((_MAX_RAW_FRAMES - 1) // temporal_compression + 1) // frames_per_block
if len(script) > max_chunks:
raise ValueError(
f"--action-script holds {len(script)} blocks, but this checkpoint's "
f"{_MAX_RAW_FRAMES}-frame image-condition horizon fits at most {max_chunks}."
)
return (len(script) * frames_per_block - 1) * temporal_compression + 1
def _validate_args(args: argparse.Namespace) -> tuple[Path, Path, Path]:
image = Path(args.image).expanduser().resolve()
action_script = Path(args.action_script).expanduser().resolve()
output_dir = Path(args.output_dir).expanduser().resolve()
if not image.is_file():
raise ValueError("--image must point to an existing file.")
if not action_script.is_file():
raise ValueError("--action-script must point to an existing JSON file.")
if not args.prompt.strip():
raise ValueError("--prompt must contain non-whitespace text.")
if args.height <= 0 or args.width <= 0 or args.height % 16 or args.width % 16:
raise ValueError("--height and --width must be positive multiples of 16.")
if args.tensor_parallel_size <= 0:
raise ValueError("--tensor-parallel-size must be positive.")
if not math.isfinite(args.gpu_memory_fraction) or not 0 < args.gpu_memory_fraction <= 1:
raise ValueError("--gpu-memory-fraction must be in (0, 1].")
if not args.request_id.strip():
raise ValueError("--request-id must contain non-whitespace text.")
return image, action_script, output_dir
def _as_frames(value: Any) -> np.ndarray:
"""Normalize one streamed chunk to ``[T, H, W, 3]`` uint8.
``output_type="np"`` reaches the client as a float array in ``[0, 1]``,
with or without the leading batch axis depending on how the formatter
unwrapped it; a nested list is the ``"pil"`` shape. Anything else means
the chunk is not pixels -- most likely the request still asked for
latents -- and is worth failing on rather than saving.
"""
import numpy as np
import PIL.Image
import torch
if isinstance(value, list):
if len(value) == 1 and isinstance(value[0], list):
value = value[0]
if value and all(isinstance(frame, PIL.Image.Image) for frame in value):
return np.stack([np.asarray(frame.convert("RGB")) for frame in value])
if isinstance(value, torch.Tensor):
value = value.detach().float().cpu().numpy()
if not isinstance(value, np.ndarray):
raise RuntimeError(f"Expected decoded pixels from each AR block, got {type(value).__name__}.")
if value.ndim == 5 and value.shape[0] == 1:
value = value[0]
if value.ndim != 4 or value.shape[-1] != 3:
raise RuntimeError(f"Expected one [T, H, W, 3] pixel chunk per AR block, got shape {tuple(value.shape)}.")
if value.dtype != np.uint8:
value = (np.clip(value, 0.0, 1.0) * 255.0).round().astype(np.uint8)
return value
def _chunk_metadata(output: Any) -> dict[str, Any]:
multimodal = getattr(output, "multimodal_output", None) or {}
metadata = multimodal.get("metadata") if isinstance(multimodal, dict) else None
ar_diffusion = metadata.get("ar_diffusion") if isinstance(metadata, dict) else None
if not isinstance(ar_diffusion, dict):
raise RuntimeError("Streamed chunk is missing its ar_diffusion metadata envelope.")
return dict(ar_diffusion)
async def run(argv: Sequence[str] | None = None) -> Path:
args = parse_args(argv)
image, action_script_path, output_dir = _validate_args(args)
camera_action_script = _load_action_script(action_script_path)
output_dir.mkdir(parents=True, exist_ok=True)
# Imports stay below pure input validation so --help and helper tests do not
# require a CUDA-enabled vLLM installation.
import numpy as np
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
frames_per_block, temporal_compression = _checkpoint_geometry(args.model)
num_chunks = len(camera_action_script)
num_frames = _validate_against_checkpoint(
camera_action_script,
frames_per_block=frames_per_block,
temporal_compression=temporal_compression,
)
engine = AsyncOmni(
model=args.model,
engine_backend="vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine",
enforce_eager=args.enforce_eager,
tensor_parallel_size=args.tensor_parallel_size,
max_num_seqs=1,
# One request, many chunks: the stepwise contract from #6844. Streaming
# output is what turns each post_decode() into a delivered chunk.
step_execution=True,
diffusion_streaming_output=True,
model_config={
"ar_diffusion_height": args.height,
"ar_diffusion_width": args.width,
"ar_diffusion_kv_config": {
"gpu_memory_fraction": args.gpu_memory_fraction,
"warmup_cudagraph": True,
},
},
)
sampling = OmniDiffusionSamplingParams(
height=args.height,
width=args.width,
num_frames=num_frames,
num_inference_steps=4,
max_sequence_length=512,
seed=args.seed,
# Pixels, not latents: this is what routes the chunk through the
# session's streaming VAE decode instead of handing back a tensor.
output_type="np",
extra_args={"flow_shift": 5.0, "camera_action_script": camera_action_script},
)
prompt = {"prompt": args.prompt, "multi_modal_data": {"image": str(image)}}
measurements: list[dict[str, Any]] = []
t_start = time.perf_counter()
first_frame_at: float | None = None
chunk_started = t_start
try:
async for output in engine.generate(prompt, sampling, request_id=args.request_id):
images = getattr(output, "images", None)
if not images:
continue
if len(images) != 1:
raise RuntimeError("Expected exactly one decoded chunk per AR block.")
frames = _as_frames(images[0])
now = time.perf_counter()
if first_frame_at is None:
first_frame_at = now - t_start
chunk_index = len(measurements)
metadata = _chunk_metadata(output)
np.save(output_dir / f"chunk_{chunk_index:03d}.npy", frames)
(output_dir / f"chunk_{chunk_index:03d}.json").write_text(
json.dumps(metadata, indent=2, sort_keys=True) + "\n"
)
measurements.append(
{
"chunk_index": chunk_index,
"chunk_latency_seconds": now - chunk_started,
"frames": int(frames.shape[0]),
"frame_shape": list(frames.shape),
"metadata": metadata,
}
)
chunk_started = now
print(json.dumps(measurements[-1], sort_keys=True), flush=True)
finally:
engine.shutdown()
delivered_frames = sum(int(chunk["frames"]) for chunk in measurements)
summary_path = output_dir / "summary.json"
summary_path.write_text(
json.dumps(
{
"request_id": args.request_id,
"chunks_delivered": len(measurements),
"chunks_expected": num_chunks,
"frames_delivered": delivered_frames,
"frames_expected": num_frames,
# The point of session-owned decode: a streamed rollout carries
# the same frame timeline as an offline decode of the same
# latents. Per-chunk decode would deliver fewer, because every
# block would re-expand its own opening frame.
"matches_offline_frame_timeline": delivered_frames == num_frames,
"time_to_first_frame_seconds": first_frame_at,
"total_seconds": time.perf_counter() - t_start,
"chunks": measurements,
},
indent=2,
sort_keys=True,
)
+ "\n"
)
return summary_path
def main(argv: Sequence[str] | None = None) -> Path:
return asyncio.run(run(argv))
if __name__ == "__main__":
main()