File size: 10,772 Bytes
58785a2 5144d1b 58785a2 5144d1b 58785a2 5144d1b 2c1f9ea 5144d1b 2c1f9ea 5144d1b 58785a2 2c1f9ea 58785a2 5144d1b 58785a2 d7139da 58785a2 5144d1b 58785a2 5144d1b 58785a2 d7139da 58785a2 d7139da | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 | """LoopVL demo: a recurrent vision-language model with a user-controlled visual compute budget.
Loads the public TierFlow/LoopVL checkpoint (~1.6B parameters) with the authors'
single-file loader, then answers questions about an image. The distinctive control
is the recurrent schedule: the model reuses its 16-layer transformer stack, and the
H (high) and L (low) cycle counts decide how many times the stack is applied per
forward pass — the "inference budget" the paper sweeps across.
"""
import os
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
import spaces # MUST come before any torch / CUDA-touching import
import time
from PIL import Image, ImageFile
import gradio as gr
import torch
ImageFile.LOAD_TRUNCATED_IMAGES = True
MODEL_ID = "TierFlow/LoopVL"
DEVICE = "cuda"
# Vendored from https://github.com/Tier-Flow/LoopVL (Apache-2.0; Penguin encoder
# keeps its Tencent LICENSE.txt alongside).
from modeling_loopvl import LoopVLForConditionalGeneration, load_tokenizer_and_processor
from hrm_penguin.generation import greedy_generate
from hrm_penguin.inference import build_inference_batch
from hrm_penguin.recurrent.visual_anchor import VisualAnchor
TRIGGER = "\nExplain your reasoning, then give the final answer."
def _download_weights() -> str:
"""Fetch config + tokenizer + single safetensors checkpoint into a local dir."""
from huggingface_hub import snapshot_download
return snapshot_download(
MODEL_ID,
allow_patterns=[
"config.json",
"generation_config.json",
"preprocessor_config.json",
"tokenizer.json",
"tokenizer_config.json",
"model.safetensors",
],
)
# --- Load everything at module scope, eagerly on "cuda" (string device). ------
_model_dir = _download_weights()
model = LoopVLForConditionalGeneration.from_pretrained(_model_dir, device=DEVICE)
tokenizer, processor = load_tokenizer_and_processor(_model_dir, model.config)
# Base visual-anchor coefficients (H_cycles=2 in the shipped checkpoint). Any
# schedule change rebuilds VisualAnchor, re-copying these values (clamped).
# Kept on CPU: schedule changes run inside the ZeroGPU worker, where a plain
# CPU tensor is trivially inherited across forks and copied cross-device.
recurrent = model.recurrent_model
BASE_ANCHOR_VALUES = recurrent.visual_anchor.raw_alpha.detach().to("cpu").clone()
ANCHOR_MAX_SCALE = recurrent.visual_anchor.max_scale
LAST_SCHEDULE = {"h": int(recurrent.config.H_cycles), "l": int(recurrent.config.L_cycles)}
def _apply_schedule(h_cycles: int, l_cycles: int) -> None:
"""Set the recurrent inference schedule (the authors' configure() pattern).
The forward pass reads config.H_cycles / config.L_cycles each call, and the
visual anchor holds one learned coefficient per high cycle, so changing H
rebuilds it and re-copies the base coefficients (clamped, as in the paper).
"""
h_cycles, l_cycles = int(h_cycles), int(l_cycles)
if h_cycles < 1 or l_cycles < 1:
raise ValueError("H and L cycle counts must be positive")
if LAST_SCHEDULE["h"] == h_cycles and LAST_SCHEDULE["l"] == l_cycles:
return # already configured; avoids rebuilding the anchor needlessly
recurrent.config.H_cycles = h_cycles
recurrent.config.L_cycles = l_cycles
old = recurrent.visual_anchor
new = VisualAnchor(h_cycles, max_scale=ANCHOR_MAX_SCALE).to(
device=old.raw_alpha.device, dtype=old.raw_alpha.dtype
)
with torch.no_grad():
for i in range(h_cycles):
new.raw_alpha[i].copy_(
BASE_ANCHOR_VALUES[min(i, len(BASE_ANCHOR_VALUES) - 1)]
)
recurrent.visual_anchor = new
LAST_SCHEDULE["h"], LAST_SCHEDULE["l"] = h_cycles, l_cycles
# Coherent schedules from the paper's Table 4 sweep: (label, H, L) -> unrolled
# depth 16 * H * (L+1). H2L3 is the trained schedule and clearly the best; the
# H1/H4 rows in the paper collapse (MMStar 0.5/24), so they are not offered.
SCHEDULES = [
("H2L3 · 128 layers (trained default, best)", 2, 3),
("H2L2 · 96 layers (lighter, still solid)", 2, 2),
("H3L3 · 192 layers (heavier, slightly lower)", 3, 3),
("H3L2 · 144 layers", 3, 2),
("H2L1 · 64 layers (noticeably degraded)", 2, 1),
("H3L1 · 96 layers (noticeably degraded)", 3, 1),
]
_SCHEDULE_LOOKUP = {name: (h, l) for name, h, l in SCHEDULES}
def _estimate_duration(*args, **kwargs) -> int:
"""Callable GPU duration, calibrated from measured calls on this Space.
Measured on ZeroGPU (cold first call per visitor): 14.1 s at H2L3/32 tok,
19.3 s at H2L3/96 tok, 39.7 s at H3L3/96 tok. Per-token cost grows
superlinearly with unrolled depth, so the coefficient is scaled by
depth/128 with a fixed base that also covers worker init.
Duration = 25 + budget x 0.2 s/token scaled by depth/128, capped at 120 s.
Worst offered case (H3L3, 96 tok) resolves to ~54 s vs 39.7 s measured.
"""
schedule = kwargs.get("schedule") or (args[2] if len(args) > 2 else None)
budget = kwargs.get("max_tokens") or (args[3] if len(args) > 3 else 32)
try:
h, l = _SCHEDULE_LOOKUP.get(str(schedule), (2, 3))
except Exception:
h, l = 2, 3
depth = 16 * h * (l + 1)
return min(120, 25 + int(float(budget) * 0.2 * depth / 128))
@spaces.GPU(duration=_estimate_duration)
def answer(
image: Image.Image,
prompt: str,
schedule: str = "H2L3 · 128 layers (trained default, best)",
max_tokens: int = 32,
use_trigger: bool = False,
progress=gr.Progress(track_tqdm=True),
) -> str:
"""Answer a question about an image using LoopVL, a recurrent vision-language model.
Args:
image: the input image to reason about.
prompt: the question or instruction about the image.
schedule: recurrent compute schedule (H and L cycle counts). Higher means
more effective transformer layers per forward pass.
max_tokens: maximum number of tokens to generate.
use_trigger: append the authors' reasoning trigger to the instruction.
progress: Gradio progress bar.
Returns:
The model's answer text.
"""
if image is None:
return "Please provide an image."
prompt = (prompt or "").strip()
if not prompt:
return "Please provide a question or instruction about the image."
h, l = _SCHEDULE_LOOKUP.get(schedule, (2, 3))
_apply_schedule(h, l)
instruction = prompt + (TRIGGER if use_trigger else "")
pil_image = image if isinstance(image, Image.Image) else Image.fromarray(image)
batch = build_inference_batch(
tokenizer,
processor,
pil_image.convert("RGB"),
instruction,
model.config,
torch.device(DEVICE),
)
max_tokens = max(1, min(int(max_tokens), 96))
start = time.perf_counter()
with torch.inference_mode():
generated = greedy_generate(
model,
batch,
max_new_tokens=max_tokens,
eos_token_id=tokenizer.eos_token_id,
)
elapsed = time.perf_counter() - start
prediction = tokenizer.decode(generated[0], skip_special_tokens=True).strip()
depth = 16 * h * (l + 1)
label = f"Answer (schedule {schedule.split(' ')[0]} → {depth} effective layers, {elapsed:.1f}s):"
return f"{label}\n{prediction}" if prediction else f"{label}\n(no tokens generated)"
CSS = """
#col-container { max-width: 1100px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""
with gr.Blocks() as demo:
with gr.Column(elem_id="col-container"):
gr.Markdown(
"""
# LoopVL: Recurrent Vision-Language Model
**LoopVL** (~1.6B params) reuses its 16-layer transformer stack in loops
instead of stacking 128 layers. You control the compute budget: pick an
**H/L schedule** to decide how many times the stack runs per forward pass,
then ask anything about the image.
[Model](https://huggingface.co/TierFlow/LoopVL) ·
[Code](https://github.com/Tier-Flow/LoopVL)
"""
)
with gr.Row():
with gr.Column(scale=1):
image_in = gr.Image(
type="pil", label="Image", sources=["upload", "clipboard"]
)
prompt_in = gr.Textbox(
label="Question / instruction",
placeholder="What is in the image?",
lines=2,
)
schedule_in = gr.Dropdown(
choices=[name for name, _, _ in SCHEDULES],
value="H2L3 · 128 layers (trained default, best)",
label="Recurrent schedule (compute budget)",
info="Unrolled depth = 16 × H × (L+1) effective layers per forward pass. "
"H2L3 is the trained schedule; other coherent schedules from the "
"paper's Table 4 sweep are offered for comparison.",
)
run_btn = gr.Button("Answer", variant="primary")
with gr.Accordion("Advanced", open=False):
max_tokens_in = gr.Slider(
minimum=8, maximum=96, step=8, value=32,
label="Max new tokens",
)
trigger_in = gr.Checkbox(
value=False,
label="Append reasoning trigger",
info='Adds "Explain your reasoning, then give the final answer." '
"(the authors' math-reasoning prompt)",
)
with gr.Column(scale=1):
answer_out = gr.Textbox(label="Answer", lines=8)
gr.Examples(
examples=[
["examples/cat_tabby.jpg", "What animal is this, and what is it doing?"],
["examples/rubber_ducks.jpg", "How many objects are in this image? Describe them."],
["examples/landscape.jpg", "Describe the scene and the weather."],
["examples/dog.jpg", "What breed of dog does this look like?"],
],
inputs=[image_in, prompt_in],
outputs=answer_out,
fn=answer,
cache_examples=True,
cache_mode="lazy",
label="Examples (click to load, then press Answer)",
)
run_btn.click(
answer,
inputs=[image_in, prompt_in, schedule_in, max_tokens_in, trigger_in],
outputs=answer_out,
api_name="generate",
concurrency_limit=1,
)
demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=CSS) |