from __future__ import annotations import os from typing import List, Tuple import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from einops import einsum, rearrange from PIL import Image from safetensors.torch import load_file BASE_DIR = os.path.dirname(os.path.abspath(__file__)) CHECKPOINT_PATH = os.path.join(BASE_DIR, "model", "model.safetensors") MODEL_CONFIG = { "model_type": "image_dit", "label_vocab_size": 11, "vocab_size": 257, "pixel_bins": 256, "context_length": 784, "d_model": 256, "num_layers": 8, "num_heads": 16, "d_ff": 1024, "rope_theta": 10000.0, "attention_backend": "torch_sdpa", "attention_sdp_backend": "auto", "device": "cuda", "dtype": "float16", "null_label_id": 10, "use_rope_2d": True, "image_height": 28, "image_width": 28, } INFER_CONFIG = { "steps": 128, "cfg_scale": 2.0, "trajectory_checkpoints": 32, } DTYPES = { "float16": torch.float16, "float32": torch.float32, "bfloat16": torch.bfloat16, } ALLOWED_ATTENTION_BACKENDS = {"custom", "torch_sdpa"} ALLOWED_SDP_BACKENDS = {"auto", "flash", "mem_efficient", "math"} def _resolve_device_dtype(device: str, dtype_name: str) -> Tuple[str, torch.dtype]: resolved_device = device if device == "cuda" and not torch.cuda.is_available(): resolved_device = "cpu" resolved_dtype = DTYPES[dtype_name] if resolved_device == "cpu" and resolved_dtype == torch.float16: resolved_dtype = torch.float32 return resolved_device, resolved_dtype def set_sdp_backend(backend: str) -> None: backend = backend.lower() if backend not in ALLOWED_SDP_BACKENDS: raise ValueError(f"attention_sdp_backend must be one of {sorted(ALLOWED_SDP_BACKENDS)}") if not torch.cuda.is_available(): return if backend == "auto": torch.backends.cuda.enable_flash_sdp(True) torch.backends.cuda.enable_mem_efficient_sdp(True) torch.backends.cuda.enable_math_sdp(True) return torch.backends.cuda.enable_flash_sdp(backend == "flash") torch.backends.cuda.enable_mem_efficient_sdp(backend == "mem_efficient") torch.backends.cuda.enable_math_sdp(backend == "math") def softmax(x: torch.Tensor, dim: int): x_max = x.max(dim=dim, keepdim=True).values x_stable = x - x_max exp_x = torch.exp(x_stable) sum_exp_x = exp_x.sum(dim=dim, keepdim=True) return exp_x / sum_exp_x class Linear(nn.Module): def __init__(self, in_features, out_features, device=None, dtype=None): super().__init__() self.weight = nn.Parameter(torch.empty(out_features, in_features, device=device, dtype=dtype)) mean = 0.0 std = 2 / (in_features + out_features) a = mean - 3 * std b = mean + 3 * std nn.init.trunc_normal_(self.weight, mean=mean, std=std, a=a, b=b) def forward(self, x: torch.Tensor) -> torch.Tensor: return einsum(self.weight, x, "out_features in_features, ... in_features -> ... out_features") class Embedding(nn.Module): def __init__(self, num_embeddings, embedding_dim, device=None, dtype=None): super().__init__() self.weight = nn.Parameter(torch.empty(num_embeddings, embedding_dim, device=device, dtype=dtype)) nn.init.trunc_normal_(self.weight, mean=0, std=1, a=-3, b=3) def forward(self, token_ids: torch.Tensor) -> torch.Tensor: return self.weight[token_ids] class RMSNorm(nn.Module): def __init__(self, d_model: int, eps: float = 1e-5, device=None, dtype=None): super().__init__() self.eps = eps self.weight = nn.Parameter(torch.empty(d_model, device=device, dtype=dtype)) nn.init.ones_(self.weight) def forward(self, x: torch.Tensor) -> torch.Tensor: in_dtype = x.dtype x = x.to(torch.float32) rms = torch.sqrt(torch.mean(x**2, dim=-1) + self.eps).unsqueeze(-1) x = (1.0 / rms) * (x * self.weight) return x.to(in_dtype) class SwiGLU(nn.Module): def __init__(self, d_model: int, d_ff: int, device=None, dtype=None): super().__init__() self.w1 = Linear(d_model, d_ff, device=device, dtype=dtype) self.w2 = Linear(d_ff, d_model, device=device, dtype=dtype) self.w3 = Linear(d_model, d_ff, device=device, dtype=dtype) def forward(self, x: torch.Tensor) -> torch.Tensor: w1x = self.w1(x) w3x = self.w3(x) silu = w1x * torch.sigmoid(w1x) return self.w2(silu * w3x) class RotaryPositionalEmbedding(nn.Module): def __init__(self, theta: float, d_k: int, max_seq_len: int, device=None): super().__init__() theta_i = theta ** (torch.arange(0, d_k, 2).float() / d_k) position = torch.arange(max_seq_len) phases = position.unsqueeze(1) / theta_i.unsqueeze(0) phases_combined = torch.stack([torch.cos(phases), torch.sin(phases)], dim=-1).to(device=device) self.register_buffer("phases", phases_combined, persistent=False) def forward(self, x: torch.Tensor, token_positions: torch.Tensor) -> torch.Tensor: x = rearrange(x, "... (d_k p) -> ... d_k p", p=2) x1 = x[..., 0] x2 = x[..., 1] phases_cos = self.phases[..., 0][token_positions].to(dtype=x.dtype) phases_sin = self.phases[..., 1][token_positions].to(dtype=x.dtype) x_rotated = torch.stack( [ x1 * phases_cos - x2 * phases_sin, x1 * phases_sin + x2 * phases_cos, ], dim=-1, ) return x_rotated.flatten(-2) def _prepare_attention_mask(attention_mask: torch.Tensor, ref_tensor: torch.Tensor) -> torch.Tensor: mask = attention_mask.to(device=ref_tensor.device, dtype=torch.bool) if mask.dim() == 2: mask = mask[:, None, None, :] elif mask.dim() == 3: mask = mask[:, None, :, :] elif mask.dim() != 4: raise ValueError("attention_mask must be 2D, 3D, or 4D") return mask def scaled_dot_product_attention( q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, attention_mask: torch.Tensor | None = None, ): scale = torch.tensor(q.shape[-1], device=q.device, dtype=q.dtype).sqrt() qk_score = einsum(q, k, "batch ... n d, batch ... m d -> batch ... n m") / scale if attention_mask is not None: mask = _prepare_attention_mask(attention_mask, qk_score) qk_score = qk_score.masked_fill(~mask, float("-inf")) return einsum(softmax(qk_score, dim=-1), v, "batch ... n m, batch ... m d -> batch ... n d") def torch_scaled_dot_product_attention( q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, attention_mask: torch.Tensor | None = None, ): mask = None if attention_mask is not None: mask = _prepare_attention_mask(attention_mask, q) return F.scaled_dot_product_attention( q.contiguous(), k.contiguous(), v.contiguous(), attn_mask=mask, dropout_p=0.0, is_causal=False, ) class MultiheadSelfAttentionRoPE2D(nn.Module): def __init__( self, d_model: int, num_heads: int, max_height: int, max_width: int, theta: float, attention_backend: str = "custom", device=None, dtype=None, ): super().__init__() self.d_model = d_model self.num_heads = num_heads self.d_k = self.d_model // self.num_heads if self.d_k % 4 != 0: raise ValueError("per-head dimension must be divisible by 4 for 2D RoPE") self.d_v = self.d_k if attention_backend not in ALLOWED_ATTENTION_BACKENDS: raise ValueError(f"attention_backend must be one of {sorted(ALLOWED_ATTENTION_BACKENDS)}") self.attention_backend = attention_backend self.q_proj = Linear(d_model, d_model, device=device, dtype=dtype) self.k_proj = Linear(d_model, d_model, device=device, dtype=dtype) self.v_proj = Linear(d_model, d_model, device=device, dtype=dtype) self.output_proj = Linear(d_model, d_model, device=device, dtype=dtype) self.d_k_half = self.d_k // 2 self.row_rope = RotaryPositionalEmbedding(theta, self.d_k_half, int(max_height), device) self.col_rope = RotaryPositionalEmbedding(theta, self.d_k_half, int(max_width), device) def _apply_2d_rope(self, x: torch.Tensor, row_positions: torch.Tensor, col_positions: torch.Tensor) -> torch.Tensor: row_part = x[..., : self.d_k_half] col_part = x[..., self.d_k_half :] return torch.cat( [ self.row_rope(row_part, row_positions), self.col_rope(col_part, col_positions), ], dim=-1, ) def forward( self, x: torch.Tensor, row_positions: torch.Tensor, col_positions: torch.Tensor, attention_mask: torch.Tensor | None = None, ) -> torch.Tensor: wqx = rearrange(self.q_proj(x), "... seq (heads d) -> ... heads seq d", heads=self.num_heads, d=self.d_k) wkx = rearrange(self.k_proj(x), "... seq (heads d) -> ... heads seq d", heads=self.num_heads, d=self.d_k) wvx = rearrange(self.v_proj(x), "... seq (heads d) -> ... heads seq d", heads=self.num_heads, d=self.d_v) q = self._apply_2d_rope(wqx, row_positions, col_positions) k = self._apply_2d_rope(wkx, row_positions, col_positions) if self.attention_backend == "torch_sdpa": attn = torch_scaled_dot_product_attention(q, k, wvx, attention_mask=attention_mask) else: attn = scaled_dot_product_attention(q, k, wvx, attention_mask=attention_mask) out = rearrange(attn, "... heads seq d -> ... seq (heads d)", heads=self.num_heads, d=self.d_v) return self.output_proj(out) class MultiheadCrossAttentionRoPE2D(nn.Module): def __init__( self, d_model: int, num_heads: int, max_height: int, max_width: int, theta: float, attention_backend: str = "custom", device=None, dtype=None, ): super().__init__() self.d_model = d_model self.num_heads = num_heads self.d_k = self.d_model // self.num_heads if self.d_k % 4 != 0: raise ValueError("per-head dimension must be divisible by 4 for 2D RoPE") self.d_v = self.d_k if attention_backend not in ALLOWED_ATTENTION_BACKENDS: raise ValueError(f"attention_backend must be one of {sorted(ALLOWED_ATTENTION_BACKENDS)}") self.attention_backend = attention_backend self.q_proj = Linear(d_model, d_model, device=device, dtype=dtype) self.k_proj = Linear(d_model, d_model, device=device, dtype=dtype) self.v_proj = Linear(d_model, d_model, device=device, dtype=dtype) self.output_proj = Linear(d_model, d_model, device=device, dtype=dtype) self.d_k_half = self.d_k // 2 self.row_rope = RotaryPositionalEmbedding(theta, self.d_k_half, int(max_height), device) self.col_rope = RotaryPositionalEmbedding(theta, self.d_k_half, int(max_width), device) def _apply_2d_rope(self, x: torch.Tensor, row_positions: torch.Tensor, col_positions: torch.Tensor) -> torch.Tensor: row_part = x[..., : self.d_k_half] col_part = x[..., self.d_k_half :] return torch.cat( [ self.row_rope(row_part, row_positions), self.col_rope(col_part, col_positions), ], dim=-1, ) def forward( self, x: torch.Tensor, context: torch.Tensor, row_positions: torch.Tensor, col_positions: torch.Tensor, context_row_positions: torch.Tensor, context_col_positions: torch.Tensor, attention_mask: torch.Tensor | None = None, ) -> torch.Tensor: wqx = rearrange(self.q_proj(x), "... seq (heads d) -> ... heads seq d", heads=self.num_heads, d=self.d_k) wkx = rearrange(self.k_proj(context), "... seq (heads d) -> ... heads seq d", heads=self.num_heads, d=self.d_k) wvx = rearrange(self.v_proj(context), "... seq (heads d) -> ... heads seq d", heads=self.num_heads, d=self.d_v) q = self._apply_2d_rope(wqx, row_positions, col_positions) k = self._apply_2d_rope(wkx, context_row_positions, context_col_positions) if self.attention_backend == "torch_sdpa": attn = torch_scaled_dot_product_attention(q, k, wvx, attention_mask=attention_mask) else: attn = scaled_dot_product_attention(q, k, wvx, attention_mask=attention_mask) out = rearrange(attn, "... heads seq d -> ... seq (heads d)", heads=self.num_heads, d=self.d_v) return self.output_proj(out) class TransformerImageBlock(nn.Module): def __init__( self, d_model: int, num_heads: int, max_seq_len: int, max_height: int | None, max_width: int | None, theta: float, d_ff: int, attention_backend: str = "custom", use_rope_2d: bool = False, device=None, dtype=None, ): super().__init__() self.ffn = SwiGLU(d_model, d_ff, device, dtype) self.use_rope_2d = bool(use_rope_2d) if not self.use_rope_2d: raise ValueError("This demo vendors only the 2D RoPE image path") if max_height is None or max_width is None: raise ValueError("max_height/max_width must be provided when use_rope_2d is True") self.self_attn = MultiheadSelfAttentionRoPE2D( d_model, num_heads, max_height, max_width, theta, attention_backend=attention_backend, device=device, dtype=dtype, ) self.cross_attn = MultiheadCrossAttentionRoPE2D( d_model, num_heads, max_height, max_width, theta, attention_backend=attention_backend, device=device, dtype=dtype, ) self.ln1 = RMSNorm(d_model, device=device, dtype=dtype) self.ln2 = RMSNorm(d_model, device=device, dtype=dtype) self.ln3 = RMSNorm(d_model, device=device, dtype=dtype) def forward( self, x: torch.Tensor, context: torch.Tensor, row_positions: torch.Tensor, col_positions: torch.Tensor, context_row_positions: torch.Tensor, context_col_positions: torch.Tensor, ) -> torch.Tensor: x = x + self.self_attn(self.ln1(x), row_positions, col_positions, attention_mask=None) x = x + self.cross_attn( self.ln2(x), context, row_positions, col_positions, context_row_positions, context_col_positions, attention_mask=None, ) x = x + self.ffn(self.ln3(x)) return x def _timestep_embedding(t: torch.Tensor, dim: int, max_period: float = 10000.0) -> torch.Tensor: if t.dim() != 1: raise ValueError("t must be 1D with shape (batch,)") half = dim // 2 if half == 0: return t[:, None] freqs = torch.exp( -torch.log(torch.tensor(max_period, device=t.device, dtype=torch.float32)) * torch.arange(half, device=t.device, dtype=torch.float32) / max(half - 1, 1) ) args = t.to(torch.float32)[:, None] * freqs[None, :] emb = torch.cat([torch.sin(args), torch.cos(args)], dim=-1) if dim % 2 == 1: emb = torch.cat([emb, torch.zeros((t.shape[0], 1), device=t.device, dtype=emb.dtype)], dim=-1) return emb class DiTImage(nn.Module): def __init__( self, context_length: int, d_model: int, num_layers: int, num_heads: int, d_ff: int, rope_theta: float, label_vocab_size: int, attention_backend: str = "custom", image_height: int | None = None, image_width: int | None = None, use_rope_2d: bool = False, device=None, dtype=None, ): super().__init__() self.context_length = int(context_length) self.use_rope_2d = bool(use_rope_2d) if not self.use_rope_2d: raise ValueError("This demo expects use_rope_2d=True") if image_height is None or image_width is None: raise ValueError("image_height/image_width must be set for the flow demo") self.image_height = int(image_height) self.image_width = int(image_width) self.input_proj = Linear(1, d_model, device, dtype) self.time_proj = Linear(d_model, d_model, device, dtype) self.label_embeddings = Embedding(label_vocab_size, d_model, device, dtype) self.layers = nn.ModuleList( [ TransformerImageBlock( d_model, num_heads, context_length, self.image_height, self.image_width, rope_theta, d_ff, attention_backend=attention_backend, use_rope_2d=True, device=device, dtype=dtype, ) for _ in range(num_layers) ] ) self.ln_final = RMSNorm(d_model, device=device, dtype=dtype) self.output_proj = Linear(d_model, 1, device, dtype) def forward(self, x: torch.Tensor, t: torch.Tensor, context: torch.Tensor | None = None) -> torch.Tensor: if x.dim() != 2: raise ValueError("x must be 2D with shape (batch, seq)") if context is None or context.dim() != 1 or context.shape[0] != x.shape[0]: raise ValueError("context must be 1D with matching batch size") if t.dim() == 2 and t.shape[1] == 1: t = t[:, 0] if t.dim() != 1 or t.shape[0] != x.shape[0]: raise ValueError("t must be 1D with matching batch size") model_dtype = self.input_proj.weight.dtype output_seq = self.input_proj(x.to(dtype=model_dtype).unsqueeze(-1)) t_emb = _timestep_embedding(t, output_seq.shape[-1]).to(dtype=model_dtype) context_emb = (self.time_proj(t_emb) + self.label_embeddings(context)).unsqueeze(-2) seq_len = output_seq.shape[-2] expected = self.image_height * self.image_width if seq_len != expected: raise ValueError(f"sequence length {seq_len} does not match image_height*image_width {expected}") row_positions = torch.arange(self.image_height, device=output_seq.device, dtype=torch.long).repeat_interleave( self.image_width ) col_positions = torch.arange(self.image_width, device=output_seq.device, dtype=torch.long).repeat( self.image_height ) context_row_positions = torch.zeros(context_emb.shape[-2], device=output_seq.device, dtype=torch.long) context_col_positions = torch.zeros(context_emb.shape[-2], device=output_seq.device, dtype=torch.long) for layer in self.layers: output_seq = layer( output_seq, context_emb, row_positions, col_positions, context_row_positions, context_col_positions, ) return self.output_proj(self.ln_final(output_seq)).squeeze(-1) @torch.no_grad() def flow_image_generate( model, prompt_indices: torch.Tensor, *, context: torch.Tensor, steps: int, cfg_scale: float = 0.0, uncond_context: torch.Tensor | None = None, generator: torch.Generator | None = None, return_history: bool = False, ) -> torch.Tensor | tuple[torch.Tensor, list[tuple[int, torch.Tensor]]]: if prompt_indices.dim() != 2: raise ValueError("prompt_indices must be 2D (batch, seq)") if context.dim() != 1 or prompt_indices.shape[0] != context.shape[0]: raise ValueError("context must be 1D with matching batch size") if prompt_indices.shape[1] != 0: raise ValueError("flow_image_generate expects empty prompt_indices for full-image generation") steps = max(1, min(int(steps), 128)) batch_size = context.shape[0] gen_length = int(model.context_length) x = torch.randn((batch_size, gen_length), device=prompt_indices.device, dtype=torch.float32, generator=generator) dt = 1.0 / float(steps) if uncond_context is not None: if uncond_context.dim() != 1 or uncond_context.shape[0] != batch_size: raise ValueError("uncond_context must be 1D with matching batch size") uncond_context = uncond_context.to(device=context.device, dtype=context.dtype) history: list[tuple[int, torch.Tensor]] = [] checkpoint_count = max(32, int(INFER_CONFIG["trajectory_checkpoints"])) checkpoint_indices = np.linspace(1, steps, num=checkpoint_count, dtype=int).tolist() checkpoint_indices = sorted(set(max(1, min(steps, idx)) for idx in checkpoint_indices)) for k in range(steps): t = torch.full((batch_size,), float(k) / float(steps), device=x.device, dtype=x.dtype) if cfg_scale > 0.0: if uncond_context is None: raise ValueError("uncond_context must be set when cfg_scale > 0 for flow_image_generate") v_cond = model(x, t, context=context) v_uncond = model(x, t, context=uncond_context) v = v_uncond + (cfg_scale + 1.0) * (v_cond - v_uncond) else: v = model(x, t, context=context) x = x + dt * v step_idx = k + 1 if return_history and step_idx in checkpoint_indices: history.append((step_idx, x.detach().clone())) if return_history: if not history or history[-1][0] != steps: history.append((steps, x.detach().clone())) return x, history return x def flow_pixels_to_uint8(values: np.ndarray) -> np.ndarray: clipped = np.clip(values.astype(np.float32), -1.0, 1.0) restored = np.round((clipped + 1.0) * 127.5) return np.clip(restored, 0, 255).astype(np.uint8) MODEL = None DEVICE = None DTYPE = None def load_model(): global MODEL, DEVICE, DTYPE if MODEL is not None: return MODEL, DEVICE, DTYPE if not os.path.exists(CHECKPOINT_PATH): raise FileNotFoundError(f"Missing checkpoint at {CHECKPOINT_PATH}") device, dtype = _resolve_device_dtype(MODEL_CONFIG["device"], MODEL_CONFIG["dtype"]) set_sdp_backend(MODEL_CONFIG["attention_sdp_backend"]) model = DiTImage( context_length=MODEL_CONFIG["context_length"], d_model=MODEL_CONFIG["d_model"], num_layers=MODEL_CONFIG["num_layers"], num_heads=MODEL_CONFIG["num_heads"], d_ff=MODEL_CONFIG["d_ff"], rope_theta=MODEL_CONFIG["rope_theta"], label_vocab_size=MODEL_CONFIG["label_vocab_size"], attention_backend=MODEL_CONFIG["attention_backend"], image_height=MODEL_CONFIG["image_height"], image_width=MODEL_CONFIG["image_width"], use_rope_2d=MODEL_CONFIG["use_rope_2d"], device=device, dtype=dtype, ) model.load_state_dict(load_file(CHECKPOINT_PATH)) model.eval().to(device) MODEL = model DEVICE = device DTYPE = dtype return MODEL, DEVICE, DTYPE def _to_image(sample: torch.Tensor) -> Image.Image: h = int(MODEL_CONFIG["image_height"]) w = int(MODEL_CONFIG["image_width"]) scale = 10 arr = sample.detach().cpu().to(torch.float32).numpy().reshape(h, w) img = Image.fromarray(flow_pixels_to_uint8(arr), mode="L") if scale > 1: img = img.resize((w * scale, h * scale), resample=Image.NEAREST) return img @torch.inference_mode() def generate_images(label: int, steps: int, num_samples: int) -> List[Image.Image]: model, device, _ = load_model() num_samples = int(num_samples) label = int(label) steps = max(1, min(int(steps), 128)) context = torch.full((num_samples,), label, device=device, dtype=torch.long) prompt = torch.empty((num_samples, 0), device=device, dtype=torch.long) cfg_scale = float(INFER_CONFIG["cfg_scale"]) null_label_id = int(MODEL_CONFIG["null_label_id"]) uncond_context = torch.full((num_samples,), null_label_id, device=device, dtype=torch.long) out = flow_image_generate( model, prompt, context=context, steps=steps, cfg_scale=cfg_scale, uncond_context=uncond_context, generator=None, ) images: List[Image.Image] = [] for i in range(num_samples): images.append(_to_image(out[i])) return images def _grid_dims(num_samples: int) -> Tuple[int, int]: cols = int(np.ceil(np.sqrt(num_samples))) rows = int(np.ceil(num_samples / cols)) return rows, cols @torch.inference_mode() def generate_grid_image(label: int, steps: int, num_samples: int) -> Image.Image: images = generate_images(label=label, steps=steps, num_samples=num_samples) if not images: return Image.new("L", (1, 1), color=0) rows, cols = _grid_dims(len(images)) w, h = images[0].size grid = Image.new("L", (cols * w, rows * h)) for idx, img in enumerate(images): r = idx // cols c = idx % cols grid.paste(img, (c * w, r * h)) return grid @torch.inference_mode() def iter_trajectory_frames(label: int, steps: int): model, device, _ = load_model() steps = max(32, min(int(steps), 128)) context = torch.full((1,), int(label), device=device, dtype=torch.long) prompt = torch.empty((1, 0), device=device, dtype=torch.long) null_label_id = int(MODEL_CONFIG["null_label_id"]) uncond_context = torch.full((1,), null_label_id, device=device, dtype=torch.long) batch_size = context.shape[0] gen_length = int(model.context_length) x = torch.randn((batch_size, gen_length), device=prompt.device, dtype=torch.float32) dt = 1.0 / float(steps) checkpoint_count = max(16, int(INFER_CONFIG["trajectory_checkpoints"])) checkpoint_indices = np.linspace(1, steps, num=checkpoint_count, dtype=int).tolist() checkpoint_indices = sorted(set(max(1, min(steps, idx)) for idx in checkpoint_indices)) cfg_scale = float(INFER_CONFIG["cfg_scale"]) for k in range(steps): t = torch.full((batch_size,), float(k) / float(steps), device=x.device, dtype=x.dtype) if cfg_scale > 0.0: v_cond = model(x, t, context=context) v_uncond = model(x, t, context=uncond_context) v = v_uncond + (cfg_scale + 1.0) * (v_cond - v_uncond) else: v = model(x, t, context=context) x = x + dt * v step_idx = k + 1 if step_idx in checkpoint_indices: yield _to_image(x[0]), step_idx, steps, len(checkpoint_indices)