multimodalart HF Staff commited on
Commit
58785a2
·
verified ·
1 Parent(s): 0cbb318

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/cat_tabby.jpg filter=lfs diff=lfs merge=lfs -text
37
+ examples/dog.jpg filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,51 @@
1
  ---
2
- title: Loopvl
3
- emoji: 🐠
4
- colorFrom: pink
5
- colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.30.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: LoopVL
3
+ emoji: 🔁
4
+ colorFrom: purple
5
+ colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 6.30.0
 
8
  app_file: app.py
9
+ short_description: Recurrent VLM with a controllable compute budget
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 30m
12
  ---
13
 
14
+ # LoopVL: Recurrent Vision-Language Model
15
+
16
+ **LoopVL** ([TierFlow/LoopVL](https://huggingface.co/TierFlow/LoopVL)) is a ~1.6B
17
+ vision-language model that reuses its 16-layer transformer stack in nested loops
18
+ instead of stacking 128 layers. The distinctive control this demo exposes is the
19
+ **recurrent inference budget**: the H (high) and L (low) cycle counts decide how
20
+ many times the stack is applied per forward pass — unrolled depth
21
+ `16 × H × (L+1)` effective layers. The schedule can be changed at runtime without
22
+ reloading weights.
23
+
24
+ Ask any question about an image, pick a schedule, and compare answers across
25
+ compute budgets.
26
+
27
+ - **Model**: [TierFlow/LoopVL](https://huggingface.co/TierFlow/LoopVL) (BF16
28
+ vision/language cores + FP32 projector, visual gate and anchor)
29
+ - **Code**: [Tier-Flow/LoopVL](https://github.com/Tier-Flow/LoopVL) — the
30
+ inference runtime (`hrm_penguin`, `penguin_encoder`, `modeling_loopvl.py`,
31
+ `metadata.py`) is vendored here from that Apache-2.0 repository; the Penguin
32
+ vision encoder keeps its Tencent `LICENSE.txt` alongside.
33
+ - **Examples**: images from
34
+ [linoyts/repo-to-space-example-inputs](https://huggingface.co/datasets/linoyts/repo-to-space-example-inputs)
35
+ (free to use, CC0-style).
36
+
37
+ ## How it works
38
+
39
+ 1. The image is resized under a dynamic-resolution policy (256–2048 visual
40
+ tokens) and patched by the PenguinVL vision encoder.
41
+ 2. Visual tokens are injected into the text sequence; the HRM-Text recurrent
42
+ LM loops its stack `H × (L+1)` times per forward pass, re-injecting an
43
+ anchored version of the visual tokens after each high cycle, gated by a
44
+ learned saturation-aware gate.
45
+ 3. Decoding is greedy, recomputing the full sequence per token (no KV cache).
46
+
47
+ ## Reproducing the reference
48
+
49
+ The `answer` endpoint follows the authors' `runtime/infer.py` one-to-one:
50
+ same prompt template (`condition\nImage:\n` + placeholders + `\nInstruction:\n…`),
51
+ same greedy decode, same `decode(skip_special_tokens=True).strip()` postprocess.
app.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LoopVL demo: a recurrent vision-language model with a user-controlled visual compute budget.
2
+
3
+ Loads the public TierFlow/LoopVL checkpoint (~1.6B parameters) with the authors'
4
+ single-file loader, then answers questions about an image. The distinctive control
5
+ is the recurrent schedule: the model reuses its 16-layer transformer stack, and the
6
+ H (high) and L (low) cycle counts decide how many times the stack is applied per
7
+ forward pass — the "inference budget" the paper sweeps across.
8
+ """
9
+
10
+ import os
11
+
12
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
13
+
14
+ import spaces # MUST come before any torch / CUDA-touching import
15
+
16
+ import time
17
+ from PIL import Image, ImageFile
18
+
19
+ import gradio as gr
20
+ import torch
21
+
22
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
23
+
24
+ MODEL_ID = "TierFlow/LoopVL"
25
+ DEVICE = "cuda"
26
+
27
+ # Vendored from https://github.com/Tier-Flow/LoopVL (Apache-2.0; Penguin encoder
28
+ # keeps its Tencent LICENSE.txt alongside).
29
+ from modeling_loopvl import LoopVLForConditionalGeneration, load_tokenizer_and_processor
30
+ from hrm_penguin.generation import greedy_generate
31
+ from hrm_penguin.inference import build_inference_batch
32
+ from hrm_penguin.recurrent.visual_anchor import VisualAnchor
33
+
34
+ TRIGGER = "\nExplain your reasoning, then give the final answer."
35
+
36
+
37
+ def _download_weights() -> str:
38
+ """Fetch config + tokenizer + single safetensors checkpoint into a local dir."""
39
+ from huggingface_hub import snapshot_download
40
+
41
+ return snapshot_download(
42
+ MODEL_ID,
43
+ allow_patterns=[
44
+ "config.json",
45
+ "generation_config.json",
46
+ "preprocessor_config.json",
47
+ "tokenizer.json",
48
+ "tokenizer_config.json",
49
+ "model.safetensors",
50
+ ],
51
+ )
52
+
53
+
54
+ # --- Load everything at module scope, eagerly on "cuda" (string device). ------
55
+ _model_dir = _download_weights()
56
+ model = LoopVLForConditionalGeneration.from_pretrained(_model_dir, device=DEVICE)
57
+ tokenizer, processor = load_tokenizer_and_processor(_model_dir, model.config)
58
+
59
+ # Base visual-anchor coefficients (H_cycles=2 in the shipped checkpoint). Any
60
+ # schedule change rebuilds VisualAnchor, re-copying these values (clamped).
61
+ # Kept on CPU: schedule changes run inside the ZeroGPU worker, where a plain
62
+ # CPU tensor is trivially inherited across forks and copied cross-device.
63
+ recurrent = model.recurrent_model
64
+ BASE_ANCHOR_VALUES = recurrent.visual_anchor.raw_alpha.detach().to("cpu").clone()
65
+ ANCHOR_MAX_SCALE = recurrent.visual_anchor.max_scale
66
+ LAST_SCHEDULE = {"h": int(recurrent.config.H_cycles), "l": int(recurrent.config.L_cycles)}
67
+
68
+
69
+ def _apply_schedule(h_cycles: int, l_cycles: int) -> None:
70
+ """Set the recurrent inference schedule (the authors' configure() pattern).
71
+
72
+ The forward pass reads config.H_cycles / config.L_cycles each call, and the
73
+ visual anchor holds one learned coefficient per high cycle, so changing H
74
+ rebuilds it and re-copies the base coefficients (clamped, as in the paper).
75
+ """
76
+ h_cycles, l_cycles = int(h_cycles), int(l_cycles)
77
+ if h_cycles < 1 or l_cycles < 1:
78
+ raise ValueError("H and L cycle counts must be positive")
79
+ if LAST_SCHEDULE["h"] == h_cycles and LAST_SCHEDULE["l"] == l_cycles:
80
+ return # already configured; avoids rebuilding the anchor needlessly
81
+ recurrent.config.H_cycles = h_cycles
82
+ recurrent.config.L_cycles = l_cycles
83
+ old = recurrent.visual_anchor
84
+ new = VisualAnchor(h_cycles, max_scale=ANCHOR_MAX_SCALE).to(
85
+ device=old.raw_alpha.device, dtype=old.raw_alpha.dtype
86
+ )
87
+ with torch.no_grad():
88
+ for i in range(h_cycles):
89
+ new.raw_alpha[i].copy_(
90
+ BASE_ANCHOR_VALUES[min(i, len(BASE_ANCHOR_VALUES) - 1)]
91
+ )
92
+ recurrent.visual_anchor = new
93
+ LAST_SCHEDULE["h"], LAST_SCHEDULE["l"] = h_cycles, l_cycles
94
+
95
+
96
+ # Preset schedules from the paper's Table 4 sweep: (H, L) -> unrolled depth
97
+ # 16 * H * (L + 1). H2L3 (the trained schedule) is the default.
98
+ SCHEDULES = [
99
+ ("H1L1 (32 layers, fastest)", 1, 1),
100
+ ("H1L3 (64 layers)", 1, 3),
101
+ ("H2L1 (64 layers)", 2, 1),
102
+ ("H2L3 (128 layers, default)", 2, 3),
103
+ ("H2L5 (192 layers)", 2, 5),
104
+ ("H4L3 (256 layers)", 4, 3),
105
+ ]
106
+ _SCHEDULE_LOOKUP = {name: (h, l) for name, h, l in SCHEDULES}
107
+
108
+
109
+ def _estimate_duration(*args, **kwargs) -> int:
110
+ """Callable GPU duration: base time grows with unrolled depth and token budget."""
111
+ schedule = kwargs.get("schedule") or (args[2] if len(args) > 2 else None)
112
+ budget = kwargs.get("max_tokens") or (args[3] if len(args) > 3 else 32)
113
+ try:
114
+ h, l = _SCHEDULE_LOOKUP.get(str(schedule), (2, 3))
115
+ except Exception:
116
+ h, l = 2, 3
117
+ depth = 16 * h * (l + 1)
118
+ return min(300, 20 + int(depth * 0.09) + int(budget * 0.35))
119
+
120
+
121
+ @spaces.GPU(duration=_estimate_duration)
122
+ def answer(
123
+ image: Image.Image,
124
+ prompt: str,
125
+ schedule: str = "H2L3 (128 layers, default)",
126
+ max_tokens: int = 32,
127
+ use_trigger: bool = False,
128
+ progress=gr.Progress(track_tqdm=True),
129
+ ) -> str:
130
+ """Answer a question about an image using LoopVL, a recurrent vision-language model.
131
+
132
+ Args:
133
+ image: the input image to reason about.
134
+ prompt: the question or instruction about the image.
135
+ schedule: recurrent compute schedule (H and L cycle counts). Higher means
136
+ more effective transformer layers per forward pass.
137
+ max_tokens: maximum number of tokens to generate.
138
+ use_trigger: append the authors' reasoning trigger to the instruction.
139
+ progress: Gradio progress bar.
140
+
141
+ Returns:
142
+ The model's answer text.
143
+ """
144
+ if image is None:
145
+ return "Please provide an image."
146
+ prompt = (prompt or "").strip()
147
+ if not prompt:
148
+ return "Please provide a question or instruction about the image."
149
+
150
+ h, l = _SCHEDULE_LOOKUP.get(schedule, (2, 3))
151
+ _apply_schedule(h, l)
152
+
153
+ instruction = prompt + (TRIGGER if use_trigger else "")
154
+ pil_image = image if isinstance(image, Image.Image) else Image.fromarray(image)
155
+ batch = build_inference_batch(
156
+ tokenizer,
157
+ processor,
158
+ pil_image.convert("RGB"),
159
+ instruction,
160
+ model.config,
161
+ torch.device(DEVICE),
162
+ )
163
+ max_tokens = max(1, min(int(max_tokens), 96))
164
+ start = time.perf_counter()
165
+ with torch.inference_mode():
166
+ generated = greedy_generate(
167
+ model,
168
+ batch,
169
+ max_new_tokens=max_tokens,
170
+ eos_token_id=tokenizer.eos_token_id,
171
+ )
172
+ elapsed = time.perf_counter() - start
173
+ prediction = tokenizer.decode(generated[0], skip_special_tokens=True).strip()
174
+ depth = 16 * h * (l + 1)
175
+ label = f"Answer (schedule {schedule.split(' ')[0]} → {depth} effective layers, {elapsed:.1f}s):"
176
+ return f"{label}\n{prediction}" if prediction else f"{label}\n(no tokens generated)"
177
+
178
+
179
+ CSS = """
180
+ #col-container { max-width: 1100px; margin: 0 auto; }
181
+ .dark .gradio-container { color: var(--body-text-color); }
182
+ """
183
+
184
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo:
185
+ with gr.Column(elem_id="col-container"):
186
+ gr.Markdown(
187
+ """
188
+ # LoopVL: Recurrent Vision-Language Model
189
+
190
+ **LoopVL** (~1.6B params) reuses its 16-layer transformer stack in loops
191
+ instead of stacking 128 layers. You control the compute budget: pick an
192
+ **H/L schedule** to decide how many times the stack runs per forward pass,
193
+ then ask anything about the image.
194
+
195
+ [Model](https://huggingface.co/TierFlow/LoopVL) ·
196
+ [Code](https://github.com/Tier-Flow/LoopVL)
197
+ """
198
+ )
199
+ with gr.Row():
200
+ with gr.Column(scale=1):
201
+ image_in = gr.Image(
202
+ type="pil", label="Image", sources=["upload", "clipboard"]
203
+ )
204
+ prompt_in = gr.Textbox(
205
+ label="Question / instruction",
206
+ placeholder="What is in the image?",
207
+ lines=2,
208
+ )
209
+ schedule_in = gr.Dropdown(
210
+ choices=[name for name, _, _ in SCHEDULES],
211
+ value="H2L3 (128 layers, default)",
212
+ label="Recurrent schedule (compute budget)",
213
+ info="Unrolled depth = 16 × H × (L+1) effective layers per forward pass",
214
+ )
215
+ run_btn = gr.Button("Answer", variant="primary")
216
+ with gr.Accordion("Advanced", open=False):
217
+ max_tokens_in = gr.Slider(
218
+ minimum=8, maximum=96, step=8, value=32,
219
+ label="Max new tokens",
220
+ )
221
+ trigger_in = gr.Checkbox(
222
+ value=False,
223
+ label="Append reasoning trigger",
224
+ info='Adds "Explain your reasoning, then give the final answer." '
225
+ "(the authors' math-reasoning prompt)",
226
+ )
227
+ with gr.Column(scale=1):
228
+ answer_out = gr.Textbox(label="Answer", lines=8, show_copy_button=True)
229
+
230
+ gr.Examples(
231
+ examples=[
232
+ ["examples/cat_tabby.jpg", "What animal is this, and what is it doing?"],
233
+ ["examples/rubber_ducks.jpg", "How many objects are in this image? Describe them."],
234
+ ["examples/landscape.jpg", "Describe the scene and the weather."],
235
+ ["examples/dog.jpg", "What breed of dog does this look like?"],
236
+ ],
237
+ inputs=[image_in, prompt_in],
238
+ outputs=answer_out,
239
+ fn=answer,
240
+ cache_examples=True,
241
+ cache_mode="lazy",
242
+ label="Examples (click to load, then press Answer)",
243
+ )
244
+
245
+ run_btn.click(
246
+ answer,
247
+ inputs=[image_in, prompt_in, schedule_in, max_tokens_in, trigger_in],
248
+ outputs=answer_out,
249
+ api_name="generate",
250
+ concurrency_limit=1,
251
+ )
252
+
253
+ demo.launch(mcp_server=True)
examples/cat_tabby.jpg ADDED

Git LFS Details

  • SHA256: 7d17010bdc32ffc33df0e3fae71660db6e896ae759edb0202857120afda9f296
  • Pointer size: 131 Bytes
  • Size of remote file: 232 kB
examples/dog.jpg ADDED

Git LFS Details

  • SHA256: 6385da70b7991fc95287c5242c3e2addc982c35bd024582978b7edb2a1370838
  • Pointer size: 131 Bytes
  • Size of remote file: 114 kB
examples/landscape.jpg ADDED
examples/rubber_ducks.jpg ADDED
hrm_penguin/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Penguin-Encoder x HRM-Text recurrent VLM."""
2
+
3
+ from .configuration import HrmPenguinConfig
4
+
5
+ __all__ = ["HrmPenguinConfig"]
6
+ __version__ = "0.1.0"
7
+
hrm_penguin/configuration.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field, fields
4
+ from typing import Any
5
+
6
+ @dataclass
7
+ class VisionConfig:
8
+ model_path: str = "runtime/models/Penguin-Encoder"
9
+ hidden_size: int = 1024
10
+ patch_size: int = 14
11
+ input_size: int = 336
12
+ grid_height: int = 24
13
+ grid_width: int = 24
14
+ visual_tokens: int = 576
15
+ dynamic_resolution: bool = False
16
+ min_visual_tokens: int = 256
17
+ max_visual_tokens: int = 1024
18
+ attn_implementation: str = "sdpa"
19
+
20
+
21
+ @dataclass
22
+ class LanguageConfig:
23
+ model_path: str = "runtime/models/HRM-Text-1B"
24
+ hidden_size: int = 1536
25
+ num_attention_heads: int = 12
26
+ head_dim: int = 128
27
+ H_cycles: int = 2
28
+ L_cycles: int = 3
29
+ rope_theta: float = 10000.0
30
+ rope_parameters: dict[str, Any] = field(default_factory=dict)
31
+ max_position_embeddings: int = 4096
32
+ attn_implementation: str = "sdpa"
33
+
34
+
35
+ @dataclass
36
+ class RecurrentVisualConfig:
37
+ rope_mode: str = "hybrid_2d"
38
+ use_visual_anchor: bool = True
39
+ use_visual_gate: bool = True
40
+ gate_dim: int = 256
41
+ anchor_raw_init: float = -2.0
42
+ anchor_max_scale: float = 0.5
43
+ gate_saturation_metric_lower: float = 0.05
44
+ gate_saturation_metric_upper: float = 1.95
45
+
46
+ def validate(self) -> None:
47
+ if self.rope_mode not in {"original_1d", "hybrid_2d"}:
48
+ raise ValueError(f"Unsupported rope_mode: {self.rope_mode}")
49
+ if not 0 <= self.gate_saturation_metric_lower < 1:
50
+ raise ValueError("Invalid lower Gate saturation metric bound")
51
+ if not 1 < self.gate_saturation_metric_upper <= 2:
52
+ raise ValueError("Invalid upper Gate saturation metric bound")
53
+
54
+
55
+ @dataclass
56
+ class InferenceConfig:
57
+ max_seq_len: int = 1024
58
+ use_cache: bool = False
59
+
60
+
61
+ @dataclass
62
+ class HrmPenguinConfig:
63
+ vision: VisionConfig = field(default_factory=VisionConfig)
64
+ language: LanguageConfig = field(default_factory=LanguageConfig)
65
+ recurrent_visual: RecurrentVisualConfig = field(default_factory=RecurrentVisualConfig)
66
+ inference: InferenceConfig = field(default_factory=InferenceConfig)
67
+
68
+ def validate(self) -> None:
69
+ self.recurrent_visual.validate()
70
+ if self.vision.input_size % self.vision.patch_size:
71
+ raise ValueError("input_size must be divisible by patch_size")
72
+ expected = self.vision.grid_height * self.vision.grid_width
73
+ if expected != self.vision.visual_tokens:
74
+ raise ValueError("visual token count does not match grid")
75
+ if self.vision.min_visual_tokens < 1:
76
+ raise ValueError("min_visual_tokens must be positive")
77
+ if self.vision.max_visual_tokens < self.vision.min_visual_tokens:
78
+ raise ValueError("max_visual_tokens must be >= min_visual_tokens")
79
+ if (
80
+ self.vision.dynamic_resolution
81
+ and self.inference.max_seq_len <= self.vision.max_visual_tokens + 2
82
+ ):
83
+ raise ValueError(
84
+ "Dynamic resolution max_seq_len must leave room for instruction and response"
85
+ )
86
+ if self.inference.max_seq_len > self.language.max_position_embeddings:
87
+ raise ValueError(
88
+ "max_seq_len exceeds the HRM language model position limit"
89
+ )
90
+ if self.language.head_dim % 2:
91
+ raise ValueError("head_dim must be even for hybrid RoPE")
92
+
93
+ @classmethod
94
+ def from_dict(cls, raw: dict[str, Any]) -> "HrmPenguinConfig":
95
+ """Read the model architecture and inference settings from embedded metadata."""
96
+ def component(kind, name, defaults=None):
97
+ values = dict(defaults or {})
98
+ values.update(raw.get(name, {}))
99
+ allowed = {item.name for item in fields(kind)}
100
+ return kind(**{key: value for key, value in values.items() if key in allowed})
101
+
102
+ language = component(LanguageConfig, "language")
103
+ cfg = cls(
104
+ vision=component(VisionConfig, "vision"),
105
+ language=language,
106
+ recurrent_visual=component(RecurrentVisualConfig, "recurrent_visual"),
107
+ inference=component(
108
+ InferenceConfig, "inference",
109
+ {"max_seq_len": language.max_position_embeddings},
110
+ ),
111
+ )
112
+ cfg.validate()
113
+ return cfg
114
+
115
+ def to_dict(self) -> dict[str, Any]:
116
+ return asdict(self)
hrm_penguin/generation.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+
5
+ from .recurrent.positions import build_compact_positions
6
+
7
+
8
+ @torch.no_grad()
9
+ def greedy_generate(
10
+ model,
11
+ batch: dict[str, torch.Tensor],
12
+ max_new_tokens: int = 64,
13
+ eos_token_id: int | None = None,
14
+ ) -> torch.LongTensor:
15
+ """Correctness-first generation that recomputes the recurrent sequence each step."""
16
+ if batch["input_ids"].shape[0] != 1:
17
+ raise ValueError("greedy_generate currently supports batch_size=1")
18
+ model.eval()
19
+ input_ids = batch["input_ids"]
20
+ header_length = int(batch["header_length"])
21
+ instruction_length = int(batch["instruction_length"])
22
+ visual_token_count = int(batch["visual_token_counts"][0])
23
+ grid_height = int(batch["grid_heights"][0])
24
+ grid_width = int(batch["grid_widths"][0])
25
+ visual_embeddings = model.encode_images(
26
+ batch["pixel_values"], batch["grid_sizes"], batch["merge_sizes"]
27
+ )
28
+ generated: list[torch.Tensor] = []
29
+ for _ in range(max_new_tokens):
30
+ response_length = input_ids.shape[1] - header_length - visual_token_count - instruction_length
31
+ mask_positions, rope_positions, visual_mask, instruction_mask = build_compact_positions(
32
+ torch.tensor([header_length], device=input_ids.device),
33
+ torch.tensor([instruction_length], device=input_ids.device),
34
+ torch.tensor([response_length], device=input_ids.device),
35
+ visual_token_counts=torch.tensor([visual_token_count], device=input_ids.device),
36
+ grid_heights=torch.tensor([grid_height], device=input_ids.device),
37
+ grid_widths=torch.tensor([grid_width], device=input_ids.device),
38
+ grid_h=model.config.vision.grid_height,
39
+ grid_w=model.config.vision.grid_width,
40
+ rope_mode=model.config.recurrent_visual.rope_mode,
41
+ )
42
+ text_embeddings = model.language_model.get_input_embeddings()(input_ids)
43
+ inputs_embeds = model._replace_visual_tokens(text_embeddings, visual_embeddings, visual_mask)
44
+ prefix_length = header_length + visual_token_count + instruction_length
45
+ token_type_ids = torch.zeros_like(input_ids)
46
+ token_type_ids[:, :prefix_length] = 1
47
+ outputs = model.recurrent_model(
48
+ inputs_embeds=inputs_embeds,
49
+ attention_mask=torch.ones_like(input_ids),
50
+ token_type_ids=token_type_ids,
51
+ position_ids=mask_positions,
52
+ rope_position_ids=rope_positions,
53
+ visual_mask=visual_mask,
54
+ instruction_mask=instruction_mask,
55
+ use_cache=False,
56
+ )
57
+ next_token = model.language_model.lm_head(outputs.last_hidden_state[:, -1]).argmax(dim=-1)
58
+ generated.append(next_token)
59
+ input_ids = torch.cat((input_ids, next_token[:, None]), dim=1)
60
+ if eos_token_id is not None and bool((next_token == eos_token_id).all()):
61
+ break
62
+ return torch.stack(generated, dim=1) if generated else input_ids[:, :0]
hrm_penguin/inference.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ from .prompting import PromptParts, tokenize_without_special_tokens
5
+
6
+
7
+ def build_inference_batch(tokenizer, image_processor, image, instruction, config, device):
8
+ prompt = PromptParts()
9
+ header = tokenize_without_special_tokens(tokenizer, prompt.header)
10
+ instruction_ids = tokenize_without_special_tokens(
11
+ tokenizer, prompt.instruction_prefix + instruction + prompt.instruction_suffix
12
+ )
13
+ placeholder = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else tokenizer.eos_token_id
14
+ processed = image_processor(image, return_tensors="pt")
15
+ grid = processed["grid_sizes"][0].long()
16
+ merge = int(processed["merge_sizes"][0])
17
+ if int(grid[0]) != 1 or int(grid[1]) % merge or int(grid[2]) % merge:
18
+ raise ValueError(f"Unsupported inference grid: {grid.tolist()}, merge={merge}")
19
+ grid_height = int(grid[1]) // merge
20
+ grid_width = int(grid[2]) // merge
21
+ visual_tokens = grid_height * grid_width
22
+ input_ids = torch.tensor(
23
+ [header + [placeholder] * visual_tokens + instruction_ids],
24
+ dtype=torch.long,
25
+ device=device,
26
+ )
27
+ return {
28
+ "input_ids": input_ids,
29
+ "header_length": len(header),
30
+ "instruction_length": len(instruction_ids),
31
+ "visual_token_counts": torch.tensor([visual_tokens], device=device),
32
+ "grid_heights": torch.tensor([grid_height], device=device),
33
+ "grid_widths": torch.tensor([grid_width], device=device),
34
+ "pixel_values": processed["pixel_values"].to(device),
35
+ "grid_sizes": processed["grid_sizes"].to(device),
36
+ "merge_sizes": processed["merge_sizes"].to(device),
37
+ }
hrm_penguin/model.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+ from torch import nn
8
+
9
+ from .configuration import HrmPenguinConfig
10
+ from .recurrent.hrm_multimodal import MultimodalHrmTextModel
11
+
12
+
13
+ @dataclass
14
+ class HrmPenguinOutput:
15
+ logits: torch.Tensor | None
16
+ past_key_values: object | None = None
17
+
18
+
19
+ class HrmPenguinForConditionalGeneration(nn.Module):
20
+ def __init__(self, vision_encoder, language_model, projector, config: HrmPenguinConfig):
21
+ super().__init__()
22
+ self.vision_encoder = vision_encoder
23
+ self.language_model = language_model
24
+ self.projector = projector
25
+ self.config = config
26
+ self.last_effective_depth_metrics: dict[str, torch.Tensor] = {}
27
+
28
+ @property
29
+ def recurrent_model(self) -> MultimodalHrmTextModel:
30
+ return self.language_model.model
31
+
32
+ @staticmethod
33
+ def _replace_visual_tokens(
34
+ text_embeddings: torch.Tensor,
35
+ visual_embeddings: torch.Tensor,
36
+ visual_mask: torch.Tensor,
37
+ image_batch_indices: torch.LongTensor | None = None,
38
+ ) -> torch.Tensor:
39
+ counts = visual_mask.sum(dim=1)
40
+ visual_rows = (counts > 0).nonzero(as_tuple=False).flatten()
41
+ if image_batch_indices is None:
42
+ image_batch_indices = visual_rows
43
+ image_batch_indices = image_batch_indices.to(device=visual_mask.device, dtype=torch.long)
44
+ if not torch.equal(visual_rows, image_batch_indices):
45
+ raise ValueError(
46
+ f"visual rows {visual_rows.tolist()} do not match image batch indices "
47
+ f"{image_batch_indices.tolist()}"
48
+ )
49
+ if visual_embeddings.ndim == 2:
50
+ if int(counts[visual_rows].sum()) != visual_embeddings.shape[0]:
51
+ raise ValueError(
52
+ f"visual_mask counts {counts.tolist()} do not match packed visual "
53
+ f"embeddings {tuple(visual_embeddings.shape)}"
54
+ )
55
+ elif visual_embeddings.ndim == 3:
56
+ if visual_rows.numel() != visual_embeddings.shape[0] or not torch.all(
57
+ counts[visual_rows] == visual_embeddings.shape[1]
58
+ ):
59
+ raise ValueError(
60
+ f"visual_mask counts {counts.tolist()} do not match visual embeddings "
61
+ f"{tuple(visual_embeddings.shape)}"
62
+ )
63
+ else:
64
+ raise ValueError(
65
+ "visual_embeddings must be packed [sum(N_i), D] or dense [B, N, D]"
66
+ )
67
+ expanded_mask = visual_mask.unsqueeze(-1).expand_as(text_embeddings)
68
+ replacement = torch.zeros_like(text_embeddings).masked_scatter(
69
+ expanded_mask, visual_embeddings.to(text_embeddings.dtype).reshape(-1)
70
+ )
71
+ return torch.where(expanded_mask, replacement, text_embeddings)
72
+
73
+ def encode_images(self, pixel_values, grid_sizes, merge_sizes) -> torch.Tensor:
74
+ vision_parameter = next(self.vision_encoder.parameters())
75
+ pixel_values = pixel_values.to(device=vision_parameter.device, dtype=vision_parameter.dtype)
76
+ grid_sizes = grid_sizes.to(vision_parameter.device)
77
+ merge_sizes = merge_sizes.to(vision_parameter.device)
78
+ with torch.no_grad():
79
+ features = self.vision_encoder(pixel_values, grid_sizes, merge_sizes)
80
+ return self.projector(features)
81
+
82
+ def forward(
83
+ self,
84
+ input_ids: torch.LongTensor,
85
+ attention_mask: torch.Tensor,
86
+ token_type_ids: torch.LongTensor,
87
+ position_ids: torch.LongTensor,
88
+ rope_position_ids: torch.LongTensor,
89
+ visual_mask: torch.BoolTensor,
90
+ instruction_mask: torch.BoolTensor,
91
+ image_batch_indices: torch.LongTensor | None = None,
92
+ visual_token_counts: torch.LongTensor | None = None,
93
+ grid_heights: torch.LongTensor | None = None,
94
+ grid_widths: torch.LongTensor | None = None,
95
+ pixel_values: torch.Tensor | None = None,
96
+ grid_sizes: torch.Tensor | None = None,
97
+ merge_sizes: torch.Tensor | None = None,
98
+ vision_features: torch.Tensor | None = None,
99
+ collect_visual_diagnostics: bool = False,
100
+ collect_depth_diagnostics: bool = False,
101
+ ) -> HrmPenguinOutput:
102
+ mask_counts = visual_mask.sum(dim=1)
103
+ if visual_token_counts is not None and not torch.equal(
104
+ mask_counts, visual_token_counts.to(mask_counts.device)
105
+ ):
106
+ raise ValueError("visual_mask does not match visual_token_counts")
107
+ if grid_heights is not None and grid_widths is not None:
108
+ expected_counts = grid_heights.to(mask_counts.device) * grid_widths.to(mask_counts.device)
109
+ if not torch.equal(mask_counts, expected_counts):
110
+ raise ValueError("visual token counts do not match dynamic grid areas")
111
+ text_embeddings = self.language_model.get_input_embeddings()(input_ids)
112
+ if bool(visual_mask.any()):
113
+ if vision_features is None:
114
+ if pixel_values is None or grid_sizes is None or merge_sizes is None:
115
+ raise ValueError(
116
+ "pixel_values/grid_sizes/merge_sizes or vision_features are required"
117
+ )
118
+ vision_features = self.encode_images(pixel_values, grid_sizes, merge_sizes)
119
+ # encode_images returns projected values for the regular path.
120
+ visual_embeddings = vision_features
121
+ else:
122
+ visual_embeddings = self.projector(vision_features)
123
+ inputs_embeds = self._replace_visual_tokens(
124
+ text_embeddings,
125
+ visual_embeddings,
126
+ visual_mask,
127
+ image_batch_indices=image_batch_indices,
128
+ )
129
+ else:
130
+ inputs_embeds = text_embeddings
131
+ outputs = self.recurrent_model(
132
+ inputs_embeds=inputs_embeds,
133
+ attention_mask=attention_mask,
134
+ token_type_ids=token_type_ids,
135
+ position_ids=position_ids,
136
+ rope_position_ids=rope_position_ids,
137
+ visual_mask=visual_mask,
138
+ instruction_mask=instruction_mask,
139
+ use_cache=False,
140
+ collect_visual_diagnostics=collect_visual_diagnostics,
141
+ collect_depth_diagnostics=collect_depth_diagnostics,
142
+ )
143
+ logits = None
144
+ self.last_effective_depth_metrics = {}
145
+ diagnostics = self.recurrent_model.last_visual_diagnostics
146
+ if (
147
+ collect_depth_diagnostics
148
+ and diagnostics is not None
149
+ and diagnostics.logit_lens_response_hidden
150
+ and diagnostics.logit_lens_response_hidden[-1].numel()
151
+ ):
152
+ with torch.no_grad():
153
+ final_hidden = diagnostics.logit_lens_response_hidden[-1]
154
+ final_log_probs = F.log_softmax(
155
+ self.language_model.lm_head(final_hidden).float(), dim=-1
156
+ )
157
+ final_probs = final_log_probs.exp()
158
+ for name, hidden in zip(
159
+ diagnostics.logit_lens_names,
160
+ diagnostics.logit_lens_response_hidden,
161
+ strict=True,
162
+ ):
163
+ if hidden.shape != final_hidden.shape:
164
+ continue
165
+ intermediate_log_probs = F.log_softmax(
166
+ self.language_model.lm_head(hidden).float(), dim=-1
167
+ )
168
+ self.last_effective_depth_metrics[f"{name}_kl_to_final"] = (
169
+ F.kl_div(
170
+ intermediate_log_probs,
171
+ final_probs,
172
+ reduction="batchmean",
173
+ log_target=False,
174
+ )
175
+ )
176
+ logits = self.language_model.lm_head(outputs.last_hidden_state)
177
+ return HrmPenguinOutput(
178
+ logits=logits,
179
+ past_key_values=outputs.past_key_values,
180
+ )
hrm_penguin/prompting.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class PromptParts:
8
+ header: str = "<|im_start|>condition\nImage:\n"
9
+ text_header: str = "<|im_start|>condition\n"
10
+ instruction_prefix: str = "\nInstruction:\n"
11
+ instruction_suffix: str = "<|im_end|>\n"
12
+
13
+
14
+ def tokenize_without_special_tokens(tokenizer, text: str) -> list[int]:
15
+ return tokenizer(text, add_special_tokens=False)["input_ids"]
hrm_penguin/recurrent/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .hybrid_rope import HybridHrmRotaryEmbedding, apply_rotary_pos_emb
2
+ from .visual_anchor import VisualAnchor
3
+ from .visual_gate import LoopAwareVisualGate
4
+
5
+ __all__ = [
6
+ "HybridHrmRotaryEmbedding",
7
+ "LoopAwareVisualGate",
8
+ "VisualAnchor",
9
+ "apply_rotary_pos_emb",
10
+ ]
11
+
hrm_penguin/recurrent/hrm_multimodal.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+
8
+ from transformers.cache_utils import Cache, DynamicCache
9
+ from transformers.masking_utils import create_causal_mask
10
+ from transformers.modeling_outputs import BaseModelOutputWithPast
11
+ from transformers.models.hrm_text.modeling_hrm_text import HrmTextModel
12
+
13
+ from .hybrid_rope import HybridHrmRotaryEmbedding
14
+ from .visual_anchor import VisualAnchor
15
+ from .visual_gate import LoopAwareVisualGate, masked_mean
16
+
17
+
18
+ @dataclass
19
+ class RecurrentVisualDiagnostics:
20
+ gate_mean: list[torch.Tensor]
21
+ gate_std: list[torch.Tensor]
22
+ gate_min: list[torch.Tensor]
23
+ gate_max: list[torch.Tensor]
24
+ gate_entropy: list[torch.Tensor]
25
+ gate_saturation_rate: list[torch.Tensor]
26
+ alpha: list[torch.Tensor]
27
+ reinjection_norm: list[torch.Tensor]
28
+ current_visual_norm: list[torch.Tensor]
29
+ reinjection_ratio: list[torch.Tensor]
30
+ gate_maps: list[list[torch.Tensor]]
31
+ transition_names: list[str]
32
+ transition_delta_rms: list[torch.Tensor]
33
+ transition_relative_delta: list[torch.Tensor]
34
+ transition_cosine: list[torch.Tensor]
35
+ transition_visual_delta_rms: list[torch.Tensor]
36
+ transition_visual_cosine: list[torch.Tensor]
37
+ transition_response_delta_rms: list[torch.Tensor]
38
+ transition_response_cosine: list[torch.Tensor]
39
+ logit_lens_names: list[str]
40
+ logit_lens_response_hidden: list[torch.Tensor]
41
+
42
+ @classmethod
43
+ def empty(cls) -> "RecurrentVisualDiagnostics":
44
+ return cls(
45
+ [], [], [], [], [], [], [], [], [], [], [],
46
+ [], [], [], [], [], [], [], [], [], [],
47
+ )
48
+
49
+ @staticmethod
50
+ def _state_change(
51
+ before: torch.Tensor,
52
+ after: torch.Tensor,
53
+ mask: torch.BoolTensor,
54
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
55
+ before_selected = before.detach()[mask].float()
56
+ after_selected = after.detach()[mask].float()
57
+ if not before_selected.numel():
58
+ nan = after.new_tensor(float("nan"), dtype=torch.float32)
59
+ return nan, nan, nan
60
+ delta = after_selected - before_selected
61
+ delta_rms = delta.square().mean().sqrt()
62
+ relative_delta = delta.norm() / before_selected.norm().clamp_min(1e-12)
63
+ cosine = F.cosine_similarity(before_selected, after_selected, dim=-1).mean()
64
+ return delta_rms, relative_delta, cosine
65
+
66
+ def record_transition(
67
+ self,
68
+ name: str,
69
+ before: torch.Tensor,
70
+ after: torch.Tensor,
71
+ valid_mask: torch.BoolTensor,
72
+ visual_mask: torch.BoolTensor | None,
73
+ response_mask: torch.BoolTensor,
74
+ *,
75
+ logit_lens_token_limit: int = 4,
76
+ ) -> None:
77
+ delta_rms, relative_delta, cosine = self._state_change(before, after, valid_mask)
78
+ self.transition_names.append(name)
79
+ self.transition_delta_rms.append(delta_rms)
80
+ self.transition_relative_delta.append(relative_delta)
81
+ self.transition_cosine.append(cosine)
82
+
83
+ if visual_mask is not None and bool(visual_mask.any()):
84
+ visual_delta, _, visual_cosine = self._state_change(before, after, visual_mask)
85
+ else:
86
+ visual_delta = after.new_tensor(float("nan"), dtype=torch.float32)
87
+ visual_cosine = after.new_tensor(float("nan"), dtype=torch.float32)
88
+ self.transition_visual_delta_rms.append(visual_delta)
89
+ self.transition_visual_cosine.append(visual_cosine)
90
+
91
+ response_delta, _, response_cosine = self._state_change(
92
+ before, after, response_mask
93
+ )
94
+ self.transition_response_delta_rms.append(response_delta)
95
+ self.transition_response_cosine.append(response_cosine)
96
+
97
+ response_hidden = after.detach()[response_mask]
98
+ if response_hidden.shape[0] > logit_lens_token_limit:
99
+ positions = torch.linspace(
100
+ 0,
101
+ response_hidden.shape[0] - 1,
102
+ steps=logit_lens_token_limit,
103
+ device=response_hidden.device,
104
+ ).round().long()
105
+ response_hidden = response_hidden.index_select(0, positions)
106
+ self.logit_lens_names.append(name)
107
+ self.logit_lens_response_hidden.append(response_hidden)
108
+
109
+ def record(
110
+ self,
111
+ gate: torch.Tensor,
112
+ alpha: torch.Tensor,
113
+ reinjection: torch.Tensor,
114
+ current_visual: torch.Tensor,
115
+ valid_mask: torch.BoolTensor | None = None,
116
+ saturation_lower: float = 0.05,
117
+ saturation_upper: float = 1.95,
118
+ ) -> None:
119
+ detached = gate.detach().float()
120
+ if valid_mask is None:
121
+ valid_mask = torch.ones_like(detached, dtype=torch.bool)
122
+ selected = detached[valid_mask]
123
+ if not selected.numel():
124
+ return
125
+ probability = (selected / 2.0).clamp(1e-6, 1.0 - 1e-6)
126
+ entropy = -(probability * probability.log() + (1 - probability) * (1 - probability).log())
127
+ reinjection_norm = reinjection.detach().float()[valid_mask].norm()
128
+ current_visual_norm = current_visual.detach().float()[valid_mask].norm()
129
+ ratio = reinjection_norm / current_visual_norm.clamp_min(1e-12)
130
+ self.gate_mean.append(selected.mean())
131
+ self.gate_std.append(selected.std())
132
+ self.gate_min.append(selected.min())
133
+ self.gate_max.append(selected.max())
134
+ self.gate_entropy.append(entropy.mean())
135
+ self.gate_saturation_rate.append(
136
+ ((selected < saturation_lower) | (selected > saturation_upper)).float().mean()
137
+ )
138
+ self.alpha.append(alpha.detach().float())
139
+ self.reinjection_norm.append(reinjection_norm)
140
+ self.current_visual_norm.append(current_visual_norm)
141
+ self.reinjection_ratio.append(ratio)
142
+ self.gate_maps.append(
143
+ [detached[row, valid_mask[row]].cpu() for row in range(detached.shape[0])]
144
+ )
145
+
146
+
147
+ class MultimodalHrmTextModel(HrmTextModel):
148
+ """Minimal HRM recurrent patch for spatial RoPE and gated visual re-grounding."""
149
+
150
+ def __init__(
151
+ self,
152
+ config,
153
+ rope_mode: str = "hybrid_2d",
154
+ use_visual_anchor: bool = True,
155
+ use_visual_gate: bool = True,
156
+ gate_dim: int = 256,
157
+ anchor_raw_init: float = -2.0,
158
+ anchor_max_scale: float = 0.5,
159
+ gate_saturation_metric_lower: float = 0.05,
160
+ gate_saturation_metric_upper: float = 1.95,
161
+ ):
162
+ super().__init__(config)
163
+ if rope_mode not in {"original_1d", "hybrid_2d"}:
164
+ raise ValueError(f"Unsupported rope_mode: {rope_mode}")
165
+ self.rope_mode = rope_mode
166
+ self.use_visual_anchor = use_visual_anchor
167
+ self.use_visual_gate = use_visual_gate
168
+ self.gate_saturation_metric_lower = float(gate_saturation_metric_lower)
169
+ self.gate_saturation_metric_upper = float(gate_saturation_metric_upper)
170
+ rope_parameters = getattr(config, "rope_parameters", None) or {}
171
+ rope_theta = getattr(config, "rope_theta", None) or rope_parameters.get("rope_theta", 10000.0)
172
+ self.hybrid_rotary_emb = HybridHrmRotaryEmbedding(
173
+ head_dim=config.head_dim,
174
+ theta=rope_theta,
175
+ config=config,
176
+ )
177
+ self.visual_gate = LoopAwareVisualGate(config.hidden_size, gate_dim)
178
+ self.visual_anchor = VisualAnchor(
179
+ config.H_cycles,
180
+ raw_init=anchor_raw_init,
181
+ max_scale=anchor_max_scale,
182
+ )
183
+ self.last_visual_diagnostics: RecurrentVisualDiagnostics | None = None
184
+
185
+ @classmethod
186
+ def from_text_model(cls, text_model: HrmTextModel, **kwargs) -> "MultimodalHrmTextModel":
187
+ model = cls(text_model.config, **kwargs)
188
+ incompatible = model.load_state_dict(text_model.state_dict(), strict=False)
189
+ allowed_missing = {
190
+ "visual_gate.visual_proj.weight",
191
+ "visual_gate.query_proj.weight",
192
+ "visual_gate.out_proj.weight",
193
+ "visual_gate.out_proj.bias",
194
+ "visual_anchor.raw_alpha",
195
+ }
196
+ unexpected_missing = set(incompatible.missing_keys).difference(allowed_missing)
197
+ if unexpected_missing or incompatible.unexpected_keys:
198
+ raise RuntimeError(
199
+ f"HRM conversion mismatch: missing={sorted(unexpected_missing)}, "
200
+ f"unexpected={incompatible.unexpected_keys}"
201
+ )
202
+ return model
203
+
204
+ @staticmethod
205
+ def _extract_tokens(
206
+ states: torch.Tensor, mask: torch.Tensor
207
+ ) -> tuple[torch.Tensor, torch.BoolTensor]:
208
+ counts = mask.sum(dim=1)
209
+ max_count = int(counts.max()) if counts.numel() else 0
210
+ tokens = states.new_zeros((states.shape[0], max_count, states.shape[-1]))
211
+ valid = torch.zeros((states.shape[0], max_count), dtype=torch.bool, device=states.device)
212
+ for row, count in enumerate(counts.tolist()):
213
+ if count:
214
+ tokens[row, :count] = states[row, mask[row]]
215
+ valid[row, :count] = True
216
+ return tokens, valid
217
+
218
+ @staticmethod
219
+ def _scatter_tokens(
220
+ tokens: torch.Tensor,
221
+ valid: torch.BoolTensor,
222
+ mask: torch.Tensor,
223
+ reference: torch.Tensor,
224
+ ) -> torch.Tensor:
225
+ output = torch.zeros_like(reference)
226
+ for row in range(reference.shape[0]):
227
+ output[row, mask[row]] = tokens[row, valid[row]]
228
+ return output
229
+
230
+ def _run_stack(self, stack, hidden_states: torch.Tensor, **kwargs) -> torch.Tensor:
231
+ return stack(hidden_states, **kwargs)
232
+
233
+ def forward(
234
+ self,
235
+ input_ids: torch.LongTensor | None = None,
236
+ attention_mask: torch.Tensor | None = None,
237
+ position_ids: torch.LongTensor | None = None,
238
+ past_key_values: Cache | None = None,
239
+ token_type_ids: torch.LongTensor | None = None,
240
+ inputs_embeds: torch.FloatTensor | None = None,
241
+ use_cache: bool | None = None,
242
+ rope_position_ids: torch.LongTensor | None = None,
243
+ visual_mask: torch.BoolTensor | None = None,
244
+ instruction_mask: torch.BoolTensor | None = None,
245
+ collect_visual_diagnostics: bool = False,
246
+ collect_depth_diagnostics: bool = False,
247
+ **kwargs,
248
+ ) -> BaseModelOutputWithPast:
249
+ if (input_ids is None) ^ (inputs_embeds is not None):
250
+ raise ValueError("You must specify exactly one of input_ids or inputs_embeds")
251
+ if inputs_embeds is None:
252
+ inputs_embeds = self.embed_tokens(input_ids)
253
+ inputs_embeds = inputs_embeds * self.embedding_scale
254
+
255
+ if use_cache and past_key_values is None:
256
+ past_key_values = DynamicCache(config=self.config)
257
+ if position_ids is None:
258
+ past_seen_tokens = past_key_values.get_seq_length() if past_key_values is not None else 0
259
+ position_ids = torch.arange(inputs_embeds.shape[1], device=inputs_embeds.device) + past_seen_tokens
260
+ position_ids = position_ids.unsqueeze(0)
261
+
262
+ mask_kwargs = {
263
+ "config": self.config,
264
+ "inputs_embeds": inputs_embeds,
265
+ "attention_mask": attention_mask,
266
+ "past_key_values": past_key_values,
267
+ "position_ids": position_ids,
268
+ }
269
+ is_first_iteration = past_key_values is None or not past_key_values.is_initialized
270
+ if token_type_ids is not None and is_first_iteration and self.config.prefix_lm:
271
+ mask_kwargs["block_sequence_ids"] = torch.where(token_type_ids == 1, 0, -1)
272
+ causal_attention_mask = create_causal_mask(**mask_kwargs)
273
+
274
+ if self.rope_mode == "hybrid_2d" and rope_position_ids is not None:
275
+ position_embeddings = self.hybrid_rotary_emb(inputs_embeds, rope_position_ids)
276
+ else:
277
+ position_embeddings = self.rotary_emb(inputs_embeds, position_ids)
278
+
279
+ hidden_states_high_cycle = inputs_embeds
280
+ hidden_states_low_cycle = (
281
+ self.z_L_init.to(dtype=hidden_states_high_cycle.dtype, device=hidden_states_high_cycle.device)
282
+ .expand_as(hidden_states_high_cycle)
283
+ .contiguous()
284
+ )
285
+ multimodal = visual_mask is not None and bool(visual_mask.any())
286
+ if multimodal:
287
+ if instruction_mask is None:
288
+ raise ValueError("instruction_mask is required when visual_mask is provided")
289
+ multimodal_rows = visual_mask.any(dim=1)
290
+ multimodal_indices = multimodal_rows.nonzero(as_tuple=False).flatten()
291
+ visual_anchor_tokens, visual_token_valid = self._extract_tokens(
292
+ inputs_embeds[multimodal_rows], visual_mask[multimodal_rows]
293
+ )
294
+ visual_anchor_tokens = visual_anchor_tokens.clone()
295
+ else:
296
+ multimodal_rows = None
297
+ multimodal_indices = None
298
+ visual_anchor_tokens = None
299
+ visual_token_valid = None
300
+
301
+ diagnostics = (
302
+ RecurrentVisualDiagnostics.empty()
303
+ if collect_visual_diagnostics or collect_depth_diagnostics
304
+ else None
305
+ )
306
+ valid_token_mask = (
307
+ attention_mask.bool()
308
+ if attention_mask is not None
309
+ else torch.ones(inputs_embeds.shape[:2], dtype=torch.bool, device=inputs_embeds.device)
310
+ )
311
+ response_mask = valid_token_mask & (
312
+ token_type_ids == 0 if token_type_ids is not None else valid_token_mask
313
+ )
314
+ num_layers_per_stack = self.config.num_layers_per_stack
315
+ for high_cycle_idx in range(self.config.H_cycles):
316
+ if multimodal and self.use_visual_anchor:
317
+ assert multimodal_rows is not None and multimodal_indices is not None
318
+ current_visual, current_visual_valid = self._extract_tokens(
319
+ hidden_states_high_cycle[multimodal_rows], visual_mask[multimodal_rows]
320
+ )
321
+ assert visual_token_valid is not None
322
+ if not torch.equal(current_visual_valid, visual_token_valid):
323
+ raise RuntimeError("Dynamic visual-token layout changed inside HRM recurrence")
324
+ instruction_state = masked_mean(
325
+ hidden_states_high_cycle[multimodal_rows],
326
+ instruction_mask[multimodal_rows],
327
+ )
328
+ if self.use_visual_gate:
329
+ gates = self.visual_gate(current_visual, instruction_state)
330
+ else:
331
+ gates = torch.ones(current_visual.shape[:2], dtype=current_visual.dtype, device=current_visual.device)
332
+ gates = gates.masked_fill(~visual_token_valid, 0.0)
333
+ reinjection_tokens = self.visual_anchor(visual_anchor_tokens, gates, high_cycle_idx)
334
+ reinjection_rows = self._scatter_tokens(
335
+ reinjection_tokens,
336
+ visual_token_valid,
337
+ visual_mask[multimodal_rows],
338
+ hidden_states_high_cycle[multimodal_rows],
339
+ )
340
+ reinjection_full = torch.zeros_like(hidden_states_high_cycle).index_copy(
341
+ 0, multimodal_indices, reinjection_rows
342
+ )
343
+ hidden_states_high_cycle = hidden_states_high_cycle + reinjection_full
344
+ if diagnostics is not None:
345
+ diagnostics.record(
346
+ gates,
347
+ self.visual_anchor.alpha(high_cycle_idx),
348
+ reinjection_tokens,
349
+ current_visual,
350
+ visual_token_valid,
351
+ self.gate_saturation_metric_lower,
352
+ self.gate_saturation_metric_upper,
353
+ )
354
+
355
+ for low_cycle_idx in range(self.config.L_cycles):
356
+ cycle_offset = (
357
+ high_cycle_idx * (self.config.L_cycles + 1) + low_cycle_idx
358
+ ) * num_layers_per_stack
359
+ previous_low = hidden_states_low_cycle
360
+ hidden_states_low_cycle = self._run_stack(
361
+ self.L_module,
362
+ hidden_states_low_cycle.to(hidden_states_high_cycle.device) + hidden_states_high_cycle,
363
+ attention_mask=causal_attention_mask,
364
+ past_key_values=past_key_values,
365
+ position_embeddings=position_embeddings,
366
+ position_ids=position_ids,
367
+ cycle_offset=cycle_offset,
368
+ **kwargs,
369
+ )
370
+ if diagnostics is not None and collect_depth_diagnostics:
371
+ diagnostics.record_transition(
372
+ f"h{high_cycle_idx}_l{low_cycle_idx}",
373
+ previous_low,
374
+ hidden_states_low_cycle,
375
+ valid_token_mask,
376
+ visual_mask,
377
+ response_mask,
378
+ )
379
+
380
+ cycle_offset = (
381
+ high_cycle_idx * (self.config.L_cycles + 1) + self.config.L_cycles
382
+ ) * num_layers_per_stack
383
+ previous_high = hidden_states_high_cycle
384
+ hidden_states_high_cycle = self._run_stack(
385
+ self.H_module,
386
+ hidden_states_high_cycle + hidden_states_low_cycle.to(hidden_states_high_cycle.device),
387
+ attention_mask=causal_attention_mask,
388
+ past_key_values=past_key_values,
389
+ position_embeddings=position_embeddings,
390
+ position_ids=position_ids,
391
+ cycle_offset=cycle_offset,
392
+ **kwargs,
393
+ )
394
+ if diagnostics is not None and collect_depth_diagnostics:
395
+ diagnostics.record_transition(
396
+ f"h{high_cycle_idx}_h",
397
+ previous_high,
398
+ hidden_states_high_cycle,
399
+ valid_token_mask,
400
+ visual_mask,
401
+ response_mask,
402
+ )
403
+
404
+ self.last_visual_diagnostics = diagnostics
405
+ return BaseModelOutputWithPast(
406
+ last_hidden_state=hidden_states_high_cycle,
407
+ past_key_values=past_key_values,
408
+ )
hrm_penguin/recurrent/hybrid_rope.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ from torch import nn
5
+
6
+
7
+ def rotate_half(x: torch.Tensor) -> torch.Tensor:
8
+ first, second = x.chunk(2, dim=-1)
9
+ return torch.cat((-second, first), dim=-1)
10
+
11
+
12
+ def apply_rotary_pos_emb(
13
+ query: torch.Tensor,
14
+ key: torch.Tensor,
15
+ cos: torch.Tensor,
16
+ sin: torch.Tensor,
17
+ unsqueeze_dim: int = 1,
18
+ ) -> tuple[torch.Tensor, torch.Tensor]:
19
+ cos = cos.unsqueeze(unsqueeze_dim)
20
+ sin = sin.unsqueeze(unsqueeze_dim)
21
+ return (
22
+ query * cos + rotate_half(query) * sin,
23
+ key * cos + rotate_half(key) * sin,
24
+ )
25
+
26
+
27
+ class HybridHrmRotaryEmbedding(nn.Module):
28
+ """2D visual RoPE that exactly reduces to HRM 1D RoPE for positions (p, p)."""
29
+
30
+ def __init__(
31
+ self,
32
+ head_dim: int = 128,
33
+ theta: float = 10000.0,
34
+ config=None,
35
+ ):
36
+ super().__init__()
37
+ if head_dim % 2:
38
+ raise ValueError("head_dim must be even")
39
+ attention_scaling = 1.0
40
+ rope_parameters = getattr(config, "rope_parameters", None) or {}
41
+ rope_type = rope_parameters.get("rope_type", "default")
42
+ if config is not None and rope_type != "default":
43
+ from transformers.modeling_rope_utils import ROPE_INIT_FUNCTIONS
44
+
45
+ if rope_type not in ROPE_INIT_FUNCTIONS:
46
+ raise ValueError(f"Unsupported hybrid RoPE type: {rope_type}")
47
+ inv_freq, attention_scaling = ROPE_INIT_FUNCTIONS[rope_type](
48
+ config,
49
+ device=None,
50
+ )
51
+ inv_freq = inv_freq.float()
52
+ else:
53
+ inv_freq = 1.0 / (
54
+ theta ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim)
55
+ )
56
+ self.head_dim = head_dim
57
+ self.rope_type = rope_type
58
+ self.attention_scaling = float(attention_scaling)
59
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
60
+
61
+ def forward(
62
+ self,
63
+ x: torch.Tensor,
64
+ rope_position_ids: torch.LongTensor,
65
+ ) -> tuple[torch.Tensor, torch.Tensor]:
66
+ if rope_position_ids.ndim == 2:
67
+ rope_position_ids = rope_position_ids.unsqueeze(0).expand(2, -1, -1)
68
+ if rope_position_ids.ndim != 3 or rope_position_ids.shape[0] != 2:
69
+ raise ValueError("rope_position_ids must have shape [2, B, S]")
70
+ positions = rope_position_ids.float()
71
+ freqs = positions.unsqueeze(-1) * self.inv_freq.float().view(1, 1, 1, -1)
72
+ # Standard HRM cos is [freqs, freqs]. Selecting axis0's first half
73
+ # and axis1's second half preserves exact 1D behavior for (p, p).
74
+ angles = torch.cat((freqs[0], freqs[1]), dim=-1)
75
+ if angles.shape[-1] != self.head_dim:
76
+ raise RuntimeError(f"RoPE produced {angles.shape[-1]} dims, expected {self.head_dim}")
77
+ scale = self.attention_scaling
78
+ return (angles.cos() * scale).to(x.dtype), (angles.sin() * scale).to(x.dtype)
hrm_penguin/recurrent/positions.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+
5
+
6
+ def build_compact_positions(
7
+ header_lengths: torch.LongTensor,
8
+ instruction_lengths: torch.LongTensor,
9
+ response_lengths: torch.LongTensor,
10
+ visual_token_counts: torch.LongTensor | None = None,
11
+ grid_heights: torch.LongTensor | None = None,
12
+ grid_widths: torch.LongTensor | None = None,
13
+ grid_h: int = 24,
14
+ grid_w: int = 24,
15
+ rope_mode: str = "hybrid_2d",
16
+ ) -> tuple[torch.LongTensor, torch.LongTensor, torch.BoolTensor, torch.BoolTensor]:
17
+ """Build true sequence positions and independent compact spatial RoPE positions."""
18
+ batch = int(header_lengths.numel())
19
+ visual_tokens = grid_h * grid_w
20
+ if visual_token_counts is None:
21
+ visual_token_counts = torch.full_like(header_lengths, visual_tokens)
22
+ if grid_heights is None:
23
+ grid_heights = torch.where(
24
+ visual_token_counts > 0,
25
+ torch.full_like(visual_token_counts, grid_h),
26
+ torch.zeros_like(visual_token_counts),
27
+ )
28
+ if grid_widths is None:
29
+ grid_widths = torch.where(
30
+ visual_token_counts > 0,
31
+ torch.full_like(visual_token_counts, grid_w),
32
+ torch.zeros_like(visual_token_counts),
33
+ )
34
+ if not (
35
+ grid_heights.shape == visual_token_counts.shape
36
+ and grid_widths.shape == visual_token_counts.shape
37
+ ):
38
+ raise ValueError("Per-sample grid tensors must match visual_token_counts")
39
+ expected_counts = grid_heights * grid_widths
40
+ if bool((expected_counts != visual_token_counts).any()):
41
+ raise ValueError(
42
+ "visual token counts must exactly equal per-sample grid area: "
43
+ f"counts={visual_token_counts.tolist()}, "
44
+ f"heights={grid_heights.tolist()}, widths={grid_widths.tolist()}"
45
+ )
46
+ total_lengths = header_lengths + visual_token_counts + instruction_lengths + response_lengths
47
+ max_length = int(total_lengths.max().item())
48
+ device = header_lengths.device
49
+ mask_positions = torch.arange(max_length, device=device).expand(batch, -1).clone()
50
+ rope_positions = mask_positions.unsqueeze(0).expand(2, -1, -1).clone()
51
+ visual_mask = torch.zeros((batch, max_length), dtype=torch.bool, device=device)
52
+ instruction_mask = torch.zeros_like(visual_mask)
53
+
54
+ for index in range(batch):
55
+ header = int(header_lengths[index])
56
+ instruction = int(instruction_lengths[index])
57
+ response = int(response_lengths[index])
58
+ row_visual_tokens = int(visual_token_counts[index])
59
+ row_grid_h = int(grid_heights[index])
60
+ row_grid_w = int(grid_widths[index])
61
+ visual_start = header
62
+ visual_end = visual_start + row_visual_tokens
63
+ instruction_start = visual_end
64
+ instruction_end = instruction_start + instruction
65
+ response_end = instruction_end + response
66
+ visual_mask[index, visual_start:visual_end] = True
67
+ instruction_mask[index, instruction_start:instruction_end] = True
68
+ if rope_mode == "hybrid_2d" and row_visual_tokens:
69
+ rows = torch.arange(row_grid_h, device=device).repeat_interleave(row_grid_w) + header
70
+ cols = torch.arange(row_grid_w, device=device).repeat(row_grid_h) + header
71
+ rope_positions[0, index, visual_start:visual_end] = rows
72
+ rope_positions[1, index, visual_start:visual_end] = cols
73
+ text_start = header + max(row_grid_h, row_grid_w)
74
+ tail = torch.arange(response_end - instruction_start, device=device) + text_start
75
+ rope_positions[:, index, instruction_start:response_end] = tail.unsqueeze(0)
76
+ return mask_positions, rope_positions, visual_mask, instruction_mask
hrm_penguin/recurrent/visual_anchor.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ from torch import nn
5
+
6
+
7
+ class VisualAnchor(nn.Module):
8
+ def __init__(
9
+ self,
10
+ H_cycles: int = 2,
11
+ raw_init: float = -2.0,
12
+ max_scale: float = 0.5,
13
+ ):
14
+ super().__init__()
15
+ self.raw_alpha = nn.Parameter(torch.full((H_cycles,), float(raw_init)))
16
+ self.max_scale = float(max_scale)
17
+
18
+ def alpha(self, cycle: int) -> torch.Tensor:
19
+ return self.max_scale * torch.sigmoid(self.raw_alpha[cycle])
20
+
21
+ def forward(
22
+ self,
23
+ visual_anchor: torch.Tensor,
24
+ gates: torch.Tensor,
25
+ cycle: int,
26
+ ) -> torch.Tensor:
27
+ alpha = self.alpha(cycle).to(dtype=visual_anchor.dtype)
28
+ return alpha * gates.unsqueeze(-1) * visual_anchor
hrm_penguin/recurrent/visual_gate.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ import torch.nn.functional as F
5
+ from torch import nn
6
+
7
+
8
+ class LoopAwareVisualGate(nn.Module):
9
+ def __init__(self, hidden_size: int = 1536, gate_dim: int = 256):
10
+ super().__init__()
11
+ self.visual_proj = nn.Linear(hidden_size, gate_dim, bias=False)
12
+ self.query_proj = nn.Linear(hidden_size, gate_dim, bias=False)
13
+ self.out_proj = nn.Linear(gate_dim, 1, bias=True)
14
+ self.reset_output_head()
15
+
16
+ def reset_output_head(self) -> None:
17
+ """Make the gate neutral while retaining its learned feature projections."""
18
+ nn.init.zeros_(self.out_proj.weight)
19
+ nn.init.zeros_(self.out_proj.bias)
20
+
21
+ def reset_parameters(self) -> None:
22
+ """Initialize a neutral gate before checkpoint loading."""
23
+ self.visual_proj.reset_parameters()
24
+ self.query_proj.reset_parameters()
25
+ self.reset_output_head()
26
+
27
+ def forward(
28
+ self,
29
+ visual_states: torch.Tensor,
30
+ instruction_state: torch.Tensor,
31
+ ) -> torch.Tensor:
32
+ output_dtype = visual_states.dtype
33
+ parameter_dtype = self.visual_proj.weight.dtype
34
+ # Keep these linears and sigmoid in fp32 when the surrounding model
35
+ # uses bf16 autocast, preserving the checkpoint's gate precision.
36
+ with torch.autocast(device_type=visual_states.device.type, enabled=False):
37
+ visual = self.visual_proj(visual_states.to(parameter_dtype))
38
+ query = self.query_proj(instruction_state.to(parameter_dtype)).unsqueeze(1)
39
+ score = self.out_proj(F.silu(visual + query)).squeeze(-1)
40
+ # Preserve the evaluated checkpoint's floating-point operation
41
+ # order, including the bounded auxiliary gate calculation.
42
+ bounded_score = score.clamp(-8.0, 8.0)
43
+ surrogate_score = score + (bounded_score - score).detach()
44
+ raw_gate = 2.0 * torch.sigmoid(score)
45
+ surrogate_gate = 2.0 * torch.sigmoid(surrogate_score)
46
+ gate = raw_gate.detach() + surrogate_gate - surrogate_gate.detach()
47
+ return gate.to(output_dtype)
48
+
49
+
50
+ def masked_mean(states: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
51
+ weights = mask.to(states.dtype).unsqueeze(-1)
52
+ denominator = weights.sum(dim=1).clamp_min(1.0)
53
+ return (states * weights).sum(dim=1) / denominator
hrm_penguin/vision/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .encoder import PenguinVisionEncoder
2
+ from .preprocessing import (
3
+ PenguinDynamicResolutionProcessor,
4
+ PenguinLetterboxProcessor,
5
+ build_penguin_image_processor,
6
+ letterbox_image,
7
+ )
8
+ from .projector import VisionProjector
9
+
10
+ __all__ = [
11
+ "PenguinVisionEncoder",
12
+ "PenguinLetterboxProcessor",
13
+ "PenguinDynamicResolutionProcessor",
14
+ "build_penguin_image_processor",
15
+ "VisionProjector",
16
+ "letterbox_image",
17
+ ]
hrm_penguin/vision/encoder.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from types import MethodType
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+ from torch import nn
8
+
9
+
10
+ def _rotate_half(value: torch.Tensor) -> torch.Tensor:
11
+ first, second = value.chunk(2, dim=-1)
12
+ return torch.cat((-second, first), dim=-1)
13
+
14
+
15
+ def _apply_penguin_rotary(query, key, cos, sin):
16
+ section = [cos.shape[-1] // 2, cos.shape[-1] // 2]
17
+ cos = torch.cat([axis[index % 2] for index, axis in enumerate(cos.split(section, dim=-1))], dim=-1)
18
+ sin = torch.cat([axis[index % 2] for index, axis in enumerate(sin.split(section, dim=-1))], dim=-1)
19
+ cos = cos.unsqueeze(1)
20
+ sin = sin.unsqueeze(1)
21
+ return query * cos + _rotate_half(query) * sin, key * cos + _rotate_half(key) * sin
22
+
23
+
24
+ def _penguin_sdpa_attention_forward(
25
+ module,
26
+ hidden_states: torch.Tensor,
27
+ position_embeddings,
28
+ attention_mask=None,
29
+ past_key_value=None,
30
+ cache_position=None,
31
+ cu_seqlens=None,
32
+ **kwargs,
33
+ ):
34
+ """SDPA equivalent of Penguin's packed, non-causal FlashAttention path."""
35
+ input_shape = hidden_states.shape[:-1]
36
+ hidden_shape = (*input_shape, -1, module.head_dim)
37
+ query = module.q_norm(module.q_proj(hidden_states).view(hidden_shape)).transpose(1, 2)
38
+ key = module.k_norm(module.k_proj(hidden_states).view(hidden_shape)).transpose(1, 2)
39
+ value = module.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
40
+ cos, sin = position_embeddings
41
+ query, key = _apply_penguin_rotary(query, key, cos, sin)
42
+ if past_key_value is not None:
43
+ cache_kwargs = {"sin": sin, "cos": cos, "cache_position": cache_position}
44
+ key, value = past_key_value.update(key, value, module.layer_idx, cache_kwargs)
45
+
46
+ if cu_seqlens is None:
47
+ attended = F.scaled_dot_product_attention(
48
+ query,
49
+ key,
50
+ value,
51
+ attn_mask=attention_mask,
52
+ dropout_p=0.0,
53
+ is_causal=False,
54
+ enable_gqa=query.shape[1] != key.shape[1],
55
+ )
56
+ else:
57
+ # Penguin packs image sequences along S inside a singleton batch. Splitting
58
+ # here prevents patches from different images attending to one another.
59
+ boundaries = cu_seqlens.detach().cpu().tolist()
60
+ chunks = []
61
+ for start, end in zip(boundaries[:-1], boundaries[1:]):
62
+ chunks.append(
63
+ F.scaled_dot_product_attention(
64
+ query[:, :, start:end],
65
+ key[:, :, start:end],
66
+ value[:, :, start:end],
67
+ dropout_p=0.0,
68
+ is_causal=False,
69
+ enable_gqa=query.shape[1] != key.shape[1],
70
+ )
71
+ )
72
+ attended = torch.cat(chunks, dim=2)
73
+ attended = attended.transpose(1, 2).reshape(*input_shape, -1).contiguous()
74
+ return module.o_proj(attended), None
75
+
76
+
77
+ def install_penguin_sdpa(model: nn.Module) -> int:
78
+ patched = 0
79
+ for module in model.modules():
80
+ if module.__class__.__name__ == "PenguinVLAttention":
81
+ module.forward = MethodType(_penguin_sdpa_attention_forward, module)
82
+ patched += 1
83
+ if patched == 0:
84
+ raise RuntimeError("No PenguinVLAttention modules found for SDPA patch")
85
+ return patched
86
+
87
+
88
+ def install_penguin_transformers_compatibility() -> None:
89
+ """Bridge Penguin remote code written for Transformers 4.51 to 5.x RoPE APIs."""
90
+ from transformers.modeling_rope_utils import ROPE_INIT_FUNCTIONS
91
+
92
+ if "default" not in ROPE_INIT_FUNCTIONS:
93
+ ROPE_INIT_FUNCTIONS["default"] = compute_default_rope_parameters
94
+
95
+
96
+ def compute_default_rope_parameters(config, device=None, **kwargs):
97
+ parameters = getattr(config, "rope_parameters", None) or getattr(config, "rope_scaling", None) or {}
98
+ theta = float(parameters.get("rope_theta", getattr(config, "rope_theta", 10000.0)))
99
+ dimension = int(getattr(config, "head_dim", config.hidden_size // config.num_attention_heads))
100
+ inverse_frequency = 1.0 / (
101
+ theta ** (torch.arange(0, dimension, 2, dtype=torch.float32, device=device) / dimension)
102
+ )
103
+ return inverse_frequency, 1.0
104
+
105
+
106
+ class PenguinVisionEncoder(nn.Module):
107
+ """Thin checked wrapper around the official Penguin encoder."""
108
+
109
+ def __init__(self, model: nn.Module, hidden_size: int = 1024):
110
+ super().__init__()
111
+ self.model = model
112
+ self.hidden_size = hidden_size
113
+
114
+ def forward(self, pixel_values, grid_sizes, merge_sizes) -> torch.Tensor:
115
+ features = self.model(
116
+ pixel_values=pixel_values,
117
+ grid_sizes=grid_sizes,
118
+ merge_sizes=merge_sizes,
119
+ )
120
+ if hasattr(features, "last_hidden_state"):
121
+ features = features.last_hidden_state
122
+ if features.ndim != 2 or features.shape[-1] != self.hidden_size:
123
+ raise RuntimeError(
124
+ f"Expected packed Penguin output [sum(N_i), {self.hidden_size}], "
125
+ f"got {tuple(features.shape)}"
126
+ )
127
+ if grid_sizes.ndim != 2 or grid_sizes.shape[1] != 3:
128
+ raise RuntimeError(f"Invalid grid_sizes shape: {tuple(grid_sizes.shape)}")
129
+ if merge_sizes.ndim != 1 or merge_sizes.shape[0] != grid_sizes.shape[0]:
130
+ raise RuntimeError(f"Invalid merge_sizes shape: {tuple(merge_sizes.shape)}")
131
+ spatial = grid_sizes[:, 1:] // merge_sizes[:, None]
132
+ if bool((spatial * merge_sizes[:, None] != grid_sizes[:, 1:]).any()):
133
+ raise RuntimeError("Grid dimensions must be divisible by merge_size")
134
+ counts = grid_sizes[:, 0] * spatial[:, 0] * spatial[:, 1]
135
+ if features.shape[0] != int(counts.sum()):
136
+ raise RuntimeError(
137
+ "Packed Penguin features do not match dynamic grids: "
138
+ f"features={features.shape[0]}, counts={counts.tolist()}"
139
+ )
140
+ return features
hrm_penguin/vision/preprocessing.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from collections.abc import Sequence
5
+
6
+ from PIL import Image, ImageOps
7
+
8
+
9
+ def letterbox_image(
10
+ image: Image.Image,
11
+ size: int = 336,
12
+ fill: tuple[int, int, int] = (128, 128, 128),
13
+ ) -> Image.Image:
14
+ """EXIF-correct, convert to RGB, preserve aspect ratio, and center pad."""
15
+ image = ImageOps.exif_transpose(image).convert("RGB")
16
+ width, height = image.size
17
+ if width < 1 or height < 1:
18
+ raise ValueError(f"Invalid image size: {image.size}")
19
+ scale = min(size / width, size / height)
20
+ new_width = max(1, round(width * scale))
21
+ new_height = max(1, round(height * scale))
22
+ image = image.resize((new_width, new_height), Image.Resampling.BICUBIC)
23
+ canvas = Image.new("RGB", (size, size), fill)
24
+ canvas.paste(image, ((size - new_width) // 2, (size - new_height) // 2))
25
+ return canvas
26
+
27
+
28
+ class PenguinLetterboxProcessor:
29
+ """Letterbox first, then delegate rescale/normalize/patchify to Penguin."""
30
+
31
+ def __init__(self, processor, image_size: int = 336):
32
+ self.processor = processor
33
+ self.image_size = image_size
34
+
35
+ @property
36
+ def neutral_fill(self) -> tuple[int, int, int]:
37
+ mean = getattr(self.processor, "image_mean", (0.5, 0.5, 0.5))
38
+ if isinstance(mean, (float, int)):
39
+ mean = (mean, mean, mean)
40
+ return tuple(max(0, min(255, round(float(value) * 255))) for value in mean)
41
+
42
+ def __call__(self, images: Image.Image | Sequence[Image.Image], return_tensors: str = "pt"):
43
+ if isinstance(images, Image.Image):
44
+ images = [images]
45
+ boxed = [letterbox_image(image, self.image_size, self.neutral_fill) for image in images]
46
+ result = self.processor(
47
+ images=boxed,
48
+ do_resize=False,
49
+ merge_size=1,
50
+ return_tensors=return_tensors,
51
+ )
52
+ required = {"pixel_values", "grid_sizes", "merge_sizes"}
53
+ missing = required.difference(result.keys())
54
+ if missing:
55
+ raise RuntimeError(f"Penguin processor missing fields: {sorted(missing)}")
56
+ return result
57
+
58
+
59
+ class PenguinDynamicResolutionProcessor:
60
+ """Use Penguin's native aspect-ratio preserving resize without image padding.
61
+
62
+ Penguin already packs differently sized images into one flat patch tensor.
63
+ This wrapper fixes the supported token range, applies EXIF orientation, and
64
+ validates that every emitted token corresponds to a real resized image patch.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ processor,
70
+ min_visual_tokens: int = 256,
71
+ max_visual_tokens: int = 1024,
72
+ ):
73
+ if min_visual_tokens < 1 or max_visual_tokens < min_visual_tokens:
74
+ raise ValueError("Invalid dynamic visual-token range")
75
+ self.processor = processor
76
+ self.min_visual_tokens = int(min_visual_tokens)
77
+ self.max_visual_tokens = int(max_visual_tokens)
78
+ # The remote Penguin processor reads these attributes while selecting
79
+ # aligned target sizes. merge_size=1 keeps one encoder token per patch.
80
+ self.processor.min_tokens = self.min_visual_tokens
81
+ self.processor.max_tokens = self.max_visual_tokens
82
+
83
+ def _fit_single(
84
+ self,
85
+ image: Image.Image,
86
+ return_tensors: str,
87
+ min_visual_tokens: int | None = None,
88
+ max_visual_tokens: int | None = None,
89
+ ):
90
+ """Fit one real image to the configured patch-token contract.
91
+
92
+ Penguin's helper internally multiplies ``min_tokens`` by 1.5 and
93
+ ``max_tokens`` by 0.95 before choosing a grid. Compensate for those
94
+ implementation factors, then tighten and retry if aspect-ratio rounding
95
+ crosses the public limits. No canvas or synthetic patch is introduced.
96
+ """
97
+
98
+ minimum = int(min_visual_tokens or self.min_visual_tokens)
99
+ maximum = int(max_visual_tokens or self.max_visual_tokens)
100
+ if minimum < 1 or maximum < minimum or maximum > self.max_visual_tokens:
101
+ raise ValueError(f"Invalid per-image token range [{minimum}, {maximum}]")
102
+ original_min = self.processor.min_tokens
103
+ original_max = self.processor.max_tokens
104
+ # Floor is intentional: using ceil(256 / 1.5) makes Penguin's own
105
+ # ``ceil`` alignment jump a square 16x16 grid to 17x17.
106
+ delegate_min = max(1, math.floor(minimum / 1.5))
107
+ delegate_max = max(delegate_min, math.ceil(maximum / 0.95))
108
+ last_count = None
109
+ try:
110
+ for _ in range(12):
111
+ self.processor.min_tokens = delegate_min
112
+ self.processor.max_tokens = delegate_max
113
+ result = self.processor(
114
+ images=[image],
115
+ do_resize=True,
116
+ merge_size=1,
117
+ return_tensors=return_tensors,
118
+ )
119
+ grids = result["grid_sizes"]
120
+ count = int(grids.prod(dim=1)[0])
121
+ last_count = count
122
+ if minimum <= count <= maximum:
123
+ return result
124
+ if count > maximum:
125
+ delegate_max = max(
126
+ delegate_min,
127
+ math.floor(
128
+ delegate_max
129
+ * maximum
130
+ / max(count, 1)
131
+ * 0.98
132
+ ),
133
+ )
134
+ else:
135
+ delegate_min = min(
136
+ delegate_max,
137
+ math.ceil(
138
+ delegate_min
139
+ * minimum
140
+ / max(count, 1)
141
+ * 1.02
142
+ ),
143
+ )
144
+ raise RuntimeError(
145
+ "Penguin token-budget tightening did not converge: "
146
+ f"last_count={last_count}, range="
147
+ f"[{minimum}, {maximum}]"
148
+ )
149
+ finally:
150
+ self.processor.min_tokens = original_min
151
+ self.processor.max_tokens = original_max
152
+
153
+ def __call__(
154
+ self,
155
+ images: Image.Image | Sequence[Image.Image],
156
+ return_tensors: str = "pt",
157
+ max_visual_tokens: int | Sequence[int] | None = None,
158
+ ):
159
+ if isinstance(images, Image.Image):
160
+ images = [images]
161
+ images = [ImageOps.exif_transpose(image).convert("RGB") for image in images]
162
+ if not images:
163
+ raise ValueError("At least one image is required")
164
+ if any(image.width < 1 or image.height < 1 for image in images):
165
+ raise ValueError("Images must have positive dimensions")
166
+ # Penguin's video-oriented processor shares max_tokens across every
167
+ # item passed in one call. LoopVL defines the budget per image, so
168
+ # select each grid independently and concatenate the already-packed
169
+ # patch tensors afterwards.
170
+ if max_visual_tokens is None:
171
+ caps = [self.max_visual_tokens] * len(images)
172
+ elif isinstance(max_visual_tokens, int):
173
+ caps = [int(max_visual_tokens)] * len(images)
174
+ else:
175
+ caps = [int(value) for value in max_visual_tokens]
176
+ if len(caps) != len(images):
177
+ raise ValueError("One visual-token cap is required per image")
178
+ per_image = [
179
+ self._fit_single(
180
+ image,
181
+ return_tensors,
182
+ min_visual_tokens=self.min_visual_tokens,
183
+ max_visual_tokens=cap,
184
+ )
185
+ for image, cap in zip(images, caps, strict=True)
186
+ ]
187
+ first = per_image[0]
188
+ pixel_values = first["pixel_values"]
189
+ grid_sizes = first["grid_sizes"]
190
+ merge_sizes = first["merge_sizes"]
191
+ if len(per_image) > 1:
192
+ import torch
193
+
194
+ pixel_values = torch.cat([item["pixel_values"] for item in per_image], dim=0)
195
+ grid_sizes = torch.cat([item["grid_sizes"] for item in per_image], dim=0)
196
+ merge_sizes = torch.cat([item["merge_sizes"] for item in per_image], dim=0)
197
+ result = {
198
+ "pixel_values": pixel_values,
199
+ "grid_sizes": grid_sizes,
200
+ "merge_sizes": merge_sizes,
201
+ }
202
+ required = {"pixel_values", "grid_sizes", "merge_sizes"}
203
+ missing = required.difference(result.keys())
204
+ if missing:
205
+ raise RuntimeError(f"Penguin processor missing fields: {sorted(missing)}")
206
+
207
+ grids = result["grid_sizes"]
208
+ merges = result["merge_sizes"]
209
+ if grids.ndim != 2 or grids.shape[1] != 3 or grids.shape[0] != len(images):
210
+ raise RuntimeError(f"Unexpected Penguin grid_sizes shape: {tuple(grids.shape)}")
211
+ if merges.ndim != 1 or merges.shape[0] != len(images) or bool((merges != 1).any()):
212
+ raise RuntimeError("Dynamic LoopVL input requires merge_size=1 for every image")
213
+ if bool((grids[:, 0] != 1).any()):
214
+ raise RuntimeError("LoopVL supports one image frame per sample")
215
+
216
+ counts = grids.prod(dim=1)
217
+ import torch
218
+
219
+ cap_tensor = torch.tensor(caps, device=counts.device, dtype=counts.dtype)
220
+ if bool(((counts < self.min_visual_tokens) | (counts > cap_tensor)).any()):
221
+ raise RuntimeError(
222
+ "Penguin emitted visual token counts outside per-image budgets: "
223
+ f"counts={counts.tolist()}, caps={caps}"
224
+ )
225
+ pixel_values = result["pixel_values"]
226
+ if pixel_values.ndim != 2 or pixel_values.shape[0] != int(counts.sum()):
227
+ raise RuntimeError(
228
+ "Packed pixel patches do not exactly match the real dynamic grids: "
229
+ f"patches={tuple(pixel_values.shape)}, grids={grids.tolist()}"
230
+ )
231
+ return result
232
+
233
+
234
+ def build_penguin_image_processor(processor, vision_config):
235
+ if getattr(vision_config, "dynamic_resolution", False):
236
+ return PenguinDynamicResolutionProcessor(
237
+ processor,
238
+ min_visual_tokens=vision_config.min_visual_tokens,
239
+ max_visual_tokens=vision_config.max_visual_tokens,
240
+ )
241
+ return PenguinLetterboxProcessor(processor, vision_config.input_size)
hrm_penguin/vision/projector.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ import torch.nn.functional as F
5
+ from torch import nn
6
+
7
+
8
+ class VisionProjector(nn.Module):
9
+ def __init__(
10
+ self,
11
+ input_size: int = 1024,
12
+ hidden_size: int = 1536,
13
+ target_raw_rms: float = 1.0,
14
+ eps: float = 1e-6,
15
+ ):
16
+ super().__init__()
17
+ self.fc1 = nn.Linear(input_size, hidden_size, bias=True)
18
+ self.act = nn.GELU()
19
+ self.fc2 = nn.Linear(hidden_size, hidden_size, bias=True)
20
+ self.eps = eps
21
+ self.register_buffer(
22
+ "visual_embedding_scale",
23
+ torch.tensor(float(target_raw_rms), dtype=torch.float32),
24
+ )
25
+ self.last_rms_diagnostics: dict[str, torch.Tensor] | None = None
26
+
27
+ def forward(self, features: torch.Tensor) -> torch.Tensor:
28
+ features = features.to(dtype=self.fc1.weight.dtype)
29
+ projected = self.fc2(self.act(self.fc1(features)))
30
+ scaled = F.rms_norm(
31
+ projected,
32
+ normalized_shape=(projected.shape[-1],),
33
+ weight=None,
34
+ eps=self.eps,
35
+ )
36
+ scaled = scaled * self.visual_embedding_scale.to(projected.dtype)
37
+ self.last_rms_diagnostics = {
38
+ "vision_encoder_rms": features.detach().float().square().mean().sqrt(),
39
+ "projector_rms": projected.detach().float().square().mean().sqrt(),
40
+ "visual_scaled_rms": scaled.detach().float().square().mean().sqrt(),
41
+ }
42
+ return scaled
metadata.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standard-library-only metadata reader for LoopVL weight snapshots.
2
+
3
+ No code is imported from the snapshot. Version 2 stores all data files at its
4
+ root; version 1 retains its original nested image-processor JSON for backwards
5
+ compatibility. Both versions use the same code distributed in this runtime.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+
13
+ COMPONENT_DTYPES = {
14
+ "vision": "bfloat16",
15
+ "language": "bfloat16",
16
+ "projector": "float32",
17
+ "visual_gate": "float32",
18
+ "visual_anchor": "float32",
19
+ }
20
+
21
+ FLAT_DATA_FILES = (
22
+ "model.safetensors",
23
+ "config.json",
24
+ "generation_config.json",
25
+ "preprocessor_config.json",
26
+ "tokenizer.json",
27
+ "tokenizer_config.json",
28
+ )
29
+
30
+ PROCESSOR_FIELDS = (
31
+ "do_convert_rgb", "do_normalize", "do_rescale", "do_resize",
32
+ "image_mean", "image_std", "max_tokens", "min_tokens", "patch_size",
33
+ "resample", "rescale_factor",
34
+ )
35
+
36
+
37
+ def read_checkpoint_metadata(model_dir: str | Path) -> dict:
38
+ """Read and validate format metadata without torch, transformers or imports."""
39
+ root = Path(model_dir).expanduser().resolve()
40
+ raw = json.loads((root / "config.json").read_text(encoding="utf-8"))
41
+ if raw.get("model_type") != "loopvl" or raw.get("format_version") not in (1, 2):
42
+ raise ValueError("Expected LoopVL single-file checkpoint format_version 1 or 2")
43
+ if raw.get("component_dtypes") != COMPONENT_DTYPES:
44
+ raise ValueError("This loader requires the original BF16 cores / FP32 adapters")
45
+ for field in ("loopvl_config", "text_config", "vision_config"):
46
+ if not isinstance(raw.get(field), dict):
47
+ raise ValueError(f"Checkpoint lacks embedded {field}")
48
+ if raw["text_config"].get("model_type") != "hrm_text":
49
+ raise ValueError("Expected the embedded hrm_text language configuration")
50
+ if raw["vision_config"].get("model_type") != "penguinvl_vision_encoder":
51
+ raise ValueError("Expected the embedded penguinvl_vision_encoder configuration")
52
+ return raw
53
+
54
+
55
+ def read_processor_config(model_dir: str | Path, raw: dict | None = None) -> dict:
56
+ """Return the original processor parameters, never a remote-code reference.
57
+
58
+ The old root preprocessor_config.json was only a bundle description and is
59
+ deliberately not used for format 1. Omitting the true image mean/std would
60
+ silently select CLIP defaults and change pixel values.
61
+ """
62
+ root = Path(model_dir).expanduser().resolve()
63
+ if raw is None:
64
+ raw = read_checkpoint_metadata(root)
65
+ version = raw.get("format_version")
66
+ if version == 1:
67
+ path = root / "penguin_encoder" / "preprocessor_config.json"
68
+ elif version == 2:
69
+ path = root / "preprocessor_config.json"
70
+ else:
71
+ raise ValueError("Unsupported processor metadata format")
72
+ values = json.loads(path.read_text(encoding="utf-8"))
73
+ missing = sorted(set(PROCESSOR_FIELDS) - set(values))
74
+ if missing:
75
+ raise ValueError(f"Missing actual Penguin image-processor parameters: {missing}")
76
+ kind = values.get("image_processor_type", "PenguinVLImageProcessor")
77
+ if kind != "PenguinVLImageProcessor":
78
+ raise ValueError(f"Unexpected image_processor_type: {kind!r}")
79
+ # Loading uses the pinned GitHub class directly. Old auto_map metadata is
80
+ # not executable and must never redirect imports into a checkpoint folder.
81
+ values.pop("auto_map", None)
82
+ return values
modeling_loopvl.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load LoopVL's unchanged architecture from a data-only weight snapshot.
2
+
3
+ This is a small explicit loader, not a claim of native Transformers AutoModel
4
+ support. All model parameters and persistent buffers live in model.safetensors.
5
+ The original hrm_penguin implementation and Penguin classes live in this GitHub
6
+ runtime, not in the weight repository. Loading neither imports checkpoint code
7
+ nor needs the old split checkpoint or the network.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import torch
14
+ from safetensors.torch import load_file
15
+ from transformers import AutoConfig, AutoTokenizer
16
+ from transformers import HrmTextForCausalLM
17
+
18
+ from metadata import COMPONENT_DTYPES, read_checkpoint_metadata, read_processor_config
19
+ from penguin_encoder.configuration_penguinvl_encoder import PenguinVLVisionEncoderConfig
20
+ from penguin_encoder.image_processing_penguinvl import PenguinVLImageProcessor
21
+ from penguin_encoder.modeling_penguinvl_encoder import (
22
+ PenguinVLVisionEncoderModel,
23
+ VisualRotaryEmbedding,
24
+ )
25
+
26
+ from hrm_penguin.configuration import HrmPenguinConfig
27
+ from hrm_penguin.model import HrmPenguinForConditionalGeneration
28
+ from hrm_penguin.recurrent.hrm_multimodal import MultimodalHrmTextModel
29
+ from hrm_penguin.vision.encoder import (
30
+ PenguinVisionEncoder,
31
+ compute_default_rope_parameters,
32
+ install_penguin_sdpa,
33
+ install_penguin_transformers_compatibility,
34
+ )
35
+ from hrm_penguin.vision.preprocessing import build_penguin_image_processor
36
+ from hrm_penguin.vision.projector import VisionProjector
37
+
38
+
39
+ def read_config(model_dir: str | Path) -> tuple[dict, HrmPenguinConfig]:
40
+ """Read embedded model configs without importing code from model_dir."""
41
+ root = Path(model_dir).expanduser().resolve()
42
+ raw = read_checkpoint_metadata(root)
43
+ source = raw["loopvl_config"]
44
+ config = HrmPenguinConfig.from_dict(source)
45
+ # Metadata paths only: both towers are constructed from embedded configs.
46
+ # No tower-specific directory or Python file is required in a v2 snapshot.
47
+ config.vision.model_path = str(root)
48
+ config.language.model_path = str(root)
49
+ config.validate()
50
+ return raw, config
51
+
52
+
53
+ def load_tokenizer_and_processor(model_dir: str | Path, config=None):
54
+ """Use the original tokenizer, image preprocessor and dynamic-resolution policy."""
55
+ root = Path(model_dir).expanduser().resolve()
56
+ raw = read_checkpoint_metadata(root)
57
+ if config is None:
58
+ _, config = read_config(root)
59
+ tokenizer = AutoTokenizer.from_pretrained(str(root), local_files_only=True)
60
+ official = PenguinVLImageProcessor.from_dict(read_processor_config(root, raw))
61
+ processor = build_penguin_image_processor(official, config.vision)
62
+ return tokenizer, processor
63
+
64
+
65
+ # Short public synonym used by the bundled preprocessor metadata.
66
+ load_components = load_tokenizer_and_processor
67
+
68
+
69
+ class LoopVLForConditionalGeneration(HrmPenguinForConditionalGeneration):
70
+ """The existing LoopVL computation graph with a single-weight-file loader.
71
+
72
+ Usage::
73
+
74
+ model = LoopVLForConditionalGeneration.from_pretrained("./model", device="cuda:0")
75
+
76
+ No dtype/quantization override is offered: the checkpoint intentionally mixes
77
+ BF16 language/vision parameters with FP32 projector, visual gate and anchor.
78
+ Moving the model to a device is safe; calling model.half()/bfloat16() would
79
+ change those FP32 adapters and is not a lossless operation.
80
+ """
81
+
82
+ @classmethod
83
+ def from_pretrained(cls, model_dir: str | Path, *, device="cpu"):
84
+ root = Path(model_dir).expanduser().resolve()
85
+ raw, config = read_config(root)
86
+ if torch.get_default_dtype() != torch.float32:
87
+ raise ValueError("Load with torch's default dtype set to torch.float32")
88
+
89
+ # Build under HF's BF16 construction context, not model.to(BF16).
90
+ # Penguin explicitly creates FP32 nonpersistent rotary buffers; converting
91
+ # the entire vision module afterward would round those frequencies.
92
+ install_penguin_transformers_compatibility()
93
+ # Patch the exact static class instantiated below, rather than a class
94
+ # copied from a weight repository into transformers' remote-code cache.
95
+ if not hasattr(VisualRotaryEmbedding, "compute_default_rope_parameters"):
96
+ VisualRotaryEmbedding.compute_default_rope_parameters = staticmethod(
97
+ compute_default_rope_parameters
98
+ )
99
+ vision_values = dict(raw["vision_config"])
100
+ vision_values.pop("auto_map", None)
101
+ vision_config = PenguinVLVisionEncoderConfig.from_dict(vision_values)
102
+ # AutoModel.from_config previously dispatched to this same _from_config
103
+ # method. Keep its dtype construction context and attention setup.
104
+ vision_model = PenguinVLVisionEncoderModel._from_config(
105
+ vision_config,
106
+ dtype=torch.bfloat16,
107
+ attn_implementation=config.vision.attn_implementation,
108
+ )
109
+ if config.vision.attn_implementation == "sdpa":
110
+ install_penguin_sdpa(vision_model)
111
+ vision = PenguinVisionEncoder(vision_model, hidden_size=config.vision.hidden_size)
112
+
113
+ text_values = dict(raw["text_config"])
114
+ model_type = text_values.pop("model_type")
115
+ if model_type != "hrm_text":
116
+ raise ValueError(f"Unexpected language model_type: {model_type!r}")
117
+ text_config = AutoConfig.for_model(model_type, **text_values)
118
+ text_config.max_position_embeddings = config.language.max_position_embeddings
119
+ text_config.rope_theta = config.language.rope_theta
120
+ if config.language.rope_parameters:
121
+ text_config.rope_parameters = dict(config.language.rope_parameters)
122
+ text_config.use_cache = bool(config.inference.use_cache)
123
+ language = HrmTextForCausalLM._from_config(
124
+ text_config,
125
+ dtype=torch.bfloat16,
126
+ attn_implementation=config.language.attn_implementation,
127
+ )
128
+
129
+ # Reproduce the historical construction order exactly. The multimodal
130
+ # model is first constructed in FP32 and then converted to BF16, including
131
+ # its nonpersistent hybrid RoPE buffers. Only adapters are restored to FP32.
132
+ recurrent = config.recurrent_visual
133
+ language.model = MultimodalHrmTextModel.from_text_model(
134
+ language.model,
135
+ rope_mode=recurrent.rope_mode,
136
+ use_visual_anchor=recurrent.use_visual_anchor,
137
+ use_visual_gate=recurrent.use_visual_gate,
138
+ gate_dim=recurrent.gate_dim,
139
+ anchor_raw_init=recurrent.anchor_raw_init,
140
+ anchor_max_scale=recurrent.anchor_max_scale,
141
+ gate_saturation_metric_lower=recurrent.gate_saturation_metric_lower,
142
+ gate_saturation_metric_upper=recurrent.gate_saturation_metric_upper,
143
+ ).to(dtype=torch.bfloat16)
144
+ language.model.visual_gate.float()
145
+ language.model.visual_anchor.float()
146
+ projector = VisionProjector(
147
+ config.vision.hidden_size, config.language.hidden_size
148
+ ).float()
149
+ # visual_embedding_scale is a persistent checkpoint buffer, so loading
150
+ # its stored value is restored alongside the model parameters.
151
+ model = cls(vision, language, projector, config)
152
+
153
+ weights = load_file(str(root / "model.safetensors"), device="cpu")
154
+ expected = model.state_dict()
155
+ missing = sorted(set(expected) - set(weights))
156
+ unexpected = sorted(set(weights) - set(expected))
157
+ if missing or unexpected:
158
+ raise RuntimeError(f"Checkpoint key mismatch: missing={missing}, unexpected={unexpected}")
159
+ for name, tensor in weights.items():
160
+ target = expected[name]
161
+ if tensor.shape != target.shape or tensor.dtype != target.dtype:
162
+ raise RuntimeError(
163
+ f"Refusing implicit conversion for {name}: checkpoint "
164
+ f"{tuple(tensor.shape)} {tensor.dtype}, model {tuple(target.shape)} {target.dtype}"
165
+ )
166
+ model.load_state_dict(weights, strict=True)
167
+ del expected, weights
168
+ model.checkpoint_directory = str(root)
169
+ return model.to(device=torch.device(device)).eval().requires_grad_(False)
penguin_encoder/LICENSE.txt ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Tencent is pleased to support the open source community by making Penguin-VL 高效视觉语言理解模型 available.
2
+
3
+ Copyright (C) 2026 Tencent. All rights reserved.
4
+
5
+ The open-source software and/or models included in this distribution may have been modified by Tencent (“Tencent Modifications”). All Tencent Modifications are Copyright (C) Tencent.
6
+
7
+ Penguin-VL 高效视觉语言理解模型 is licensed under the License Terms of Penguin-VL 高效视觉语言理解模型, except for the third-party components listed below, which remain licensed under their respective original terms. Penguin-VL 高效视觉语言理解模型 does not impose any additional restrictions beyond those specified in the original licenses of these third-party components. Users are required to comply with all applicable terms and conditions of the original licenses and to ensure that the use of these third-party components conforms to all relevant laws and regulations.
8
+
9
+ For the avoidance of doubt, Penguin-VL 高效视觉语言理解模型 refers solely to training code, inference code, parameters, and weights made publicly available by Tencent in accordance with the License Terms of Penguin-VL 高效视觉语言理解模型.
10
+
11
+ Terms of the License Terms of Penguin-VL 高效视觉语言理解模型:
12
+ --------------------------------------------------------------------
13
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
14
+
15
+ 0. Additional Territorial Limitation ** Penguin-VL 高效视觉语言理解模型 IS NOT INTENDED FOR USE WITHIN THE EUROPEAN UNION.** IN THE EVENT OF ANY CONFLICT, THIS CLAUSE SHALL PREVAIL.
16
+
17
+ 1. Definitions.
18
+
19
+ "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
20
+
21
+ "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
22
+
23
+ "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%!)(MISSING) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
24
+
25
+ "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
28
+
29
+ "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
30
+
31
+ "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
32
+
33
+ "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
34
+
35
+ "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
36
+
37
+ "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
38
+
39
+ 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
40
+
41
+ 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
42
+
43
+ 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
44
+
45
+ You must give any other recipients of the Work or Derivative Works a copy of this License; and
46
+
47
+ You must cause any modified files to carry prominent notices stating that You changed the files; and
48
+
49
+ You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
50
+
51
+ If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
52
+
53
+ You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
54
+
55
+ 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
56
+
57
+ 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
58
+
59
+ 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
60
+
61
+ 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
62
+
63
+ 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
64
+
65
+
66
+ END OF TERMS AND CONDITIONS
67
+
68
+
69
+
70
+ Dependencies and Licenses:
71
+
72
+ This open-source project, Penguin-VL 高效视觉语言理解模型, builds upon the following open-source models and/or software components, each of which remains licensed under its original license. Certain models or software may include modifications made by Tencent (“Tencent Modifications”), which are Copyright (C) Tencent.
73
+
74
+ In case you believe there have been errors in the attribution below, you may submit the concerns to us for review and correction.
75
+
76
+
77
+ Open Source Model Licensed under the Apache License 2.0:
78
+ --------------------------------------------------------------------
79
+ 1. Qwen3
80
+ Copyright 2024 Alibaba Cloud
81
+ Please note this model has been modified by Tencent in this distribution.
82
+
83
+
84
+
85
+ Terms of the Apache License 2.0:
86
+ --------------------------------------------------------------------
87
+ Apache License
88
+ Version 2.0, January 2004
89
+ http://www.apache.org/licenses/
90
+
91
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
92
+
93
+ 1. Definitions.
94
+
95
+ "License" shall mean the terms and conditions for use, reproduction,
96
+ and distribution as defined by Sections 1 through 9 of this document.
97
+
98
+ "Licensor" shall mean the copyright owner or entity authorized by
99
+ the copyright owner that is granting the License.
100
+
101
+ "Legal Entity" shall mean the union of the acting entity and all
102
+ other entities that control, are controlled by, or are under common
103
+ control with that entity. For the purposes of this definition,
104
+ "control" means (i) the power, direct or indirect, to cause the
105
+ direction or management of such entity, whether by contract or
106
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
107
+ outstanding shares, or (iii) beneficial ownership of such entity.
108
+
109
+ "You" (or "Your") shall mean an individual or Legal Entity
110
+ exercising permissions granted by this License.
111
+
112
+ "Source" form shall mean the preferred form for making modifications,
113
+ including but not limited to software source code, documentation
114
+ source, and configuration files.
115
+
116
+ "Object" form shall mean any form resulting from mechanical
117
+ transformation or translation of a Source form, including but
118
+ not limited to compiled object code, generated documentation,
119
+ and conversions to other media types.
120
+
121
+ "Work" shall mean the work of authorship, whether in Source or
122
+ Object form, made available under the License, as indicated by a
123
+ copyright notice that is included in or attached to the work
124
+ (an example is provided in the Appendix below).
125
+
126
+ "Derivative Works" shall mean any work, whether in Source or Object
127
+ form, that is based on (or derived from) the Work and for which the
128
+ editorial revisions, annotations, elaborations, or other modifications
129
+ represent, as a whole, an original work of authorship. For the purposes
130
+ of this License, Derivative Works shall not include works that remain
131
+ separable from, or merely link (or bind by name) to the interfaces of,
132
+ the Work and Derivative Works thereof.
133
+
134
+ "Contribution" shall mean any work of authorship, including
135
+ the original version of the Work and any modifications or additions
136
+ to that Work or Derivative Works thereof, that is intentionally
137
+ submitted to Licensor for inclusion in the Work by the copyright owner
138
+ or by an individual or Legal Entity authorized to submit on behalf of
139
+ the copyright owner. For the purposes of this definition, "submitted"
140
+ means any form of electronic, verbal, or written communication sent
141
+ to the Licensor or its representatives, including but not limited to
142
+ communication on electronic mailing lists, source code control systems,
143
+ and issue tracking systems that are managed by, or on behalf of, the
144
+ Licensor for the purpose of discussing and improving the Work, but
145
+ excluding communication that is conspicuously marked or otherwise
146
+ designated in writing by the copyright owner as "Not a Contribution."
147
+
148
+ "Contributor" shall mean Licensor and any individual or Legal Entity
149
+ on behalf of whom a Contribution has been received by Licensor and
150
+ subsequently incorporated within the Work.
151
+
152
+ 2. Grant of Copyright License. Subject to the terms and conditions of
153
+ this License, each Contributor hereby grants to You a perpetual,
154
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
155
+ copyright license to reproduce, prepare Derivative Works of,
156
+ publicly display, publicly perform, sublicense, and distribute the
157
+ Work and such Derivative Works in Source or Object form.
158
+
159
+ 3. Grant of Patent License. Subject to the terms and conditions of
160
+ this License, each Contributor hereby grants to You a perpetual,
161
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
162
+ (except as stated in this section) patent license to make, have made,
163
+ use, offer to sell, sell, import, and otherwise transfer the Work,
164
+ where such license applies only to those patent claims licensable
165
+ by such Contributor that are necessarily infringed by their
166
+ Contribution(s) alone or by combination of their Contribution(s)
167
+ with the Work to which such Contribution(s) was submitted. If You
168
+ institute patent litigation against any entity (including a
169
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
170
+ or a Contribution incorporated within the Work constitutes direct
171
+ or contributory patent infringement, then any patent licenses
172
+ granted to You under this License for that Work shall terminate
173
+ as of the date such litigation is filed.
174
+
175
+ 4. Redistribution. You may reproduce and distribute copies of the
176
+ Work or Derivative Works thereof in any medium, with or without
177
+ modifications, and in Source or Object form, provided that You
178
+ meet the following conditions:
179
+
180
+ (a) You must give any other recipients of the Work or
181
+ Derivative Works a copy of this License; and
182
+
183
+ (b) You must cause any modified files to carry prominent notices
184
+ stating that You changed the files; and
185
+
186
+ (c) You must retain, in the Source form of any Derivative Works
187
+ that You distribute, all copyright, patent, trademark, and
188
+ attribution notices from the Source form of the Work,
189
+ excluding those notices that do not pertain to any part of
190
+ the Derivative Works; and
191
+
192
+ (d) If the Work includes a "NOTICE" text file as part of its
193
+ distribution, then any Derivative Works that You distribute must
194
+ include a readable copy of the attribution notices contained
195
+ within such NOTICE file, excluding those notices that do not
196
+ pertain to any part of the Derivative Works, in at least one
197
+ of the following places: within a NOTICE text file distributed
198
+ as part of the Derivative Works; within the Source form or
199
+ documentation, if provided along with the Derivative Works; or,
200
+ within a display generated by the Derivative Works, if and
201
+ wherever such third-party notices normally appear. The contents
202
+ of the NOTICE file are for informational purposes only and
203
+ do not modify the License. You may add Your own attribution
204
+ notices within Derivative Works that You distribute, alongside
205
+ or as an addendum to the NOTICE text from the Work, provided
206
+ that such additional attribution notices cannot be construed
207
+ as modifying the License.
208
+
209
+ You may add Your own copyright statement to Your modifications and
210
+ may provide additional or different license terms and conditions
211
+ for use, reproduction, or distribution of Your modifications, or
212
+ for any such Derivative Works as a whole, provided Your use,
213
+ reproduction, and distribution of the Work otherwise complies with
214
+ the conditions stated in this License.
215
+
216
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
217
+ any Contribution intentionally submitted for inclusion in the Work
218
+ by You to the Licensor shall be under the terms and conditions of
219
+ this License, without any additional terms or conditions.
220
+ Notwithstanding the above, nothing herein shall supersede or modify
221
+ the terms of any separate license agreement you may have executed
222
+ with Licensor regarding such Contributions.
223
+
224
+ 6. Trademarks. This License does not grant permission to use the trade
225
+ names, trademarks, service marks, or product names of the Licensor,
226
+ except as required for reasonable and customary use in describing the
227
+ origin of the Work and reproducing the content of the NOTICE file.
228
+
229
+ 7. Disclaimer of Warranty. Unless required by applicable law or
230
+ agreed to in writing, Licensor provides the Work (and each
231
+ Contributor provides its Contributions) on an "AS IS" BASIS,
232
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
233
+ implied, including, without limitation, any warranties or conditions
234
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
235
+ PARTICULAR PURPOSE. You are solely responsible for determining the
236
+ appropriateness of using or redistributing the Work and assume any
237
+ risks associated with Your exercise of permissions under this License.
238
+
239
+ 8. Limitation of Liability. In no event and under no legal theory,
240
+ whether in tort (including negligence), contract, or otherwise,
241
+ unless required by applicable law (such as deliberate and grossly
242
+ negligent acts) or agreed to in writing, shall any Contributor be
243
+ liable to You for damages, including any direct, indirect, special,
244
+ incidental, or consequential damages of any character arising as a
245
+ result of this License or out of the use or inability to use the
246
+ Work (including but not limited to damages for loss of goodwill,
247
+ work stoppage, computer failure or malfunction, or any and all
248
+ other commercial damages or losses), even if such Contributor
249
+ has been advised of the possibility of such damages.
250
+
251
+ 9. Accepting Warranty or Additional Liability. While redistributing
252
+ the Work or Derivative Works thereof, You may choose to offer,
253
+ and charge a fee for, acceptance of support, warranty, indemnity,
254
+ or other liability obligations and/or rights consistent with this
255
+ License. However, in accepting such obligations, You may act only
256
+ on Your own behalf and on Your sole responsibility, not on behalf
257
+ of any other Contributor, and only if You agree to indemnify,
258
+ defend, and hold each Contributor harmless for any liability
259
+ incurred by, or claims asserted against, such Contributor by reason
260
+ of your accepting any such warranty or additional liability.
261
+
262
+ END OF TERMS AND CONDITIONS
263
+
264
+ APPENDIX: How to apply the Apache License to your work.
265
+
266
+ To apply the Apache License to your work, attach the following
267
+ boilerplate notice, with the fields enclosed by brackets "[]"
268
+ replaced with your own identifying information. (Don't include
269
+ the brackets!) The text should be enclosed in the appropriate
270
+ comment syntax for the file format. We also recommend that a
271
+ file or class name and description of purpose be included on the
272
+ same "printed page" as the copyright notice for easier
273
+ identification within third-party archives.
274
+
275
+ Copyright [yyyy] [name of copyright owner]
276
+
277
+ Licensed under the Apache License, Version 2.0 (the "License");
278
+ you may not use this file except in compliance with the License.
279
+ You may obtain a copy of the License at
280
+
281
+ http://www.apache.org/licenses/LICENSE-2.0
282
+
283
+ Unless required by applicable law or agreed to in writing, software
284
+ distributed under the License is distributed on an "AS IS" BASIS,
285
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
286
+ See the License for the specific language governing permissions and
287
+ limitations under the License.
288
+
289
+
penguin_encoder/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Pinned Penguin vision implementation, distributed as code in GitHub only."""
penguin_encoder/configuration_penguinvl_encoder.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PenguinVL vision encoder model configuration."""
2
+
3
+ from transformers import Qwen3Config
4
+
5
+
6
+ class PenguinVLVisionEncoderConfig(Qwen3Config):
7
+
8
+ model_type = "penguinvl_vision_encoder"
9
+
10
+ def __init__(
11
+ self,
12
+ hidden_size=1536,
13
+ intermediate_size=8960,
14
+ num_hidden_layers=12,
15
+ num_attention_heads=12,
16
+ num_channels=3,
17
+ patch_size=14,
18
+ layer_norm_eps=1e-6,
19
+ attention_dropout=0.0,
20
+ num_key_value_heads=2,
21
+ **kwargs,
22
+ ):
23
+ super().__init__(**kwargs)
24
+
25
+ self.hidden_size = hidden_size
26
+ self.intermediate_size = intermediate_size
27
+ self.num_hidden_layers = num_hidden_layers
28
+ self.num_attention_heads = num_attention_heads
29
+ self.num_channels = num_channels
30
+ self.patch_size = patch_size
31
+ self.attention_dropout = attention_dropout
32
+ self.num_key_value_heads = num_key_value_heads
33
+ self.layer_norm_eps = layer_norm_eps
penguin_encoder/image_processing_penguinvl.py ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Adopted from https://github.com/huggingface/transformers/blob/main/src/transformers/models/qwen2_vl/image_processing_qwen2_vl.py.
2
+ # Below is the original copyright:
3
+ # Copyright 2024 The Qwen team, Alibaba Group and the HuggingFace Inc. team. All rights reserved.
4
+ #
5
+ # This code is based on EleutherAI's GPT-NeoX library and the GPT-NeoX
6
+ # and OPT implementations in this library. It has been modified from its
7
+ # original forms to accommodate minor architectural differences compared
8
+ # to GPT-NeoX and OPT used by the Meta AI team that trained the model.
9
+ #
10
+ # Licensed under the Apache License, Version 2.0 (the "License");
11
+ # you may not use this file except in compliance with the License.
12
+ # You may obtain a copy of the License at
13
+ #
14
+ # http://www.apache.org/licenses/LICENSE-2.0
15
+ #
16
+ # Unless required by applicable law or agreed to in writing, software
17
+ # distributed under the License is distributed on an "AS IS" BASIS,
18
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
19
+ # See the License for the specific language governing permissions and
20
+ # limitations under the License.
21
+ """Image processor class for PenguinVL."""
22
+
23
+ import math
24
+ from typing import Dict, List, Optional, Union
25
+
26
+ import numpy as np
27
+
28
+ import torch
29
+ from transformers.image_processing_utils import BaseImageProcessor, BatchFeature
30
+ from transformers.image_utils import ImageInput
31
+ from transformers.image_transforms import (
32
+ convert_to_rgb,
33
+ resize,
34
+ to_channel_dimension_format,
35
+ )
36
+ from transformers.image_utils import (
37
+ OPENAI_CLIP_MEAN,
38
+ OPENAI_CLIP_STD,
39
+ ChannelDimension,
40
+ ImageInput,
41
+ PILImageResampling,
42
+ get_image_size,
43
+ infer_channel_dimension_format,
44
+ is_scaled_image,
45
+ is_valid_image,
46
+ make_list_of_images,
47
+ to_numpy_array,
48
+ )
49
+ try:
50
+ from transformers.image_utils import VideoInput
51
+ except:
52
+ from transformers.video_utils import VideoInput
53
+ from transformers.utils import TensorType, is_vision_available, logging
54
+
55
+
56
+ logger = logging.get_logger(__name__)
57
+
58
+
59
+ if is_vision_available():
60
+ from PIL import Image
61
+
62
+
63
+ def is_valid_video(video) -> bool:
64
+ if isinstance(video, (list, tuple)):
65
+ return all(is_valid_image(frame) for frame in video)
66
+ elif isinstance(video, np.ndarray):
67
+ return video.ndim == 4
68
+ elif isinstance(video, torch.Tensor):
69
+ return video.ndim == 4
70
+ return False
71
+
72
+
73
+ def make_batched_images(images) -> List[List[ImageInput]]:
74
+ """
75
+ Normalize visual inputs to ``List[List[ImageInput]]`` – a list of *clips*,
76
+ where each clip is a list of frames.
77
+
78
+ Supported input formats::
79
+
80
+ Nested clips : [[image], [f1, f2, ...], ...] → returned as-is
81
+ Flat frames : [f1, f2, ...] → [[f1, f2, ...]]
82
+ Single image : image → [[image]]
83
+
84
+ Returns:
85
+ List of clips, where each clip is a list of valid images / frames.
86
+ """
87
+ if isinstance(images, (list, tuple)) and len(images) > 0:
88
+ if isinstance(images[0], (list, tuple)):
89
+ return [list(clip) for clip in images]
90
+ if all(is_valid_image(f) for f in images):
91
+ return [list(images)]
92
+ if is_valid_image(images):
93
+ return [[images]]
94
+ raise ValueError(f"Could not make batched images from {images}")
95
+
96
+
97
+ def simple_batched_resize(
98
+ images,
99
+ factor: int = 28,
100
+ min_tokens: int = 4 * 4,
101
+ max_tokens: int = 16384,
102
+ input_data_format: str = None,
103
+ frame_types=None
104
+ ):
105
+ """
106
+ Compute per-frame target (h, w) for a video frame list under a token budget (key/intermediate may differ).
107
+
108
+ Uses the Temporal Redundancy-Aware (TRA) token compression strategy: key and intermediate frames
109
+ can have different target areas (e.g. 1:16 ratio when compressing) to stay within max_tokens.
110
+
111
+ Args:
112
+ images: List of video frames (each PIL Image or ndarray).
113
+ factor: Alignment granularity (height and width are multiples of factor), default 28.
114
+ min_tokens: Minimum tokens per frame (used to derive min_pixels), default 16.
115
+ max_tokens: Token cap for total pixel budget, default 16384.
116
+ input_data_format: Channel format when not PIL, e.g. "channels_first".
117
+ frame_types: Per-frame type list, 0=key, 1=intermediate; None means all key.
118
+
119
+ Returns:
120
+ image_sizes: List of (h, w) per frame, one-to-one with images.
121
+ """
122
+ min_pixels = min_tokens * factor * factor * 1.5
123
+ max_pixels = max_tokens * factor * factor * 0.95
124
+
125
+ # --- Base info ---
126
+ first_image = images[0]
127
+ if isinstance(first_image, Image.Image):
128
+ width, height = first_image.size
129
+ else:
130
+ height, width = get_image_size(first_image, channel_dim=input_data_format)
131
+
132
+ aspect_ratio = height / width
133
+ raw_area = height * width
134
+
135
+ num_frames = len(images)
136
+ if frame_types is not None:
137
+ ft_list = frame_types.tolist() if hasattr(frame_types, 'tolist') else frame_types
138
+ num_intermediate = ft_list.count(1)
139
+ num_key = ft_list.count(0)
140
+ else:
141
+ num_key = num_frames
142
+ num_intermediate = 0
143
+ ft_list = [0] * num_frames
144
+
145
+ def get_dims_from_area(target_area, ar, fac):
146
+ """Compute aligned (h, w) from target area and aspect ratio; area = w²·ar => w = sqrt(area/ar)."""
147
+ w_new = math.sqrt(target_area / ar)
148
+ h_new = w_new * ar
149
+
150
+ h_bar = round(h_new / fac) * fac
151
+ w_bar = round(w_new / fac) * fac
152
+ h_bar = max(h_bar, fac)
153
+ w_bar = max(w_bar, fac)
154
+
155
+ return h_bar, w_bar
156
+
157
+ # --- Stage 1: No-downscale check ---
158
+ # If total pixels within budget, keep original size for both key and intermediate frames.
159
+ total_raw_pixels = num_frames * raw_area
160
+ target_key_area = raw_area
161
+ target_intermediate_area = raw_area
162
+
163
+ if total_raw_pixels > max_pixels:
164
+ # --- Stage 2: Sync compression ---
165
+ # Over budget: compress with 1:16 area ratio, intermediate_area = key_area / 16.
166
+ # Constraint: N_key·A_key + N_intermediate·(A_key/16) = max_pixels => A_key = max_pixels / (N_key + N_intermediate/16).
167
+ effective_count = num_key + (num_intermediate / 16.0)
168
+ calc_key_area = max_pixels / effective_count
169
+ calc_intermediate_area = calc_key_area / 16.0
170
+
171
+ # --- Stage 3: Intermediate-frame floor ---
172
+ # If computed intermediate area is below min_pixels, pin intermediate to min_pixels and give remaining budget to key.
173
+ if calc_intermediate_area >= min_pixels:
174
+ target_key_area = calc_key_area
175
+ target_intermediate_area = calc_intermediate_area
176
+ else:
177
+ target_intermediate_area = min_pixels
178
+ pixels_taken_by_intermediate = num_intermediate * min_pixels
179
+ remaining_for_key = max_pixels - pixels_taken_by_intermediate
180
+ target_key_area = remaining_for_key / num_key
181
+
182
+ # --- Stage 4: Key-frame hard floor ---
183
+ if target_key_area < min_pixels:
184
+ target_key_area = min_pixels
185
+
186
+ # --- Area to aligned dimensions ---
187
+ k_h, k_w = get_dims_from_area(target_key_area, aspect_ratio, factor)
188
+ if num_intermediate > 0:
189
+ i_h, i_w = get_dims_from_area(target_intermediate_area, aspect_ratio, factor)
190
+ else:
191
+ i_h, i_w = 0, 0
192
+
193
+ def ensure_min_hw(h, w, min_p, raw_ar):
194
+ """If area still below min_pixels after alignment (rounding), recompute from min area and align upward."""
195
+ if h * w < min_p:
196
+ w = math.sqrt(min_p / raw_ar)
197
+ h = w * raw_ar
198
+ h = math.ceil(h / factor) * factor
199
+ w = math.ceil(w / factor) * factor
200
+ return h, w
201
+
202
+ k_h, k_w = ensure_min_hw(k_h, k_w, min_pixels, aspect_ratio)
203
+ if num_intermediate > 0:
204
+ i_h, i_w = ensure_min_hw(i_h, i_w, min_pixels, aspect_ratio)
205
+
206
+ image_sizes = [
207
+ (i_h, i_w) if ft_list[i] == 1 else (k_h, k_w)
208
+ for i in range(num_frames)
209
+ ]
210
+ return image_sizes
211
+
212
+
213
+ class PenguinVLImageProcessor(BaseImageProcessor):
214
+ r"""
215
+ Constructs a PenguinVL image processor that dynamically resizes images based on the original images.
216
+
217
+ Args:
218
+ do_resize (`bool`, *optional*, defaults to `True`):
219
+ Whether to resize the image's (height, width) dimensions.
220
+ resample (`PILImageResampling`, *optional*, defaults to `Resampling.BICUBIC`):
221
+ Resampling filter to use when resizing the image.
222
+ do_rescale (`bool`, *optional*, defaults to `True`):
223
+ Whether to rescale the image by the specified scale `rescale_factor`.
224
+ rescale_factor (`int` or `float`, *optional*, defaults to `1/255`):
225
+ Scale factor to use if rescaling the image.
226
+ do_normalize (`bool`, *optional*, defaults to `True`):
227
+ Whether to normalize the image.
228
+ image_mean (`float` or `List[float]`, *optional*, defaults to `[0.48145466, 0.4578275, 0.40821073]`):
229
+ Mean to use if normalizing the image. This is a float or list of floats for each channel in the image.
230
+ image_std (`float` or `List[float]`, *optional*, defaults to `[0.26862954, 0.26130258, 0.27577711]`):
231
+ Standard deviation to use if normalizing the image. This is a float or list of floats for each channel in the image.
232
+ do_convert_rgb (`bool`, *optional*, defaults to `True`):
233
+ Whether to convert the image to RGB.
234
+ min_pixels (`int`, *optional*, defaults to `56 * 56`):
235
+ The min pixels of the image to resize the image.
236
+ max_pixels (`int`, *optional*, defaults to `28 * 28 * 1280`):
237
+ The max pixels of the image to resize the image.
238
+ patch_size (`int`, *optional*, defaults to 14):
239
+ The spacial patch size of the vision encoder.
240
+ """
241
+
242
+ model_input_names = ["pixel_values", "grid_sizes", "merge_sizes"]
243
+
244
+ def __init__(
245
+ self,
246
+ do_resize: bool = True,
247
+ resample: PILImageResampling = PILImageResampling.BICUBIC,
248
+ do_rescale: bool = True,
249
+ rescale_factor: Union[int, float] = 1 / 255,
250
+ do_normalize: bool = True,
251
+ image_mean: Optional[Union[float, List[float]]] = None,
252
+ image_std: Optional[Union[float, List[float]]] = None,
253
+ do_convert_rgb: bool = True,
254
+ min_tokens: int = 4 * 4,
255
+ max_tokens: int = 16384,
256
+ patch_size: int = 14,
257
+ **kwargs,
258
+ ) -> None:
259
+ super().__init__(**kwargs)
260
+ self.do_resize = do_resize
261
+ self.resample = resample
262
+ self.do_rescale = do_rescale
263
+ self.rescale_factor = rescale_factor
264
+ self.do_normalize = do_normalize
265
+ self.image_mean = image_mean if image_mean is not None else OPENAI_CLIP_MEAN
266
+ self.image_std = image_std if image_std is not None else OPENAI_CLIP_STD
267
+ self.min_tokens = min_tokens
268
+ self.max_tokens = max_tokens
269
+ self.patch_size = patch_size
270
+ self.do_convert_rgb = do_convert_rgb
271
+
272
+ def _allocate_token_budget(self, clips, clip_merge_sizes, input_data_format):
273
+ """Distribute self.max_tokens across clips proportionally to their raw token counts."""
274
+ clip_raw_tokens = []
275
+ for clip, ms in zip(clips, clip_merge_sizes):
276
+ first_frame = clip[0]
277
+ if isinstance(first_frame, Image.Image):
278
+ w, h = first_frame.size
279
+ else:
280
+ h, w = get_image_size(first_frame, channel_dim=input_data_format)
281
+ factor = self.patch_size * ms
282
+ clip_raw_tokens.append(len(clip) * h * w / (factor * factor))
283
+
284
+ total_raw_tokens = sum(clip_raw_tokens)
285
+ if total_raw_tokens <= self.max_tokens:
286
+ return [self.max_tokens] * len(clips)
287
+
288
+ return [
289
+ max(self.min_tokens * len(clip), raw * self.max_tokens / total_raw_tokens)
290
+ for clip, raw in zip(clips, clip_raw_tokens)
291
+ ]
292
+
293
+ def _preprocess(
294
+ self,
295
+ images: Union[ImageInput, VideoInput],
296
+ target_size: List[int],
297
+ merge_size: int = 1,
298
+ do_resize: bool = None,
299
+ resample: PILImageResampling = None,
300
+ do_rescale: bool = None,
301
+ rescale_factor: float = None,
302
+ do_normalize: bool = None,
303
+ image_mean: Optional[Union[float, List[float]]] = None,
304
+ image_std: Optional[Union[float, List[float]]] = None,
305
+ do_convert_rgb: bool = None,
306
+ data_format: Optional[ChannelDimension] = ChannelDimension.FIRST,
307
+ input_data_format: Optional[Union[str, ChannelDimension]] = None,
308
+ ):
309
+ """
310
+ Preprocess an image or batch of images. Copy of the `preprocess` method from `CLIPImageProcessor`.
311
+
312
+ Args:
313
+ images (`ImageInput`):
314
+ Image or batch of images to preprocess. Expects pixel values ranging from 0 to 255. If pixel values range from 0 to 1, set `do_rescale=False`.
315
+ target_size (`List[int]`):
316
+ The target size to resize the image to. Should be a list of two integers: [target_height, target_width].
317
+ merge_size (`int`, *optional*, defaults to `1`):
318
+ The merge size after the vision encoder.
319
+ do_resize (`bool`, *optional*, defaults to `self.do_resize`):
320
+ Whether to resize the image.
321
+ resample (`PILImageResampling`, *optional*, defaults to `self.resample`):
322
+ Resampling filter to use if resizing the image. This can be one of the `PILImageResampling` enums.
323
+ do_rescale (`bool`, *optional*, defaults to `self.do_rescale`):
324
+ Whether to rescale the image.
325
+ rescale_factor (`float`, *optional*, defaults to `self.rescale_factor`):
326
+ Scale factor to use if rescaling the image.
327
+ do_normalize (`bool`, *optional*, defaults to `self.do_normalize`):
328
+ Whether to normalize the image.
329
+ image_mean (`float` or `List[float]`, *optional*, defaults to `self.image_mean`):
330
+ Mean to use if normalizing the image. Can be a float or a list of floats corresponding to the number of channels in the image.
331
+ image_std (`float` or `List[float]`, *optional*, defaults to `self.image_std`):
332
+ Standard deviation to use if normalizing the image. Can be a float or a list of floats corresponding to the number of channels in the image.
333
+ do_convert_rgb (`bool`, *optional*, defaults to `self.do_convert_rgb`):
334
+ Whether to convert the image to RGB.
335
+ data_format (`ChannelDimension`, *optional*, defaults to `ChannelDimension.FIRST`):
336
+ The channel dimension format for the output image. Can be one of:
337
+ - `"channels_first"` or `ChannelDimension.FIRST`: image in (num_channels, height, width) format.
338
+ - `"channels_last"` or `ChannelDimension.LAST`: image in (height, width, num_channels) format.
339
+ - Unset: Use the channel dimension format of the input image.
340
+ input_data_format (`ChannelDimension` or `str`, *optional*):
341
+ The channel dimension format for the input image. Can be one of:
342
+ - `"channels_first"` or `ChannelDimension.FIRST`: image in (num_channels, height, width) format.
343
+ - `"channels_last"` or `ChannelDimension.LAST`: image in (height, width, num_channels) format.
344
+ - `"none"` or `ChannelDimension.NONE`: image in (height, width) format. - `"none"` or `ChannelDimension.NONE`: image in (height, width) format.
345
+ """
346
+ images = make_list_of_images(images)
347
+
348
+ if do_convert_rgb:
349
+ images = [convert_to_rgb(image) for image in images]
350
+
351
+ # All transformations expect numpy arrays.
352
+ images = [to_numpy_array(image) for image in images]
353
+
354
+ if is_scaled_image(images[0]) and do_rescale:
355
+ logger.warning_once(
356
+ "It looks like you are trying to rescale already rescaled images. If the input"
357
+ " images have pixel values between 0 and 1, set `do_rescale=False` to avoid rescaling them again."
358
+ )
359
+ if input_data_format is None:
360
+ # We assume that all images have the same channel dimension format.
361
+ input_data_format = infer_channel_dimension_format(images[0])
362
+
363
+ height, width = get_image_size(images[0], channel_dim=input_data_format)
364
+ resized_height, resized_width = height, width
365
+ processed_images = []
366
+ for image in images:
367
+ if do_resize:
368
+ resized_height, resized_width = target_size
369
+ image = resize(
370
+ image, size=(resized_height, resized_width), resample=resample, input_data_format=input_data_format
371
+ )
372
+
373
+ if do_rescale:
374
+ image = self.rescale(image, scale=rescale_factor, input_data_format=input_data_format)
375
+
376
+ if do_normalize:
377
+ image = self.normalize(
378
+ image=image, mean=image_mean, std=image_std, input_data_format=input_data_format
379
+ )
380
+
381
+ image = to_channel_dimension_format(image, data_format, input_channel_dim=input_data_format)
382
+ processed_images.append(image)
383
+
384
+ patches = np.array(processed_images)
385
+ if data_format == ChannelDimension.LAST:
386
+ patches = patches.transpose(0, 3, 1, 2)
387
+ t = patches.shape[0]
388
+ channel = patches.shape[1]
389
+ grid_h, grid_w = resized_height // self.patch_size, resized_width // self.patch_size
390
+ patches = patches.reshape(
391
+ t,
392
+ channel,
393
+ grid_h // merge_size,
394
+ merge_size,
395
+ self.patch_size,
396
+ grid_w // merge_size,
397
+ merge_size,
398
+ self.patch_size,
399
+ )
400
+ patches = patches.transpose(0, 2, 5, 3, 6, 1, 4, 7)
401
+ flatten_patches = patches.reshape(
402
+ t * grid_h * grid_w, channel * self.patch_size * self.patch_size
403
+ )
404
+
405
+ return flatten_patches, (t, grid_h, grid_w)
406
+
407
+ def preprocess(
408
+ self,
409
+ images: ImageInput,
410
+ do_resize: bool = None,
411
+ resample: PILImageResampling = None,
412
+ do_rescale: bool = None,
413
+ rescale_factor: float = None,
414
+ do_normalize: bool = None,
415
+ image_mean: Optional[Union[float, List[float]]] = None,
416
+ image_std: Optional[Union[float, List[float]]] = None,
417
+ do_convert_rgb: bool = None,
418
+ merge_size: Optional[Union[int, List[int]]] = None,
419
+ frame_types: Optional[Union[int, List[int]]] = None,
420
+ return_tensors: Optional[Union[str, TensorType]] = None,
421
+ data_format: Optional[ChannelDimension] = ChannelDimension.FIRST,
422
+ input_data_format: Optional[Union[str, ChannelDimension]] = None,
423
+ ):
424
+ """
425
+ Args:
426
+ images (`ImageInput`):
427
+ Image to preprocess. Expects a single or batch of images with pixel values ranging from 0 to 255. If
428
+ passing in images with pixel values between 0 and 1, set `do_rescale=False`.
429
+ do_resize (`bool`, *optional*, defaults to `self.do_resize`):
430
+ Whether to resize the image.
431
+ resample (`int`, *optional*, defaults to `self.resample`):
432
+ Resampling filter to use if resizing the image. This can be one of the enum `PILImageResampling`. Only
433
+ has an effect if `do_resize` is set to `True`.
434
+ do_rescale (`bool`, *optional*, defaults to `self.do_rescale`):
435
+ Whether to rescale the image.
436
+ rescale_factor (`float`, *optional*, defaults to `self.rescale_factor`):
437
+ Rescale factor to rescale the image by if `do_rescale` is set to `True`.
438
+ do_normalize (`bool`, *optional*, defaults to `self.do_normalize`):
439
+ Whether to normalize the image.
440
+ image_mean (`float` or `List[float]`, *optional*, defaults to `self.image_mean`):
441
+ Image mean to use for normalization. Only has an effect if `do_normalize` is set to `True`.
442
+ image_std (`float` or `List[float]`, *optional*, defaults to `self.image_std`):
443
+ Image standard deviation to use for normalization. Only has an effect if `do_normalize` is set to
444
+ `True`.
445
+ do_convert_rgb (`bool`, *optional*, defaults to `self.do_convert_rgb`):
446
+ Whether to convert the image to RGB.
447
+ return_tensors (`str` or `TensorType`, *optional*):
448
+ The type of tensors to return. Can be one of:
449
+ - Unset: Return a list of `np.ndarray`.
450
+ - `TensorType.TENSORFLOW` or `'tf'`: Return a batch of type `tf.Tensor`.
451
+ - `TensorType.PYTORCH` or `'pt'`: Return a batch of type `torch.Tensor`.
452
+ - `TensorType.NUMPY` or `'np'`: Return a batch of type `np.ndarray`.
453
+ - `TensorType.JAX` or `'jax'`: Return a batch of type `jax.numpy.ndarray`.
454
+ data_format (`ChannelDimension` or `str`, *optional*, defaults to `ChannelDimension.FIRST`):
455
+ The channel dimension format for the output image. Can be one of:
456
+ - `"channels_first"` or `ChannelDimension.FIRST`: image in (num_channels, height, width) format.
457
+ - `"channels_last"` or `ChannelDimension.LAST`: image in (height, width, num_channels) format.
458
+ - Unset: Use the channel dimension format of the input image.
459
+ input_data_format (`ChannelDimension` or `str`, *optional*):
460
+ The channel dimension format for the input image. If unset, the channel dimension format is inferred
461
+ from the input image. Can be one of:
462
+ - `"channels_first"` or `ChannelDimension.FIRST`: image in (num_channels, height, width) format.
463
+ - `"channels_last"` or `ChannelDimension.LAST`: image in (height, width, num_channels) format.
464
+ - `"none"` or `ChannelDimension.NONE`: image in (height, width) format.
465
+
466
+ """
467
+ do_resize = do_resize if do_resize is not None else self.do_resize
468
+ resample = resample if resample is not None else self.resample
469
+ do_rescale = do_rescale if do_rescale is not None else self.do_rescale
470
+ rescale_factor = rescale_factor if rescale_factor is not None else self.rescale_factor
471
+ do_normalize = do_normalize if do_normalize is not None else self.do_normalize
472
+ image_mean = image_mean if image_mean is not None else self.image_mean
473
+ image_std = image_std if image_std is not None else self.image_std
474
+ merge_size = merge_size if merge_size is not None else self.merge_size
475
+ do_convert_rgb = do_convert_rgb if do_convert_rgb is not None else self.do_convert_rgb
476
+
477
+ clips = make_batched_images(images)
478
+ num_clips = len(clips)
479
+
480
+ if isinstance(merge_size, (list, tuple)):
481
+ assert len(merge_size) == num_clips, (
482
+ f"merge_size length ({len(merge_size)}) must match number of clips ({num_clips})"
483
+ )
484
+ clip_merge_sizes = list(merge_size)
485
+ else:
486
+ clip_merge_sizes = [merge_size] * num_clips
487
+
488
+ if frame_types is None:
489
+ clip_frame_types = [None] * num_clips
490
+ elif isinstance(frame_types, (list, tuple)) and len(frame_types) > 0:
491
+ if isinstance(frame_types[0], (list, tuple)) or frame_types[0] is None:
492
+ assert len(frame_types) == num_clips, (
493
+ f"frame_types length ({len(frame_types)}) must match number of clips ({num_clips})"
494
+ )
495
+ clip_frame_types = list(frame_types)
496
+ else:
497
+ assert num_clips == 1, "Flat frame_types is only supported for a single clip"
498
+ clip_frame_types = [frame_types]
499
+ else:
500
+ clip_frame_types = [None] * num_clips
501
+
502
+ pixel_values, grid_sizes, per_frame_merge_sizes = [], [], []
503
+
504
+ clip_max_tokens_list = self._allocate_token_budget(
505
+ clips, clip_merge_sizes, input_data_format,
506
+ )
507
+
508
+ for clip, ms, ft, clip_max_tokens in zip(clips, clip_merge_sizes, clip_frame_types, clip_max_tokens_list):
509
+ target_sizes = simple_batched_resize(
510
+ clip,
511
+ factor=self.patch_size * ms,
512
+ min_tokens=self.min_tokens,
513
+ max_tokens=clip_max_tokens,
514
+ input_data_format=input_data_format,
515
+ frame_types=ft,
516
+ )
517
+
518
+ for frame, target_size in zip(clip, target_sizes):
519
+ patches, grid_size = self._preprocess(
520
+ frame,
521
+ target_size=target_size,
522
+ merge_size=ms,
523
+ do_resize=do_resize,
524
+ resample=resample,
525
+ do_rescale=do_rescale,
526
+ rescale_factor=rescale_factor,
527
+ do_normalize=do_normalize,
528
+ image_mean=image_mean,
529
+ image_std=image_std,
530
+ data_format=data_format,
531
+ do_convert_rgb=do_convert_rgb,
532
+ input_data_format=input_data_format,
533
+ )
534
+ pixel_values.append(patches)
535
+ grid_sizes.append(grid_size)
536
+ per_frame_merge_sizes.append(ms)
537
+
538
+ pixel_values = np.concatenate(pixel_values, axis=0)
539
+ grid_sizes = np.array(grid_sizes)
540
+ merge_sizes = np.array(per_frame_merge_sizes)
541
+
542
+ data = {
543
+ "pixel_values": pixel_values,
544
+ "grid_sizes": grid_sizes,
545
+ "merge_sizes": merge_sizes,
546
+ }
547
+
548
+ return BatchFeature(data=data, tensor_type=return_tensors)
penguin_encoder/modeling_penguinvl_encoder.py ADDED
@@ -0,0 +1,522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from torch import nn
2
+ import torch
3
+ import math
4
+ import warnings
5
+ from .configuration_penguinvl_encoder import PenguinVLVisionEncoderConfig
6
+ from transformers.modeling_utils import PreTrainedModel
7
+ from transformers.models.qwen3.modeling_qwen3 import Qwen3Model, Qwen3Attention, rotate_half, Qwen3DecoderLayer
8
+ from typing import List, Optional, Tuple, Union
9
+ from transformers.modeling_outputs import BaseModelOutputWithPast
10
+ from transformers.processing_utils import Unpack
11
+ from transformers.modeling_flash_attention_utils import FlashAttentionKwargs
12
+ from transformers.cache_utils import Cache, DynamicCache
13
+ from transformers.utils import logging, is_flash_attn_greater_or_equal_2_10, is_flash_attn_2_available
14
+ from transformers.modeling_rope_utils import ROPE_INIT_FUNCTIONS
15
+ from torch.nn.init import _calculate_fan_in_and_fan_out
16
+ import torch.nn.functional as F
17
+ if is_flash_attn_2_available():
18
+ from transformers.modeling_flash_attention_utils import _flash_attention_forward
19
+ from flash_attn import flash_attn_varlen_func
20
+
21
+ logger = logging.get_logger(__name__)
22
+
23
+ class PenguinVLVisionEncoderEmbeddings(nn.Module):
24
+
25
+ def __init__(self, config: PenguinVLVisionEncoderConfig):
26
+ super().__init__()
27
+ self.config = config
28
+ self.embed_dim = config.hidden_size
29
+ self.patch_size = config.patch_size
30
+
31
+ self.patch_embedding = nn.Conv2d(
32
+ in_channels=config.num_channels,
33
+ out_channels=self.embed_dim,
34
+ kernel_size=self.patch_size,
35
+ stride=self.patch_size,
36
+ padding="valid",
37
+ )
38
+
39
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
40
+ hidden_states = hidden_states.view(
41
+ -1, self.config.num_channels, self.patch_size, self.patch_size
42
+ )
43
+ patch_embeds = self.patch_embedding(hidden_states)
44
+ embeddings = patch_embeds.view(-1, self.embed_dim)
45
+
46
+ return embeddings
47
+
48
+
49
+ # Adapted from Qwen2VLRotaryEmbedding in transformers/models/qwen2/modeling_qwen2.py
50
+ class VisualRotaryEmbedding(nn.Module):
51
+ def __init__(
52
+ self,
53
+ dim=None,
54
+ max_position_embeddings=2048,
55
+ base=10000,
56
+ device=None,
57
+ scaling_factor=1.0,
58
+ rope_type="default",
59
+ config = None,
60
+ ):
61
+ super().__init__()
62
+ # TODO (joao): remove the `if` below, only used for BC
63
+ self.rope_kwargs = {}
64
+ if config is None:
65
+ logger.warning_once(
66
+ "`Qwen2VLRotaryEmbedding` can now be fully parameterized by passing the model config through the "
67
+ "`config` argument. All other arguments will be removed in v4.46"
68
+ )
69
+ self.rope_kwargs = {
70
+ "rope_type": rope_type,
71
+ "factor": scaling_factor,
72
+ "dim": dim,
73
+ "base": base,
74
+ "max_position_embeddings": max_position_embeddings,
75
+ }
76
+ self.rope_type = rope_type
77
+ self.max_seq_len_cached = max_position_embeddings
78
+ self.original_max_seq_len = max_position_embeddings
79
+ else:
80
+ # BC: "rope_type" was originally "type"
81
+ if config.rope_scaling is not None:
82
+ self.rope_type = config.rope_scaling.get("rope_type", config.rope_scaling.get("type"))
83
+ else:
84
+ self.rope_type = "default"
85
+ self.max_seq_len_cached = config.max_position_embeddings
86
+ self.original_max_seq_len = config.max_position_embeddings
87
+
88
+ self.config = config
89
+ self.rope_init_fn = ROPE_INIT_FUNCTIONS[self.rope_type]
90
+
91
+ inv_freq, self.attention_scaling = self.rope_init_fn(self.config, device, **self.rope_kwargs)
92
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
93
+ self.original_inv_freq = self.inv_freq
94
+
95
+ def _dynamic_frequency_update(self, position_ids, device):
96
+ """
97
+ dynamic RoPE layers should recompute `inv_freq` in the following situations:
98
+ 1 - growing beyond the cached sequence length (allow scaling)
99
+ 2 - the current sequence length is in the original scale (avoid losing precision with small sequences)
100
+ """
101
+ seq_len = torch.max(position_ids) + 1
102
+ if seq_len > self.max_seq_len_cached: # growth
103
+ inv_freq, self.attention_scaling = self.rope_init_fn(
104
+ self.config, device, seq_len=seq_len, **self.rope_kwargs
105
+ )
106
+ self.register_buffer("inv_freq", inv_freq, persistent=False) # TODO joao: may break with compilation
107
+ self.max_seq_len_cached = seq_len
108
+
109
+ if seq_len < self.original_max_seq_len and self.max_seq_len_cached > self.original_max_seq_len: # reset
110
+ self.register_buffer("inv_freq", self.original_inv_freq, persistent=False)
111
+ self.max_seq_len_cached = self.original_max_seq_len
112
+
113
+ @torch.no_grad()
114
+ def forward(self, x, position_ids):
115
+ if "dynamic" in self.rope_type:
116
+ self._dynamic_frequency_update(position_ids, device=x.device)
117
+
118
+ inv_freq_expanded = self.inv_freq[None, None, :, None].float().expand(2, position_ids.shape[1], -1, 1)
119
+ position_ids_expanded = position_ids[:, :, None, :].float() # shape (2, bs, 1, positions)
120
+ # Force float32 (see https://github.com/huggingface/transformers/pull/29285)
121
+ device_type = x.device.type
122
+ device_type = device_type if isinstance(device_type, str) and device_type != "mps" else "cpu"
123
+ with torch.autocast(device_type=device_type, enabled=False):
124
+ freqs = (inv_freq_expanded.float() @ position_ids_expanded.float()).transpose(2, 3)
125
+ emb = torch.cat((freqs, freqs), dim=-1)
126
+ cos = emb.cos()
127
+ sin = emb.sin()
128
+
129
+ # Advanced RoPE types (e.g. yarn) apply a post-processing scaling factor, equivalent to scaling attention
130
+ cos = cos * self.attention_scaling
131
+ sin = sin * self.attention_scaling
132
+
133
+ return cos.to(dtype=x.dtype), sin.to(dtype=x.dtype)
134
+
135
+
136
+ def apply_multimodal_rotary_pos_emb(q, k, cos, sin, unsqueeze_dim=1):
137
+ rope_section = [cos.shape[-1] // 2, cos.shape[-1] // 2]
138
+ cos = torch.cat([m[i % 2] for i, m in enumerate(cos.split(rope_section, dim=-1))], dim=-1).unsqueeze(unsqueeze_dim)
139
+ sin = torch.cat([m[i % 2] for i, m in enumerate(sin.split(rope_section, dim=-1))], dim=-1).unsqueeze(unsqueeze_dim)
140
+
141
+ q_embed = (q * cos) + (rotate_half(q) * sin)
142
+ k_embed = (k * cos) + (rotate_half(k) * sin)
143
+ return q_embed, k_embed
144
+
145
+
146
+ class PenguinVLAttention(Qwen3Attention):
147
+ """Multi-headed attention from 'Attention Is All You Need' paper"""
148
+
149
+ # Copied from transformers.models.llama.modeling_llama.LlamaFlashAttention2.__init__
150
+ def __init__(self, *args, **kwargs):
151
+ super().__init__(*args, **kwargs)
152
+ self.is_causal = False
153
+
154
+ def forward(
155
+ self,
156
+ hidden_states: torch.Tensor,
157
+ position_embeddings: Tuple[torch.Tensor, torch.Tensor],
158
+ attention_mask: Optional[torch.Tensor],
159
+ past_key_value: Optional[Cache] = None,
160
+ cache_position: Optional[torch.LongTensor] = None,
161
+ cu_seqlens: Optional[torch.Tensor] = None,
162
+ **kwargs: Unpack[FlashAttentionKwargs],
163
+ ) -> Tuple[torch.Tensor, Optional[torch.Tensor], Optional[Tuple[torch.Tensor]]]:
164
+ input_shape = hidden_states.shape[:-1]
165
+ hidden_shape = (*input_shape, -1, self.head_dim)
166
+
167
+ query_states = self.q_norm(self.q_proj(hidden_states).view(hidden_shape)).transpose(1, 2)
168
+ key_states = self.k_norm(self.k_proj(hidden_states).view(hidden_shape)).transpose(1, 2)
169
+ value_states = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
170
+
171
+ cos, sin = position_embeddings
172
+ query_states, key_states = apply_multimodal_rotary_pos_emb(query_states, key_states, cos, sin)
173
+
174
+ if past_key_value is not None:
175
+ # sin and cos are specific to RoPE models; cache_position needed for the static cache
176
+ cache_kwargs = {"sin": sin, "cos": cos, "cache_position": cache_position}
177
+ key_states, value_states = past_key_value.update(key_states, value_states, self.layer_idx, cache_kwargs)
178
+
179
+ # This is before the transpose
180
+ seq_len = query_states.shape[2]
181
+
182
+ # Restore the attention dtype if normalization promoted hidden states to FP32.
183
+ target_dtype = None
184
+ if query_states.dtype == torch.float32:
185
+ if torch.is_autocast_enabled():
186
+ target_dtype = torch.get_autocast_gpu_dtype()
187
+ # Handle the case where the model is quantized
188
+ elif hasattr(self.config, "_pre_quantization_dtype"):
189
+ target_dtype = self.config._pre_quantization_dtype
190
+ else:
191
+ target_dtype = next(layer for layer in self.modules() if isinstance(layer, torch.nn.Linear)).weight.dtype
192
+
193
+ # FA2 always relies on the value set in the module, so remove it if present in kwargs to avoid passing it twice
194
+ kwargs.pop("is_causal", None)
195
+
196
+ # Reashape to the expected shape for Flash Attention
197
+ query_states = query_states.transpose(1, 2).squeeze(0)
198
+ key_states = key_states.transpose(1, 2).squeeze(0)
199
+ value_states = value_states.transpose(1, 2).squeeze(0)
200
+
201
+ max_seqlen = (cu_seqlens[1:] - cu_seqlens[:-1]).max().item()
202
+ attn_output = flash_attn_varlen_func(
203
+ query_states,
204
+ key_states,
205
+ value_states,
206
+ cu_seqlens_q=cu_seqlens,
207
+ cu_seqlens_k=cu_seqlens,
208
+ max_seqlen_q=max_seqlen,
209
+ max_seqlen_k=max_seqlen,
210
+ dropout_p=0.0 if not self.training else self.attention_dropout,
211
+ causal=self.is_causal
212
+ )
213
+
214
+ attn_output = attn_output.reshape(*input_shape, -1).contiguous()
215
+ attn_output = self.o_proj(attn_output)
216
+ return attn_output, None
217
+
218
+
219
+ class PenguinVLDecoderLayer(Qwen3DecoderLayer):
220
+ def __init__(self, config: PenguinVLVisionEncoderConfig, layer_idx: int):
221
+ super(PenguinVLDecoderLayer, self).__init__(config, layer_idx)
222
+ self.self_attn = PenguinVLAttention(config, layer_idx)
223
+
224
+ def forward(
225
+ self,
226
+ hidden_states: torch.Tensor,
227
+ attention_mask: Optional[torch.Tensor] = None,
228
+ position_ids: Optional[torch.LongTensor] = None,
229
+ past_key_value: Optional[Cache] = None,
230
+ output_attentions: Optional[bool] = False,
231
+ use_cache: Optional[bool] = False,
232
+ cache_position: Optional[torch.LongTensor] = None,
233
+ position_embeddings: Optional[Tuple[torch.Tensor, torch.Tensor]] = None, # necessary, but kept here for BC
234
+ cu_seqlens: Optional[torch.Tensor] = None,
235
+ **kwargs: Unpack[FlashAttentionKwargs],
236
+ ) -> Tuple[torch.FloatTensor, Optional[Tuple[torch.FloatTensor, torch.FloatTensor]]]:
237
+ residual = hidden_states
238
+
239
+ hidden_states = self.input_layernorm(hidden_states)
240
+
241
+ # Self Attention
242
+ hidden_states, self_attn_weights = self.self_attn(
243
+ hidden_states=hidden_states,
244
+ attention_mask=attention_mask,
245
+ position_ids=position_ids,
246
+ past_key_value=past_key_value,
247
+ output_attentions=output_attentions,
248
+ use_cache=use_cache,
249
+ cache_position=cache_position,
250
+ position_embeddings=position_embeddings,
251
+ cu_seqlens=cu_seqlens,
252
+ **kwargs,
253
+ )
254
+ hidden_states = residual + hidden_states
255
+
256
+ # Fully Connected
257
+ residual = hidden_states
258
+ hidden_states = self.post_attention_layernorm(hidden_states)
259
+ hidden_states = self.mlp(hidden_states)
260
+ hidden_states = residual + hidden_states
261
+
262
+ outputs = (hidden_states,)
263
+ if output_attentions:
264
+ outputs += (self_attn_weights,)
265
+
266
+ return outputs
267
+
268
+
269
+ class PenguinVLVisionEncoderFromQwen3Model(Qwen3Model):
270
+ def __init__(self, config: PenguinVLVisionEncoderConfig):
271
+ super().__init__(config)
272
+ self.layers = nn.ModuleList(
273
+ [PenguinVLDecoderLayer(config, layer_idx) for layer_idx in range(config.num_hidden_layers)]
274
+ )
275
+ self.rotary_emb = VisualRotaryEmbedding(config=config)
276
+ del self.embed_tokens
277
+
278
+ @staticmethod
279
+ def _prepare_4d_causal_attention_mask_with_cache_position(
280
+ attention_mask: torch.Tensor,
281
+ sequence_length: int,
282
+ target_length: int,
283
+ dtype: torch.dtype,
284
+ device: torch.device,
285
+ cache_position: torch.Tensor,
286
+ batch_size: int,
287
+ config: PenguinVLVisionEncoderConfig,
288
+ past_key_values: Cache,
289
+ ):
290
+ """
291
+ Override the original causal mask method to create full attention mask instead.
292
+ Creates a full attention 4D mask of shape `(batch_size, 1, query_length, key_value_length)`
293
+ from a 2D mask of shape `(batch_size, key_value_length)`.
294
+
295
+ For vision encoding, we want full attention between all patches, not causal attention.
296
+ """
297
+ if attention_mask is not None and attention_mask.dim() == 4:
298
+ # In this case we assume that the mask comes already in inverted form and requires no inversion or slicing.
299
+ full_attention_mask = attention_mask
300
+ else:
301
+ # Create full attention mask (all zeros, meaning attend to all positions)
302
+ # We only mask based on the provided attention_mask for padding
303
+ if attention_mask is not None:
304
+ # Use the provided attention_mask to handle padding
305
+ min_dtype = torch.finfo(dtype).min
306
+ full_attention_mask = torch.zeros(
307
+ (sequence_length, target_length), dtype=dtype, device=device
308
+ )
309
+ # Expand to 4D
310
+ full_attention_mask = full_attention_mask[None, None, :, :].expand(batch_size, 1, -1, -1)
311
+
312
+ # Apply padding mask if provided
313
+ full_attention_mask = full_attention_mask.clone() # copy to contiguous memory for in-place edit
314
+ if attention_mask.shape[-1] > target_length:
315
+ attention_mask = attention_mask[:, :target_length]
316
+ mask_length = attention_mask.shape[-1]
317
+ padding_mask = attention_mask[:, None, None, :] == 0
318
+ full_attention_mask[:, :, :, :mask_length] = full_attention_mask[:, :, :, :mask_length].masked_fill(
319
+ padding_mask, min_dtype
320
+ )
321
+ else:
322
+ # No attention mask provided, create all-zeros mask (full attention)
323
+ full_attention_mask = torch.zeros(
324
+ (batch_size, 1, sequence_length, target_length), dtype=dtype, device=device
325
+ )
326
+ return full_attention_mask
327
+
328
+ def get_rope_index(self, grid_sizes, merge_sizes, position_ids):
329
+ position_ids = position_ids.contiguous()
330
+ batch_size = grid_sizes.shape[0]
331
+
332
+ # Vision Part: Generate 2D position indices for vision tokens
333
+ vision_pos_ids = []
334
+ for (t, h, w), merge_size in zip(grid_sizes, merge_sizes):
335
+ # Generate height position indices
336
+ hpos_ids = torch.arange(h).unsqueeze(1).expand(-1, w).to(position_ids.device)
337
+ hpos_ids = hpos_ids.reshape(
338
+ h // merge_size,
339
+ merge_size,
340
+ w // merge_size,
341
+ merge_size,
342
+ )
343
+ hpos_ids = hpos_ids.permute(0, 2, 1, 3)
344
+ hpos_ids = hpos_ids.flatten()
345
+
346
+ # Generate width position indices
347
+ wpos_ids = torch.arange(w).unsqueeze(0).expand(h, -1).to(position_ids.device)
348
+ wpos_ids = wpos_ids.reshape(
349
+ h // merge_size,
350
+ merge_size,
351
+ w // merge_size,
352
+ merge_size,
353
+ )
354
+ wpos_ids = wpos_ids.permute(0, 2, 1, 3)
355
+ wpos_ids = wpos_ids.flatten()
356
+
357
+ # Stack height and width to create 2D positions
358
+ vision_pos_ids.append(torch.stack([hpos_ids, wpos_ids], dim=-1).repeat(t, 1))
359
+
360
+ num_start_idx = 0
361
+ for batch_idx in range(batch_size):
362
+ pos_len = vision_pos_ids[batch_idx].shape[0]
363
+ position_ids[:, 0, num_start_idx: num_start_idx+pos_len] = vision_pos_ids[batch_idx].permute(1, 0)
364
+ num_start_idx += pos_len
365
+
366
+ return position_ids
367
+
368
+
369
+ def forward(
370
+ self,
371
+ input_ids: Optional[torch.LongTensor] = None,
372
+ attention_mask: Optional[torch.Tensor] = None,
373
+ position_ids: Optional[torch.LongTensor] = None,
374
+ past_key_values: Optional[Cache] = None,
375
+ inputs_embeds: Optional[torch.FloatTensor] = None,
376
+ use_cache: Optional[bool] = None,
377
+ output_attentions: Optional[bool] = None,
378
+ output_hidden_states: Optional[bool] = None,
379
+ cache_position: Optional[torch.LongTensor] = None,
380
+ grid_sizes: Optional[torch.Tensor] = None,
381
+ merge_sizes: Optional[torch.Tensor] = None,
382
+ **flash_attn_kwargs: Unpack[FlashAttentionKwargs],
383
+ ) -> BaseModelOutputWithPast:
384
+ output_attentions = output_attentions if output_attentions is not None else self.config.output_attentions
385
+ output_hidden_states = (
386
+ output_hidden_states if output_hidden_states is not None else self.config.output_hidden_states
387
+ )
388
+ use_cache = use_cache if use_cache is not None else self.config.use_cache
389
+
390
+ if (input_ids is None) ^ (inputs_embeds is not None):
391
+ raise ValueError("You must specify exactly one of input_ids or inputs_embeds")
392
+
393
+ # TODO (joao): remove this exception in v4.56 -- it exists for users that try to pass a legacy cache
394
+ if not isinstance(past_key_values, (type(None), Cache)):
395
+ raise ValueError("The `past_key_values` should be either a `Cache` object or `None`.")
396
+
397
+ if inputs_embeds is None:
398
+ inputs_embeds = self.embed_tokens(input_ids)
399
+
400
+ if use_cache and past_key_values is None:
401
+ past_key_values = DynamicCache()
402
+
403
+ if cache_position is None:
404
+ past_seen_tokens = past_key_values.get_seq_length() if past_key_values is not None else 0
405
+ cache_position = torch.arange(
406
+ past_seen_tokens, past_seen_tokens + inputs_embeds.shape[1], device=inputs_embeds.device
407
+ )
408
+
409
+ # the hard coded `2` is for temporal, height and width.
410
+ if position_ids is None:
411
+ position_ids = cache_position.view(1, 1, -1).expand(2, inputs_embeds.shape[0], -1)
412
+ elif position_ids.dim() == 2:
413
+ position_ids = position_ids[None, ...].expand(2, position_ids.shape[0], -1)
414
+ position_ids = self.get_rope_index(grid_sizes, merge_sizes, position_ids)
415
+
416
+ causal_mask = None
417
+
418
+ hidden_states = inputs_embeds
419
+
420
+ # create position embeddings to be shared across the decoder layers
421
+ position_embeddings = self.rotary_emb(hidden_states, position_ids)
422
+
423
+ # decoder layers
424
+ all_hidden_states = () if output_hidden_states else None
425
+ all_self_attns = () if output_attentions else None
426
+
427
+ # Calculate cumulative sequence lengths for the grid sizes
428
+ cu_seqlens = torch.repeat_interleave(grid_sizes[:, 1] * grid_sizes[:, 2], grid_sizes[:, 0]).cumsum(dim=0, dtype=torch.int32)
429
+ cu_seqlens = F.pad(cu_seqlens, (1, 0), value=0)
430
+
431
+ for decoder_layer in self.layers[: self.config.num_hidden_layers]:
432
+ if output_hidden_states:
433
+ all_hidden_states += (hidden_states,)
434
+
435
+ layer_outputs = decoder_layer(
436
+ hidden_states,
437
+ attention_mask=causal_mask,
438
+ position_ids=position_ids,
439
+ past_key_value=past_key_values,
440
+ output_attentions=output_attentions,
441
+ use_cache=use_cache,
442
+ cache_position=cache_position,
443
+ position_embeddings=position_embeddings,
444
+ cu_seqlens=cu_seqlens,
445
+ **flash_attn_kwargs,
446
+ )
447
+
448
+ hidden_states = layer_outputs[0]
449
+
450
+ if output_attentions:
451
+ all_self_attns += (layer_outputs[1],)
452
+
453
+ hidden_states = self.norm(hidden_states)
454
+
455
+ # add hidden states from the last decoder layer
456
+ if output_hidden_states:
457
+ all_hidden_states += (hidden_states,)
458
+
459
+ return BaseModelOutputWithPast(
460
+ last_hidden_state=hidden_states,
461
+ past_key_values=past_key_values if use_cache else None,
462
+ hidden_states=all_hidden_states,
463
+ attentions=all_self_attns,
464
+ )
465
+
466
+
467
+ class PenguinVLVisionEncoderModel(PreTrainedModel):
468
+
469
+ config_class = PenguinVLVisionEncoderConfig
470
+ base_model_prefix = "penguinvl_vision_encoder"
471
+ main_input_name = "pixel_values"
472
+ _no_split_modules = [
473
+ "PenguinVLVisionEncoderEmbeddings",
474
+ ]
475
+ _supports_flash_attn_2 = True
476
+ _supports_sdpa = True
477
+
478
+ def __init__(self, config: PenguinVLVisionEncoderConfig):
479
+ super().__init__(config=config)
480
+ self.embeddings = PenguinVLVisionEncoderEmbeddings(config)
481
+ self.encoder = PenguinVLVisionEncoderFromQwen3Model(config)
482
+
483
+ self.post_init()
484
+
485
+
486
+ def forward(self, pixel_values, grid_sizes, merge_sizes=None) -> torch.Tensor:
487
+ hidden_states = self.embeddings(pixel_values)
488
+ encoder_output = self.encoder(
489
+ inputs_embeds=hidden_states[None, ...],
490
+ grid_sizes=grid_sizes,
491
+ merge_sizes=merge_sizes,
492
+ output_hidden_states=True,
493
+ )
494
+ hidden_states = encoder_output.hidden_states
495
+ hidden_states = hidden_states[-1].squeeze(0)
496
+
497
+ hidden_states_chunks = hidden_states.split(grid_sizes.prod(dim=1).tolist(), dim=0)
498
+ outputs = []
499
+
500
+ for hidden_states, grid_size, merge_size in zip(hidden_states_chunks, grid_sizes, merge_sizes):
501
+ # NOTE: previous implementation, which supports downsampling with any factor
502
+ c = hidden_states.shape[-1]
503
+ hidden_states = hidden_states.view(
504
+ grid_size[0], grid_size[1] // merge_size, grid_size[2] // merge_size, merge_size, merge_size, c
505
+ ).permute(0, 1, 3, 2, 4, 5)
506
+ hidden_states = hidden_states.reshape(
507
+ grid_size[0], grid_size[1], grid_size[2], c
508
+ ).permute(0, 3, 1, 2)
509
+ hidden_states = torch.nn.functional.interpolate(
510
+ hidden_states,
511
+ size=(grid_size[1] // merge_size, grid_size[2] // merge_size),
512
+ mode='bilinear'
513
+ )
514
+ hidden_states = hidden_states.permute(0, 2, 3, 1).view(-1, c)
515
+
516
+ # NOTE: simplified implementation, which only supports downsampling with integer factor
517
+ # NOTE: this implementation is mathematically equivalent to the previous one when merge_size is 1 or 2 but may cause slightly different results
518
+ # hidden_states = hidden_states.view(-1, merge_size * merge_size, hidden_states.size(-1))
519
+ # hidden_states = hidden_states.mean(dim=1)
520
+
521
+ outputs.append(hidden_states)
522
+ return torch.cat(outputs, dim=0)
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ transformers==5.15.1
2
+ safetensors==0.8.0
3
+ pillow==12.2.0
4
+ pyyaml==6.0.3
5
+ accelerate==1.15.0