"""Minimal text-to-image inference for the packed UnifiedMoT model. Performs Euler-ODE sampling in the LLM hidden space: x_T ~ N(0, 1) for t in linspace(1, 0, num_steps + 1)[:-1]: flow_embed = vae2llm(x_t) + time_embedder(t) + latent_pos_embed hidden = model(... flow_embed injected at latent positions ...) v_pred = llm2vae(hidden_at_latent_positions) x_{t-dt} = x_t - dt * v_pred Then VAE-decodes the final x_0 to pixels. This script is single-sample / single-image. Classifier-free guidance (CFG) is optional via ``--cfg_scale`` (>1 enables it): each ODE step runs a second forward with an empty prompt, matching the 10% ``text_cond_dropout`` used in training. ``--cfg_scale 1`` (default) disables CFG for the fastest path. Run: # no CFG (fastest) python text2image_inference.py --ckpt /path/to/checkpoint \\ --prompt "a watercolor cat" \\ --vae /path/to/vae.safetensors \\ --out out.png \\ --height 256 --width 256 --num_steps 25 # with CFG python text2image_inference.py --ckpt /path/to/checkpoint \\ --prompt "a watercolor cat" \\ --vae /path/to/vae.safetensors \\ --cfg_scale 5.0 --num_steps 50 """ from __future__ import annotations import argparse import torch from PIL import Image from transformers.models.hunyuan_vl_mot import HunYuanVLMoTProcessor from model import ( UnifiedMoTConfig, UnifiedMoTForConditionalGeneration, maybe_init_generation_path, ) from model.flow_matching_modules import ( unpatchify_latent, ) from vae_model.autoencoder import load_ae def parse_args(): p = argparse.ArgumentParser() p.add_argument("--ckpt", required=True, help="Model checkpoint directory") p.add_argument("--vae", required=True, help="VAE safetensors path") p.add_argument("--prompt", required=True) p.add_argument("--out", default="out.png") p.add_argument("--height", type=int, default=256) p.add_argument("--width", type=int, default=256) p.add_argument("--num_steps", type=int, default=25) p.add_argument("--cfg_scale", type=float, default=1.0, help="Classifier-free guidance scale. >1 enables CFG " "(runs an extra empty-prompt forward per step; 2-5 typical). " "1.0 disables CFG.") p.add_argument("--seed", type=int, default=0) p.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float16", "float32"]) return p.parse_args() def get_2d_position_ids(h: int, w: int, max_per_side: int) -> torch.Tensor: """2D-flattened position ids matching model.PositionEmbedding lookup table.""" rows = torch.arange(h)[:, None] * max_per_side cols = torch.arange(w)[None, :] return (rows + cols).reshape(-1).long() @torch.no_grad() def _build_seq_meta(processor, prompt, cfg, n_latent_tokens, device): """Build the packed (1, T) input sequence + routing tensors for one prompt. Sequence: chat_template(user/assistant) + + LATENT*N + + EOS Returns a dict of everything the per-step forward needs (constant across ODE steps except the latent region, which the caller patches each step). """ eos = cfg.eos_token_id prompt_messages = [ {"role": "user", "content": [{"type": "text", "text": prompt}]}, {"role": "assistant", "content": ""}, ] prompt_inputs = processor.apply_chat_template( prompt_messages, return_dict=True, tokenize=True, add_generation_prompt=False, ) prompt_ids = list(prompt_inputs["input_ids"][0]) # Strip trailing EOS so the assistant turn flows into while prompt_ids and prompt_ids[-1] == eos: prompt_ids.pop() latent_ph = cfg.flow_latent_placeholder_id # upstream latent_token_id image_start = cfg.image_start_token_id image_end = cfg.image_end_token_id seq = ( prompt_ids + [image_start] + [latent_ph] * n_latent_tokens + [image_end] + [eos] ) input_ids = torch.tensor(seq, dtype=torch.long, device=device).unsqueeze(0) # (1, T) seq_len = input_ids.shape[1] latent_start = len(prompt_ids) + 1 # right after latent_end = latent_start + n_latent_tokens modality_mask = torch.zeros(1, seq_len, dtype=torch.long, device=device) modality_mask[0, latent_start:latent_end] = 2 # gen-latent route flow_positions = torch.tensor([[latent_start, latent_end]], dtype=torch.int32, device=device) g_seqlens = flow_positions.clone() # Single sample → packed degenerate: still feed cu_seqlens/sample_ids so the # same attention path runs as during training. cu_seqlens = torch.tensor([0, seq_len], dtype=torch.int32, device=device) sample_ids = torch.zeros(1, seq_len, dtype=torch.int32, device=device) position_ids = torch.arange(seq_len, dtype=torch.long, device=device).unsqueeze(0) return { "input_ids": input_ids, "T": seq_len, "latent_start": latent_start, "latent_end": latent_end, "modality_mask": modality_mask, "g_seqlens": g_seqlens, "cu_seqlens": cu_seqlens, "sample_ids": sample_ids, "position_ids": position_ids, } @torch.no_grad() def _forward_v(model, inner, base_embeds, flow_embed, meta, dtype): """Run one forward with `flow_embed` injected at the latent span; return velocity.""" inputs_embeds = base_embeds.clone() inputs_embeds[0, meta["latent_start"]:meta["latent_end"]] = flow_embed out = inner( input_ids=None, inputs_embeds=inputs_embeds, attention_mask=None, position_ids=meta["position_ids"], cu_seqlens=meta["cu_seqlens"], sample_ids=meta["sample_ids"], modality_mask=meta["modality_mask"], input_image_mask=torch.zeros(1, meta["T"], dtype=torch.bool, device=inputs_embeds.device), flow_embeds=None, # we already pre-built inputs_embeds flow_positions=None, g_seqlens=meta["g_seqlens"], ) # FM velocity target during training is v = noise - x_0 return model.llm2vae(out.hidden_states[0, meta["latent_start"]:meta["latent_end"]]).to(dtype) @torch.no_grad() def generate_image( model: UnifiedMoTForConditionalGeneration, vae, processor: HunYuanVLMoTProcessor, prompt: str, height: int, width: int, num_steps: int, device, dtype, cfg_scale: float = 1.0, ): """T2I single-sample sampling (optional CFG). Returns a (3, H, W) tensor in [-1, 1]. With ``cfg_scale > 1`` each ODE step runs cond + uncond forwards and combines v = v_uncond + cfg_scale * (v_cond - v_uncond) The uncond branch uses an empty prompt, matching text_cond_dropout=0.1 in training where 10% of samples have the caption replaced with "". """ cfg: UnifiedMoTConfig = model.config p = model.latent_patch_size downsample = cfg.vae_image_downsample # pixel → patch-token (e.g. 16 = VAE(8) * patch(2)) h_lat = height // downsample w_lat = width // downsample n_latent_tokens = h_lat * w_lat do_cfg = (cfg_scale != 1.0) embed_layer = model.get_input_embeddings() cond_meta = _build_seq_meta(processor, prompt, cfg, n_latent_tokens, device) cond_base = embed_layer(cond_meta["input_ids"]) if do_cfg: uncond_meta = _build_seq_meta(processor, "", cfg, n_latent_tokens, device) uncond_base = embed_layer(uncond_meta["input_ids"]) # 2D position ids for latent_pos_embed lookup latent_pos_ids = get_2d_position_ids(h_lat, w_lat, cfg.max_latent_size).to(device) # Initial noise x_T patch_latent_dim = p * p * cfg.vae_z_channels x = torch.randn(n_latent_tokens, patch_latent_dim, device=device, dtype=dtype) # Euler ODE: t from 1.0 → 0.0 in `num_steps` ts = torch.linspace(1.0, 0.0, num_steps + 1, device=device, dtype=dtype) inner = model.model # UnifiedMoTModel for i in range(num_steps): t = ts[i] dt = ts[i] - ts[i + 1] # positive # Build flow_embed for the current x_t (shared by cond & uncond branches) time_emb = model.time_embedder(t.expand(n_latent_tokens)).to(dtype) x_proj = model.vae2llm(x.to(model.vae2llm.weight.dtype)).to(dtype) pos_emb = model.latent_pos_embed(latent_pos_ids).to(dtype) flow_embed = x_proj + time_emb + pos_emb # (n_latent_tokens, D) v_cond = _forward_v(model, inner, cond_base, flow_embed, cond_meta, dtype) if do_cfg: v_uncond = _forward_v(model, inner, uncond_base, flow_embed, uncond_meta, dtype) v = v_uncond + cfg_scale * (v_cond - v_uncond) else: v = v_cond # Euler step: x_{t-dt} = x_t - dt * v x = x - dt * v # Unpatchify + VAE decode x_lat = unpatchify_latent(x.float(), h_lat, w_lat, p, cfg.vae_z_channels) # (C, H_lat, W_lat) vae_dtype = next(vae.parameters()).dtype x_lat = x_lat.unsqueeze(0).to(device=device, dtype=vae_dtype) img = vae.decode(x_lat) if hasattr(img, "sample"): img = img.sample return img.squeeze(0).float().clamp(-1, 1) def main(): args = parse_args() 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) print(f"Loading processor from {args.ckpt}...") processor = HunYuanVLMoTProcessor.from_pretrained(args.ckpt, trust_remote_code=True) print(f"Loading model from {args.ckpt}...") model = UnifiedMoTForConditionalGeneration.from_pretrained(args.ckpt, dtype=dtype) maybe_init_generation_path(model, model_load_path=args.ckpt) model.to(device) model.eval() print(f"Loading VAE from {args.vae}...") vae, _ = load_ae(args.vae) vae.requires_grad_(False) vae.eval() vae.to(device, dtype=dtype) cfg_note = f", CFG {args.cfg_scale}" if args.cfg_scale != 1.0 else " (no CFG)" print(f"Generating: '{args.prompt}' @ {args.width}x{args.height}, {args.num_steps} ODE steps{cfg_note}") img = generate_image( model, vae, processor, args.prompt, height=args.height, width=args.width, num_steps=args.num_steps, device=device, dtype=dtype, cfg_scale=args.cfg_scale, ) # Save: (C, H, W) in [-1, 1] → uint8 PNG arr = ((img.cpu().permute(1, 2, 0).numpy() + 1.0) * 127.5).clip(0, 255).astype("uint8") Image.fromarray(arr).save(args.out) print(f"Saved → {args.out}") if __name__ == "__main__": main()