""" Hugging Face Space — GPT2VL Stackformer V2 — Image Captioning Only =================================================================== Model trained in float32 with torch.amp.autocast (fp16 matmuls, fp32 accumulators). Inference mirrors training exactly: • float32 model weights • torch.amp.autocast for GPU execution • greedy argmax decoding (no sampling) • ViT stays float32; patch tokens cast to resampler dtype inside encode_image Checkpoint layout on the Hub: config.json — architecture hyperparameters model_trainable.safetensors — adapter weights (resampler.* + cross_blocks.*) """ import os, json import torch import torch.nn as nn import torch.nn.functional as F import gradio as gr from PIL import Image # ── ZeroGPU shim ───────────────────────────────────────────────────────────── try: import spaces except ImportError: class spaces: # noqa: E302 @staticmethod def GPU(duration=None): def decorator(fn): return fn return decorator from torchvision.models import vit_b_16, ViT_B_16_Weights from torchvision import transforms from transformers import GPT2TokenizerFast, GPT2LMHeadModel from huggingface_hub import hf_hub_download from safetensors.torch import load_file REPO_ID = os.environ.get("MODEL_REPO_ID", "gurumurthy3/gpt2vl-stackformer-v2") device = torch.device("cuda" if torch.cuda.is_available() else "cpu") MODEL_DTYPE = torch.float32 # model saved and loaded in fp32 — matches training # ── Stackformer components (self-contained, FP32-stable LayerNorm) ──────────── class LayerNormalization(nn.Module): """Always upcasts to float32 for mean/var math — safe under torch.amp.autocast.""" def __init__(self, embed_dim, eps=1e-5, device=None, dtype=None): super().__init__() self.eps = eps fk = {"device": device, "dtype": dtype} self.weight = nn.Parameter(torch.ones(embed_dim, **fk)) self.bias = nn.Parameter(torch.zeros(embed_dim, **fk)) def forward(self, x): orig = x.dtype x32 = x.float() mean = x32.mean(-1, keepdim=True) var = x32.var(-1, keepdim=True, unbiased=False) out = self.weight.float() * (x32 - mean) / (var + self.eps).sqrt() + self.bias.float() return out.to(orig) class FF_GELU(nn.Module): """Feed-forward with GELU, matching stackformer FF_GELU structure.""" def __init__(self, embed_dim, hidden_dim, dropout=0.0, device=None, dtype=None): super().__init__() kw = {"device": device, "dtype": dtype} # NOTE: stackformer FF_GELU stores as self.gelu = nn.Sequential(...) # We expose the same attribute so weight-copy code can use gelu[0]/gelu[3] self.gelu = nn.Sequential( nn.Linear(embed_dim, hidden_dim, **kw), # index 0 nn.GELU(), # index 1 nn.Dropout(dropout), # index 2 nn.Linear(hidden_dim, embed_dim, **kw), # index 3 nn.Dropout(dropout), # index 4 ) def forward(self, x): return self.gelu(x) class AbsolutePositionEmbedding(nn.Module): def __init__(self, seq_len, embed_dim, device=None, dtype=None): super().__init__() self.embedding = nn.Embedding(seq_len, embed_dim, device=device, dtype=dtype) def forward(self, x): B, T = x.shape[:2] pos = torch.arange(T, device=self.embedding.weight.device, dtype=torch.long) return self.embedding(pos).unsqueeze(0).expand(B, -1, -1) class Multi_Head_Attention(nn.Module): def __init__(self, embed_dim, num_heads, dropout=0.0, qkv_bias=True, device=None, dtype=None): super().__init__() self.embed_dim = embed_dim self.num_heads = num_heads self.head_dim = embed_dim // num_heads self.dropout_p = dropout kw = {"device": device, "dtype": dtype} self.qkv_proj = nn.Linear(embed_dim, embed_dim * 3, bias=qkv_bias, **kw) self.out_proj = nn.Linear(embed_dim, embed_dim, bias=qkv_bias, **kw) def forward(self, x, mask=True): B, T, C = x.shape q, k, v = self.qkv_proj(x).split(self.embed_dim, dim=-1) def rs(t): return t.view(B, T, self.num_heads, self.head_dim).transpose(1, 2) dp = self.dropout_p if self.training else 0.0 out = F.scaled_dot_product_attention(rs(q), rs(k), rs(v), dropout_p=dp, is_causal=mask) return self.out_proj(out.transpose(1, 2).contiguous().view(B, T, C)) class Cross_MultiHead_Attention(nn.Module): def __init__(self, embed_dim, num_heads, dropout=0.0, qkv_bias=True, device=None, dtype=None): super().__init__() self.embed_dim = embed_dim self.num_heads = num_heads self.head_dim = embed_dim // num_heads self.dropout_p = dropout kw = {"device": device, "dtype": dtype} self.q_proj = nn.Linear(embed_dim, embed_dim, bias=qkv_bias, **kw) self.kv_proj = nn.Linear(embed_dim, embed_dim * 2, bias=qkv_bias, **kw) self.out_proj = nn.Linear(embed_dim, embed_dim, bias=qkv_bias, **kw) def forward(self, x, context, mask=False, attn_mask=None): B, T, C = x.shape S = context.size(1) q = self.q_proj(x) k, v = self.kv_proj(context).split(self.embed_dim, dim=-1) def rsq(t): return t.view(B, T, self.num_heads, self.head_dim).transpose(1, 2) def rsc(t): return t.view(B, S, self.num_heads, self.head_dim).transpose(1, 2) dp = self.dropout_p if self.training else 0.0 out = F.scaled_dot_product_attention(rsq(q), rsc(k), rsc(v), dropout_p=dp, is_causal=False) return self.out_proj(out.transpose(1, 2).contiguous().view(B, T, C)) class EncoderBlock(nn.Module): def __init__(self, embed_dim, num_heads, hidden_dim, dropout=0.0, qkv_bias=True, device=None, dtype=None): super().__init__() kw = dict(device=device, dtype=dtype) self.self_attn = Multi_Head_Attention(embed_dim, num_heads, dropout=dropout, qkv_bias=qkv_bias, **kw) self.ffn = FF_GELU(embed_dim, hidden_dim, dropout=dropout, **kw) self.norm1 = LayerNormalization(embed_dim, **kw) self.norm2 = LayerNormalization(embed_dim, **kw) # expose aliases matching stackformer naming so weight-transfer code works @property def attention(self): return self.self_attn @property def ff(self): return self.ffn def forward(self, x, mask=False): x = x + self.self_attn(self.norm1(x), mask=mask) x = x + self.ffn(self.norm2(x)) return x class TransformerEncoder(nn.Module): def __init__(self, embed_dim, num_heads, hidden_dim, num_layers, dropout=0.0, qkv_bias=True, device=None, dtype=None): super().__init__() kw = dict(embed_dim=embed_dim, num_heads=num_heads, hidden_dim=hidden_dim, dropout=dropout, qkv_bias=qkv_bias, device=device, dtype=dtype) self.layers = nn.ModuleList([EncoderBlock(**kw) for _ in range(num_layers)]) self.final_norm = LayerNormalization(embed_dim, device=device, dtype=dtype) def forward(self, x, mask=False): for layer in self.layers: x = layer(x, mask=mask) return self.final_norm(x) class GPT_2(nn.Module): def __init__(self, vocab_size, num_layers, embed_dim, num_heads, seq_len, dropout=0.1, hidden_dim=0, qkv_bias=True, eps=1e-5, device="cpu", dtype=None): super().__init__() hidden_dim = hidden_dim or 4 * embed_dim kw = dict(device=device, dtype=dtype) self.embedding = nn.Embedding(vocab_size, embed_dim, **kw) self.position_embedding = AbsolutePositionEmbedding(seq_len, embed_dim, **kw) self.backbone = TransformerEncoder(embed_dim, num_heads, hidden_dim, num_layers, dropout=dropout, qkv_bias=qkv_bias, **kw) self.final_norm = LayerNormalization(embed_dim, eps=eps, **kw) self.lm_head = nn.Linear(embed_dim, vocab_size, bias=False, **kw) def forward(self, x): x = self.embedding(x) + self.position_embedding(x) x = self.backbone(x, mask=True) return self.lm_head(x) # ── Multimodal components ────────────────────────────────────────────────────── class GatedSparseCrossAttnBlock(nn.Module): def __init__(self, embed_dim, num_heads, dropout, qkv_bias=True, device="cpu", dtype=None): super().__init__() kw = dict(device=device, dtype=dtype) self.norm = LayerNormalization(embed_dim, **kw) self.cross_attn = Cross_MultiHead_Attention(embed_dim, num_heads, dropout=dropout, qkv_bias=qkv_bias, **kw) self.drop = nn.Dropout(dropout) self.alpha = nn.Parameter(torch.zeros(1, **kw)) def forward(self, x, context): residual = x x_norm = self.norm(x) attn_out = self.cross_attn(x_norm, context, mask=False) attn_out = self.drop(attn_out) return residual + self.alpha * attn_out class PerceiverResamplerSF(nn.Module): def __init__(self, embed_dim, num_latents, depth, num_heads, dropout=0.0, hidden_dim=None, device="cpu", dtype=None): super().__init__() hidden_dim = hidden_dim or embed_dim * 2 kw = dict(dropout=dropout, qkv_bias=True, device=device, dtype=dtype) self.latents = nn.Parameter(torch.randn(num_latents, embed_dim, device=device, dtype=dtype) * 0.02) self.cross_layers = nn.ModuleList([Cross_MultiHead_Attention(embed_dim, num_heads, **kw) for _ in range(depth)]) self.norm_latent = nn.ModuleList([LayerNormalization(embed_dim, device=device, dtype=dtype) for _ in range(depth)]) self.norm_media = nn.ModuleList([LayerNormalization(embed_dim, device=device, dtype=dtype) for _ in range(depth)]) self.ffns = nn.ModuleList([FF_GELU(embed_dim, hidden_dim, dropout, device=device, dtype=dtype) for _ in range(depth)]) self.ffn_norms = nn.ModuleList([LayerNormalization(embed_dim, device=device, dtype=dtype) for _ in range(depth)]) self.depth = depth def forward(self, media_seq): b = media_seq.shape[0] x = self.latents.unsqueeze(0).expand(b, -1, -1) for i in range(self.depth): xn = self.norm_latent[i](x) ctx = self.norm_media[i](media_seq) x = x + self.cross_layers[i](xn, ctx, mask=False) x = x + self.ffns[i](self.ffn_norms[i](x)) return x class TorchvisionViTEncoder(nn.Module): """Frozen ViT-B/16. Kept in float32 (same as notebook — NO dtype cast at init). Patch tokens are cast to the resampler's dtype inside GPT2VL.encode_image.""" def __init__(self, pretrained=True, freeze=True): super().__init__() weights = ViT_B_16_Weights.IMAGENET1K_V1 if pretrained else None self.model = vit_b_16(weights=weights) # stays float32 self.hidden_dim = self.model.hidden_dim if freeze: for p in self.parameters(): p.requires_grad = False @torch.no_grad() def forward(self, images): f = self.model._process_input(images) b = f.shape[0] x = torch.cat((self.model.class_token.expand(b, -1, -1), f), dim=1) x = self.model.encoder(x) return x # (B, 197, 768) — includes CLS token class GPT2VL(nn.Module): def __init__(self, cfg, device="cpu", dtype=None): super().__init__() self.cfg = cfg kw = dict(device=device, dtype=dtype) self.gpt2 = GPT_2( vocab_size=cfg["vocab_size"], num_layers=cfg["num_layers"], embed_dim=cfg["embed_dim"], num_heads=cfg["num_heads"], seq_len=cfg["context_length"], dropout=cfg["dropout"], hidden_dim=cfg["hidden_dim"], qkv_bias=cfg["qkv_bias"], **kw, ) self.cross_attention_pos = set(cfg["cross_attention_pos"]) self.cross_blocks = nn.ModuleDict({ str(i): GatedSparseCrossAttnBlock(cfg["embed_dim"], cfg["num_heads"], cfg["dropout"], cfg["qkv_bias"], **kw) for i in cfg["cross_attention_pos"] }) # ViT stays float32 (no dtype arg) — matches notebook Cell 13 self.vision_encoder = TorchvisionViTEncoder(pretrained=True, freeze=True) # No vision_project needed: vision_dim == embed_dim == 768 self.resampler = PerceiverResamplerSF( cfg["embed_dim"], cfg["num_visual_tokens"], cfg["perceiver_depth"], cfg["perceiver_heads"], cfg["dropout"], device=device, dtype=dtype, ) def encode_image(self, images): with torch.no_grad(): patch_tokens = self.vision_encoder(images) # (B, 197, 768) float32 # Cast to resampler's dtype (float32 normally; fp16 under autocast) patch_tokens = patch_tokens.to(dtype=self.resampler.latents.dtype) return self.resampler(patch_tokens[:, 1:, :]) # drop CLS → (B, 196, 768) def forward(self, input_ids, images=None, visual_context=None): if visual_context is None and images is not None: visual_context = self.encode_image(images) x = self.gpt2.embedding(input_ids) + self.gpt2.position_embedding(input_ids) backbone = self.gpt2.backbone for i, layer in enumerate(backbone.layers): x = layer(x, mask=True) if i in self.cross_attention_pos and visual_context is not None: x = self.cross_blocks[str(i)](x, visual_context) return self.gpt2.lm_head(backbone.final_norm(x)) def freeze_text_backbone(self): for p in self.gpt2.parameters(): p.requires_grad = False for p in self.vision_encoder.parameters(): p.requires_grad = False # ── Tokenizer ───────────────────────────────────────────────────────────────── tokenizer = GPT2TokenizerFast.from_pretrained("gpt2") if tokenizer.pad_token is None: tokenizer.add_special_tokens({"pad_token": "<|pad|>"}) CFG_VOCAB_SIZE = len(tokenizer) # 50258 base + 1 pad = 50259 bos_id = tokenizer.bos_token_id or tokenizer.eos_token_id eos_id = tokenizer.eos_token_id # ── Download & assemble model ───────────────────────────────────────────────── print(f"[startup] downloading checkpoint from {REPO_ID} …") config_path = hf_hub_download(REPO_ID, "config.json") weights_path = hf_hub_download(REPO_ID, "model_trainable.safetensors") with open(config_path) as f: CFG = json.load(f) CFG["vocab_size"] = CFG_VOCAB_SIZE # sync with tokenizer (notebook Cell 23 line 1) CONTEXT_LENGTH = CFG["context_length"] print("[startup] building model in float32 …") model = GPT2VL(CFG, device=str(device), dtype=MODEL_DTYPE).to(device=device, dtype=MODEL_DTYPE) model.vision_encoder.to(device) # ViT always on the right device # ── GPT-2 weight transfer (mirrors notebook Cell 23 exactly) ───────────────── print("[startup] transferring pretrained GPT-2 weights …") hf_gpt2 = GPT2LMHeadModel.from_pretrained("gpt2") hf_state = hf_gpt2.state_dict() hf_vocab_sz = hf_state["transformer.wte.weight"].shape[0] layers = model.gpt2.backbone.layers with torch.no_grad(): # ── Token embedding ────────────────────────────────────────────────────── new_emb = model.gpt2.embedding.weight.data new_emb[:hf_vocab_sz] = hf_state["transformer.wte.weight"].to(MODEL_DTYPE) if CFG["vocab_size"] > hf_vocab_sz: nn.init.normal_(new_emb[hf_vocab_sz:], mean=0.0, std=0.02) # ── Position embedding ─────────────────────────────────────────────────── model.gpt2.position_embedding.embedding.weight.copy_( hf_state["transformer.wpe.weight"][: CFG["context_length"]].to(MODEL_DTYPE) ) # ── Transformer layers ─────────────────────────────────────────────────── for i, block in enumerate(layers): p = f"transformer.h.{i}." # LayerNorm block.norm1.weight.copy_(hf_state[p + "ln_1.weight"].to(MODEL_DTYPE)) block.norm1.bias.copy_( hf_state[p + "ln_1.bias"].to(MODEL_DTYPE)) block.norm2.weight.copy_(hf_state[p + "ln_2.weight"].to(MODEL_DTYPE)) block.norm2.bias.copy_( hf_state[p + "ln_2.bias"].to(MODEL_DTYPE)) # Attention — HF Conv1D weight shape is (C, 3C); split BEFORE transposing # (matches notebook Cell 23 exactly): # w_q, w_k, w_v = w_qkv.split(768, dim=1) # each (768, 768) # W_fused = cat([w_q.T, w_k.T, w_v.T], dim=0) # (2304, 768) w_qkv = hf_state[p + "attn.c_attn.weight"] # (768, 2304) b_qkv = hf_state[p + "attn.c_attn.bias"] # (2304,) w_q, w_k, w_v = w_qkv.split(CFG["embed_dim"], dim=1) b_q, b_k, b_v = b_qkv.split(CFG["embed_dim"], dim=0) W_fused = torch.cat([w_q.T, w_k.T, w_v.T], dim=0).to(MODEL_DTYPE) # (2304, 768) b_fused = torch.cat([b_q, b_k, b_v], dim=0).to(MODEL_DTYPE) # (2304,) block.self_attn.qkv_proj.weight.copy_(W_fused) block.self_attn.qkv_proj.bias.copy_(b_fused) block.self_attn.out_proj.weight.copy_(hf_state[p + "attn.c_proj.weight"].T.to(MODEL_DTYPE)) block.self_attn.out_proj.bias.copy_( hf_state[p + "attn.c_proj.bias"].to(MODEL_DTYPE)) # FFN — stackformer FF_GELU stores layers as self.gelu (nn.Sequential) # indices: 0=fc1, 1=GELU, 2=Dropout, 3=fc2, 4=Dropout block.ffn.gelu[0].weight.copy_(hf_state[p + "mlp.c_fc.weight"].T.to(MODEL_DTYPE)) block.ffn.gelu[0].bias.copy_( hf_state[p + "mlp.c_fc.bias"].to(MODEL_DTYPE)) block.ffn.gelu[3].weight.copy_(hf_state[p + "mlp.c_proj.weight"].T.to(MODEL_DTYPE)) block.ffn.gelu[3].bias.copy_( hf_state[p + "mlp.c_proj.bias"].to(MODEL_DTYPE)) # ── Final LayerNorm ────────────────────────────────────────────────────── model.gpt2.backbone.final_norm.weight.copy_(hf_state["transformer.ln_f.weight"].to(MODEL_DTYPE)) model.gpt2.backbone.final_norm.bias.copy_( hf_state["transformer.ln_f.bias"].to(MODEL_DTYPE)) # ── LM head weight tying (notebook Cell 23 line 996) ──────────────────── # GPT-2 ties lm_head.weight == wte; copy the (possibly extended) embedding model.gpt2.lm_head.weight.copy_(new_emb) del hf_gpt2, hf_state # ── Load trained adapter weights ───────────────────────────────────────────── print("[startup] loading trained adapter weights …") trained_state = load_file(weights_path) # Weights were saved from a float32 model → load as-is, no dtype conversion needed missing, unexpected = model.load_state_dict(trained_state, strict=False) n_loaded = sum(1 for k in trained_state if k not in unexpected) print(f"[startup] loaded {n_loaded} adapter tensors | unexpected: {len(unexpected)}") if missing: print(f"[startup] WARNING missing keys: {missing[:5]} …") model.eval() print("[startup] model ready.") # ── Image preprocessing (same as training dataset transform) ───────────────── image_tx = transforms.Compose([ transforms.Resize((224, 224)), transforms.Lambda(lambda im: im.convert("RGB")), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) # ── Local Sample Image Preparation (6 Examples from user repo) ─────────────── import time import urllib.request import ssl from PIL import Image, ImageDraw SAMPLES_DIR = os.path.join(os.path.dirname(__file__), "samples") os.makedirs(SAMPLES_DIR, exist_ok=True) SAMPLE_FILES = [] def _prepare_sample_images(): ssl_ctx = ssl.create_default_context() ssl_ctx.check_hostname = False ssl_ctx.verify_mode = ssl.CERT_NONE base_url = "https://raw.githubusercontent.com/Gurumurthy30/multimodal-gpt2-demo/main/v1/examples/" colors = [(180, 140, 100), (140, 160, 200), (100, 140, 180), (180, 180, 180), (200, 150, 120), (160, 200, 140)] for i in range(1, 7): filename = f"example{i}.png" url = f"{base_url}{filename}" fallback_color = colors[i - 1] local_path = os.path.join(SAMPLES_DIR, filename) if not os.path.exists(local_path) or os.path.getsize(local_path) == 0: try: req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req, context=ssl_ctx, timeout=8) as resp, open(local_path, "wb") as f: f.write(resp.read()) except Exception as e: print(f"[startup] Notice: Sample download skipped ({filename}): {e}, generating fallback image...") try: img = Image.new("RGB", (320, 240), color=fallback_color) draw = ImageDraw.Draw(img) draw.rectangle([20, 20, 300, 220], outline=(255, 255, 255), width=2) img.save(local_path) except Exception as fe: print(f"[startup] Fallback generation error: {fe}") if os.path.exists(local_path) and os.path.getsize(local_path) > 0: SAMPLE_FILES.append([local_path]) _prepare_sample_images() def _gpu_duration(pil_image, max_new_tokens, temperature, top_k, top_p): return min(120, 15 + int(max_new_tokens) * 0.5) def _sample_next_token(logits, temperature=0.7, top_k=40, top_p=0.9): """Samples next token using temperature scaling, top-k filtering, and top-p (nucleus) filtering.""" if temperature <= 1e-4: probs = F.softmax(logits, dim=-1) top_prob, top_idx = torch.max(probs, dim=-1) return top_idx.unsqueeze(-1), top_prob.item() logits_scaled = logits / temperature if top_k > 0: top_k = min(top_k, logits_scaled.size(-1)) v, _ = torch.topk(logits_scaled, top_k) min_topk = v[:, -1:] logits_scaled = torch.where(logits_scaled < min_topk, torch.full_like(logits_scaled, -float("Inf")), logits_scaled) if top_p < 1.0: sorted_logits, sorted_indices = torch.sort(logits_scaled, descending=True, dim=-1) sorted_probs = F.softmax(sorted_logits, dim=-1) cumulative_probs = torch.cumsum(sorted_probs, dim=-1) sorted_indices_to_remove = cumulative_probs > top_p sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone() sorted_indices_to_remove[..., 0] = 0 indices_to_remove = sorted_indices_to_remove.scatter(1, sorted_indices, sorted_indices_to_remove) logits_scaled = torch.where(indices_to_remove, torch.full_like(logits_scaled, -float("Inf")), logits_scaled) probs = F.softmax(logits_scaled, dim=-1) next_tok = torch.multinomial(probs, num_samples=1) tok_prob = probs[0, next_tok.item()].item() return next_tok, tok_prob @spaces.GPU(duration=_gpu_duration) @torch.no_grad() def generate_caption( pil_image: Image.Image, max_new_tokens: int = 40, temperature: float = 0.7, top_k: int = 40, top_p: float = 0.9, ): """ Caption generation for image using visual context + autoregressive sampling. Returns: caption, latency badge HTML, metrics grid HTML. """ if pil_image is None: empty_metrics = _build_metrics_html(0, 0, int(max_new_tokens), int(top_k), 0.0) return "⚠️ Please upload an image first.", "⚡ 0 ms", empty_metrics t0 = time.perf_counter() img_t = image_tx(pil_image).unsqueeze(0).to(device) amp_ctx = ( torch.amp.autocast(device_type="cuda", dtype=torch.float16) if device.type == "cuda" else torch.amp.autocast(device_type="cpu", enabled=False) ) step_probs = [] with amp_ctx: visual_ctx = model.encode_image(img_t) gen_ids = torch.full((1, 1), bos_id, dtype=torch.long, device=device) for _ in range(int(max_new_tokens)): logits = model(gen_ids, visual_context=visual_ctx) # (1, T, V) last_logits = logits[0, -1, :].unsqueeze(0).float() # (1, V) next_tok, prob = _sample_next_token(last_logits, temperature=temperature, top_k=int(top_k), top_p=top_p) step_probs.append(prob) gen_ids = torch.cat([gen_ids, next_tok], dim=1) if next_tok.item() == eos_id: break if gen_ids.shape[1] >= CONTEXT_LENGTH: break latency_ms = (time.perf_counter() - t0) * 1000.0 ids = gen_ids[0, 1:].tolist() if eos_id in ids: ids = ids[: ids.index(eos_id)] step_probs = step_probs[: len(ids)] caption = tokenizer.decode(ids, skip_special_tokens=True).strip() or "…" mean_conf = (sum(step_probs) / max(len(step_probs), 1)) * 100.0 if step_probs else 0.0 latency_html = f"⚡ {latency_ms:.0f} ms" metrics_html = _build_metrics_html(latency_ms, len(ids), int(max_new_tokens), int(top_k), mean_conf) return caption, latency_html, metrics_html def _build_metrics_html(latency_ms, token_count, max_tokens, top_k, confidence): conf_pct = min(100.0, max(0.0, confidence)) return f"""