Spaces:
Running on Zero
Running on Zero
| """Interleaved multi-image inference with a SigLIP-context handoff. | |
| Generates a sequence of frames autoregressively: | |
| for k in 1..Y: | |
| seq_k = chat_template(user[obs + decoded f_1..f_{k-1}] + text) + <Image>+LAT*N+</Image>+EOS | |
| x_0 = Euler-ODE(seq_k) # prior frames condition via SigLIP | |
| f_k = VAE.decode(x_0) # decode to pixels | |
| # f_k is fed (as a SigLIP image) into seq_{k+1} | |
| The crucial property: a previously generated frame enters the next step's context | |
| as a **SigLIP-encoded image in a user turn** — NOT as its VAE latent — so the | |
| context representation is identical at train and test (clean pixels either way), | |
| avoiding the noised-VAE-latent train/test mismatch of single-sequence interleave. | |
| `build_conditioned_sequence` is the single sequence constructor; training builds | |
| the prompt the same way (apply_chat_template([user(images)+text, assistant:""]) | |
| -> strip EOS -> <Image>+LAT*N+</Image>+EOS), so train and infer token layouts | |
| match exactly. | |
| Run: | |
| python interleave_inference.py --ckpt <ckpt> --vae <vae> \ | |
| --frames obs.jpg --task "put the cup on the shelf" \ | |
| --max_frames 3 --num_steps 50 --out_dir out_interleave | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import os | |
| from typing import List, Optional | |
| import torch | |
| from PIL import Image | |
| from transformers.models.hunyuan_vl_mot import HunYuanVLMoTProcessor | |
| # NOTE: heavy imports (model package -> flash_attn, vae_model) are done lazily | |
| # inside the functions that need them, so `build_conditioned_sequence` can be | |
| # imported with only a processor. | |
| # Input-image placeholder token ids the upstream HunYuanVL processor scatters | |
| # ViT features into. | |
| INPUT_IMAGE_PLACEHOLDER_IDS = (120687, 120688) | |
| def build_conditioned_sequence(processor, input_image_paths: List[str], prompt_text: str, | |
| prior_steps=None, task_instruction: Optional[str] = None): | |
| """Build the prompt token ids + processor vision tensors for a | |
| (multi-image + text) -> 1-generated-image sample. | |
| apply_chat_template([user(...), assistant:""]) then strip trailing EOS. | |
| prior_steps: optional list of (text, frame_path) for already-produced steps. | |
| When given, the user turn is the interleaved context the JOINT converter | |
| emits — [obs frames] + task + [text_1, frame_1, ..., text_m, frame_m] — | |
| so prior plan texts stay in context and prior frames enter as SigLIP inputs. | |
| input_image_paths are then the OBSERVATION frames only. | |
| When None, the legacy [all images] + text layout is used. | |
| task_instruction: optional mode instruction appended as the LAST user-text item, | |
| e.g. "Generate interleave goal planning". | |
| Returns (prompt_ids: list[int], proc_inputs: dict). | |
| """ | |
| user_content = [{"type": "image", "image": p} for p in input_image_paths] | |
| user_content.append({"type": "text", "text": prompt_text}) | |
| for t, f in (prior_steps or []): | |
| if t: | |
| user_content.append({"type": "text", "text": t}) | |
| user_content.append({"type": "image", "image": f}) | |
| if task_instruction: | |
| user_content.append({"type": "text", "text": task_instruction}) | |
| prompt_messages = [ | |
| {"role": "user", "content": user_content}, | |
| {"role": "assistant", "content": ""}, | |
| ] | |
| proc_inputs = processor.apply_chat_template( | |
| prompt_messages, return_dict=True, tokenize=True, add_generation_prompt=False, | |
| ) | |
| eos = processor.tokenizer.eos_token_id | |
| prompt_ids = list(proc_inputs["input_ids"][0]) | |
| while prompt_ids and prompt_ids[-1] == eos: | |
| prompt_ids.pop() | |
| return prompt_ids, proc_inputs | |
| def generate_step_joint( | |
| model, vae, processor, | |
| input_image_paths: List[str], | |
| task_text: str, | |
| height: int, width: int, num_steps: int, | |
| device, dtype, | |
| max_text_tokens: int = 64, | |
| prior_steps=None, | |
| ): | |
| """v2 joint step: autoregressively decode the plan TEXT, then (on <Image>) | |
| flow-match the FRAME. Returns (text_str, image_tensor). | |
| prior_steps: list of (text, frame_path) already produced — threaded into the | |
| context so the rollout matches the JOINT training layout (prior texts kept, | |
| prior frames as SigLIP). When given, input_image_paths = observation frames only. | |
| The prefix is built by the SAME `build_conditioned_sequence` used at train | |
| time, so the text the model emits after `</answer>` and the <Image> trigger | |
| match what training taught.""" | |
| from model.flow_matching_modules import unpatchify_latent | |
| from text2image_inference import get_2d_position_ids | |
| cfg = model.config | |
| eos = cfg.eos_token_id | |
| image_start = cfg.image_start_token_id | |
| latent_ph = cfg.flow_latent_placeholder_id | |
| inner = model.model | |
| # Append the interleave mode instruction (train==infer parity). | |
| from inference_utils import TASK_INSTRUCTION_INTERLEAVE | |
| prompt_ids, proc = build_conditioned_sequence(processor, input_image_paths, task_text, | |
| prior_steps=prior_steps, | |
| task_instruction=TASK_INSTRUCTION_INTERLEAVE) | |
| pixel_values = proc.get("pixel_values") | |
| image_grid_thw = proc.get("image_grid_thw") | |
| if pixel_values is not None: | |
| pixel_values = pixel_values.to(device=device, dtype=dtype) | |
| if image_grid_thw is not None: | |
| image_grid_thw = image_grid_thw.to(device=device) | |
| # ---- 1. autoregressive text decode until <Image> (KV-cached: exact, ~2.8x) ---- | |
| # prefill the prompt once (use_cache), then feed one token/step against the growing | |
| # KV cache — the model's native past_key_values plumbing (Mode C handles the decode | |
| # shape). Validated bit-identical to the non-cached per-token full-forward decode. | |
| from transformers.cache_utils import DynamicCache | |
| def _dmask(ids): | |
| seq_len = len(ids) | |
| inp = torch.tensor(ids, dtype=torch.long, device=device).unsqueeze(0) | |
| mod = torch.zeros(1, seq_len, dtype=torch.long, device=device) | |
| iim = torch.zeros(1, seq_len, dtype=torch.bool, device=device) | |
| for pid in INPUT_IMAGE_PLACEHOLDER_IDS: | |
| m = inp[0] == pid | |
| mod[0, m] = 1 | |
| iim[0, m] = True | |
| return inp, mod, iim | |
| _empty_g = torch.zeros((0, 2), dtype=torch.int32, device=device) | |
| pkv = DynamicCache() | |
| prompt_len = len(prompt_ids) | |
| inp, mod, iim = _dmask(list(prompt_ids)) | |
| out = inner(input_ids=inp, position_ids=torch.arange(prompt_len, device=device).unsqueeze(0), | |
| pixel_values=pixel_values, image_grid_thw=image_grid_thw, | |
| cu_seqlens=torch.tensor([0, prompt_len], dtype=torch.int32, device=device), | |
| sample_ids=torch.zeros(1, prompt_len, dtype=torch.int32, device=device), | |
| modality_mask=mod, input_image_mask=iim, | |
| flow_embeds=None, flow_positions=None, g_seqlens=_empty_g, | |
| use_cache=True, past_key_values=pkv, cache_position=torch.arange(prompt_len, device=device)) | |
| pkv = out.past_key_values | |
| nxt = int(out.logits[0, -1].argmax().item()) | |
| cur = prompt_len | |
| text_ids = [] | |
| for _ in range(max_text_tokens): | |
| if nxt == image_start or nxt == eos: | |
| break | |
| text_ids.append(nxt) | |
| out = inner(input_ids=torch.tensor([[nxt]], dtype=torch.long, device=device), | |
| position_ids=torch.tensor([[cur]], device=device), | |
| pixel_values=None, image_grid_thw=None, | |
| cu_seqlens=torch.tensor([0, cur + 1], dtype=torch.int32, device=device), | |
| sample_ids=torch.zeros(1, 1, dtype=torch.int32, device=device), | |
| modality_mask=torch.zeros(1, 1, dtype=torch.long, device=device), | |
| input_image_mask=torch.zeros(1, 1, dtype=torch.bool, device=device), | |
| flow_embeds=None, flow_positions=None, g_seqlens=_empty_g, | |
| use_cache=True, past_key_values=pkv, cache_position=torch.tensor([cur], device=device)) | |
| pkv = out.past_key_values | |
| nxt = int(out.logits[0, -1].argmax().item()) | |
| cur += 1 | |
| seq = list(prompt_ids) + text_ids | |
| text_str = processor.tokenizer.decode(text_ids, skip_special_tokens=True) | |
| # ---- 2. flow-match the frame — diffusion PREFIX-CACHE: prefill (prompt+text+IMAGE_START) | |
| # once, then each denoise step forwards ONLY the n latent tokens against the cached prefix | |
| # (Mode C causal=False -> latents attend bidirectionally to prefix+latents, == the gen-block). | |
| # Validated equivalent to the full-forward loop within ROCm non-determinism. ---- | |
| p = model.latent_patch_size | |
| ds = cfg.vae_image_downsample | |
| h_lat, w_lat = height // ds, width // ds | |
| n_latent = h_lat * w_lat | |
| prefix = seq + [image_start] | |
| prefix_len = len(prefix) # = latent_start | |
| inp_p, mod_p, iim_p = _dmask(prefix) | |
| pkv_f = DynamicCache() | |
| inner(input_ids=inp_p, position_ids=torch.arange(prefix_len, device=device).unsqueeze(0), | |
| pixel_values=pixel_values, image_grid_thw=image_grid_thw, | |
| cu_seqlens=torch.tensor([0, prefix_len], dtype=torch.int32, device=device), | |
| sample_ids=torch.zeros(1, prefix_len, dtype=torch.int32, device=device), | |
| modality_mask=mod_p, input_image_mask=iim_p, | |
| flow_embeds=None, flow_positions=None, g_seqlens=_empty_g, | |
| use_cache=True, past_key_values=pkv_f, cache_position=torch.arange(prefix_len, device=device)) | |
| latent_pos_ids = get_2d_position_ids(h_lat, w_lat, cfg.max_latent_size).to(device) | |
| latent_pos_emb = model.latent_pos_embed(latent_pos_ids).to(dtype) | |
| lat_ids = torch.tensor([[latent_ph] * n_latent], dtype=torch.long, device=device) | |
| fp_rel = torch.tensor([[0, n_latent]], dtype=torch.int32, device=device) # rel to the n-token input | |
| mod2 = torch.full((1, n_latent), 2, dtype=torch.long, device=device) | |
| iim2 = torch.zeros(1, n_latent, dtype=torch.bool, device=device) | |
| pos_l = torch.arange(prefix_len, prefix_len + n_latent, device=device).unsqueeze(0) | |
| cu_l = torch.tensor([0, prefix_len + n_latent], dtype=torch.int32, device=device) | |
| sid_l = torch.zeros(1, n_latent, dtype=torch.int32, device=device) | |
| x = torch.randn(n_latent, p * p * cfg.vae_z_channels, device=device, dtype=dtype) | |
| ts = torch.linspace(1.0, 0.0, num_steps + 1, device=device, dtype=dtype) | |
| for i in range(num_steps): | |
| t, dt = ts[i], ts[i] - ts[i + 1] | |
| fe = (model.vae2llm(x.to(model.vae2llm.weight.dtype)).to(dtype) | |
| + model.time_embedder(t.expand(n_latent)).to(dtype) + latent_pos_emb) | |
| out = inner(input_ids=lat_ids, inputs_embeds=None, attention_mask=None, | |
| position_ids=pos_l, pixel_values=None, image_grid_thw=None, | |
| cu_seqlens=cu_l, sample_ids=sid_l, modality_mask=mod2, input_image_mask=iim2, | |
| flow_embeds=fe, flow_positions=fp_rel, g_seqlens=_empty_g, | |
| use_cache=True, past_key_values=pkv_f, cache_position=pos_l[0]) | |
| v = model.llm2vae(out.hidden_states[0, 0:n_latent]).to(dtype) | |
| x = x - dt * v | |
| pkv_f.crop(prefix_len) # drop the latents; keep the fixed prefix for next step | |
| x_lat = unpatchify_latent(x.float(), h_lat, w_lat, p, cfg.vae_z_channels) | |
| x_lat = x_lat.unsqueeze(0).to(device=device, dtype=next(vae.parameters()).dtype) | |
| img = vae.decode(x_lat) | |
| if hasattr(img, "sample"): | |
| img = img.sample | |
| return text_str, img.squeeze(0).float().clamp(-1, 1) | |
| def interleave_generate_joint( | |
| model, vae, processor, | |
| obs_frames: List[str], task_text: str, num_frames: int, | |
| height: int, width: int, num_steps: int, device, dtype, out_dir: str, | |
| max_text_tokens: int = 64, | |
| ): | |
| """v2 rollout: at each step the model emits plan text + a frame; the decoded | |
| frame AND its plan text are fed back as context for the next step — prior | |
| frames via SigLIP, prior texts kept — matching the JOINT training layout.""" | |
| import os | |
| os.makedirs(out_dir, exist_ok=True) | |
| gen_paths, texts = [], [] | |
| prior_steps = [] # (text, frame_path) accumulated across steps | |
| for k in range(num_frames): | |
| text, img = generate_step_joint( | |
| model, vae, processor, list(obs_frames), task_text, | |
| height, width, num_steps, device, dtype, max_text_tokens, | |
| prior_steps=list(prior_steps), | |
| ) | |
| arr = ((img.cpu().permute(1, 2, 0).numpy() + 1.0) * 127.5).clip(0, 255).astype("uint8") | |
| path = os.path.join(out_dir, f"joint_step{k+1}.png") | |
| Image.fromarray(arr).save(path) | |
| gen_paths.append(path) | |
| texts.append(text) | |
| prior_steps.append((text, path)) | |
| print(f" [joint step {k+1}/{num_frames}] prior={len(prior_steps)-1} | TEXT: {text[:80]!r} -> {path}") | |
| return gen_paths, texts | |
| def _row(paths, w, h): | |
| """Horizontal strip of images (resized to w x h, 4px gaps).""" | |
| imgs = [Image.open(p).convert("RGB").resize((w, h)) for p in paths] | |
| n = len(imgs) | |
| strip = Image.new("RGB", (w * n + 4 * (n - 1), h), (20, 20, 20)) | |
| for i, im in enumerate(imgs): | |
| strip.paste(im, (i * (w + 4), 0)) | |
| return strip | |
| def main(): | |
| ap = argparse.ArgumentParser(description="Joint interleaved rollout: decode ALL plan text + ALL frames.") | |
| ap.add_argument("--ckpt", required=True) | |
| ap.add_argument("--vae", required=True) | |
| ap.add_argument("--frames", nargs="+", required=True, help="observation frame path(s)") | |
| ap.add_argument("--task", required=True, help="overall task text") | |
| ap.add_argument("--max_frames", type=int, default=None, help="cap #frames to generate") | |
| ap.add_argument("--out_dir", default="out_interleave") | |
| ap.add_argument("--height", type=int, default=144) | |
| ap.add_argument("--width", type=int, default=256) | |
| ap.add_argument("--num_steps", type=int, default=50, help="ODE steps per frame") | |
| ap.add_argument("--max_text_tokens", type=int, default=96) | |
| ap.add_argument("--seed", type=int, default=0) | |
| ap.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float16", "float32"]) | |
| ap.add_argument("--understanding_max_pixels", type=int, default=524288, | |
| help="Cap obs/prior-frame ViT input pixels to MATCH training. " | |
| "Default 524288 (~494 tok/frame); the model default 4194304 (~4050 tok/frame) " | |
| "is an 8x train/infer resolution mismatch that degrades eval. Set 0 to disable.") | |
| args = ap.parse_args() | |
| from model import UnifiedMoTForConditionalGeneration, maybe_init_generation_path | |
| from vae_model.autoencoder import load_ae | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| dtype = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}[args.dtype] | |
| torch.manual_seed(args.seed) | |
| # ---- resolve obs frames + task ---- | |
| obs, task, n_frames = args.frames, args.task, (args.max_frames or 1) | |
| processor = HunYuanVLMoTProcessor.from_pretrained(args.ckpt, trust_remote_code=True) | |
| # train==infer parity: cap obs/prior-frame ViT pixels to the SAME value training | |
| # used. Prior frames re-enter via SigLIP at rollout, so this must match training too. | |
| if args.understanding_max_pixels and args.understanding_max_pixels > 0: | |
| ip = processor.image_processor | |
| ip.max_pixels = args.understanding_max_pixels | |
| if isinstance(getattr(ip, "size", None), dict) and "longest_edge" in ip.size: | |
| ip.size["longest_edge"] = args.understanding_max_pixels | |
| model = UnifiedMoTForConditionalGeneration.from_pretrained(args.ckpt, dtype=dtype) | |
| maybe_init_generation_path(model, model_load_path=args.ckpt) | |
| model.to(device).eval() | |
| vae, _ = load_ae(args.vae) | |
| vae.requires_grad_(False) | |
| vae.eval() | |
| vae.to(device, dtype=dtype) | |
| os.makedirs(args.out_dir, exist_ok=True) | |
| print(f"JOINT interleaved rollout | obs={len(obs)} frame(s) | steps={n_frames}") | |
| print(f"TASK: {task}\n") | |
| gen_paths, gen_texts = interleave_generate_joint( | |
| model, vae, processor, obs, task, n_frames, | |
| args.height, args.width, args.num_steps, device, dtype, args.out_dir, | |
| max_text_tokens=args.max_text_tokens, | |
| ) | |
| # ---- write the FULL decoded output (text) + montages (image) ---- | |
| lines = [f"TASK: {task}", ""] | |
| for k in range(len(gen_texts)): | |
| lines.append(f"--- step {k+1} ---") | |
| lines.append(f"GEN text: {gen_texts[k]}") | |
| lines.append("") | |
| txt_path = os.path.join(args.out_dir, "result.txt") | |
| open(txt_path, "w").write("\n".join(lines)) | |
| print("\n".join(lines)) | |
| # image montage: GEN row | |
| width, height = args.width, args.height | |
| gen_row = _row(gen_paths, width, height) | |
| gen_row.save(os.path.join(args.out_dir, "rollout_GEN.png")) | |
| print(f"\nDecoded {len(gen_paths)} frames + {len(gen_texts)} texts -> {args.out_dir}") | |
| print(f" text : {txt_path}") | |
| print(f" images: {args.out_dir}/joint_step*.png + rollout montage") | |
| if __name__ == "__main__": | |
| main() | |