| """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) + <Image> + LATENT*N + </Image> + 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]) |
| |
| while prompt_ids and prompt_ids[-1] == eos: |
| prompt_ids.pop() |
|
|
| latent_ph = cfg.flow_latent_placeholder_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) |
| seq_len = input_ids.shape[1] |
| latent_start = len(prompt_ids) + 1 |
| 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 |
| flow_positions = torch.tensor([[latent_start, latent_end]], dtype=torch.int32, device=device) |
| g_seqlens = flow_positions.clone() |
| |
| |
| 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, |
| flow_positions=None, |
| g_seqlens=meta["g_seqlens"], |
| ) |
| |
| 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 |
| 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"]) |
|
|
| |
| latent_pos_ids = get_2d_position_ids(h_lat, w_lat, cfg.max_latent_size).to(device) |
|
|
| |
| patch_latent_dim = p * p * cfg.vae_z_channels |
| x = torch.randn(n_latent_tokens, patch_latent_dim, device=device, dtype=dtype) |
|
|
| |
| ts = torch.linspace(1.0, 0.0, num_steps + 1, device=device, dtype=dtype) |
| inner = model.model |
|
|
| for i in range(num_steps): |
| t = ts[i] |
| dt = ts[i] - ts[i + 1] |
|
|
| |
| 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 |
|
|
| 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 |
| |
| x = x - dt * v |
|
|
| |
| x_lat = unpatchify_latent(x.float(), h_lat, w_lat, p, cfg.vae_z_channels) |
| 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, |
| ) |
|
|
| |
| 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() |
|
|