Add embed_video: the frozen base's own video path (qwen-vl-utils preprocessing, official defaults), gate-guarded like images; bitwise-verified against the base and the reference loader
Browse files- modeling_fusion_embedding.py +80 -4
modeling_fusion_embedding.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""HF remote-code modeling for the fusion-embedding family (AutoModel + trust_remote_code).
|
| 2 |
|
| 3 |
-
One embedding space for text, images, and audio. The checkpoint on this repository holds
|
| 4 |
ONLY the trained components (perceiver-resampler connector, diagonal text whitening,
|
| 5 |
logit scale and — generation 2 — the modality-gated deep adapters); the frozen
|
| 6 |
Qwen3-VL-Embedding-2B base and the frozen Qwen2.5-Omni audio tower are downloaded from
|
|
@@ -12,16 +12,18 @@ their own repositories on first use and are byte-identical to their releases.
|
|
| 12 |
t = model.embed_text("a dog barks in the distance")
|
| 13 |
a = model.embed_audio("dog.wav")
|
| 14 |
i = model.embed_image("dog.jpg")
|
|
|
|
| 15 |
|
| 16 |
The embed_* methods reproduce the repository's reference ``inference.py`` exactly (same
|
| 17 |
chat templates, truncation, pooling, whitening, Matryoshka truncation and normalization);
|
| 18 |
outputs are bitwise-identical to that loader on the same hardware. Non-audio inputs never
|
| 19 |
execute the generation-2 adapter branch (the gate returns the frozen layers' output
|
| 20 |
-
untouched), so text/image outputs are bit-for-bit those of generation 1 and of the
|
| 21 |
-
computation path.
|
| 22 |
|
| 23 |
Requires: transformers>=4.46 (with the Qwen2.5-Omni model classes), torchvision, pillow,
|
| 24 |
-
soundfile, librosa
|
|
|
|
| 25 |
"""
|
| 26 |
|
| 27 |
from __future__ import annotations
|
|
@@ -439,6 +441,80 @@ class FusionEmbeddingModel(PreTrainedModel):
|
|
| 439 |
pooled = last_token_pool(h, inputs["attention_mask"])
|
| 440 |
return self._finish(pooled, dim)
|
| 441 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
# ------------------------------------------------------------- batched
|
| 443 |
@torch.no_grad()
|
| 444 |
def embed_text_batch(self, texts, instruction: str = DEFAULT_QUERY_INSTRUCTION,
|
|
|
|
| 1 |
"""HF remote-code modeling for the fusion-embedding family (AutoModel + trust_remote_code).
|
| 2 |
|
| 3 |
+
One embedding space for text, images, video, and audio. The checkpoint on this repository holds
|
| 4 |
ONLY the trained components (perceiver-resampler connector, diagonal text whitening,
|
| 5 |
logit scale and — generation 2 — the modality-gated deep adapters); the frozen
|
| 6 |
Qwen3-VL-Embedding-2B base and the frozen Qwen2.5-Omni audio tower are downloaded from
|
|
|
|
| 12 |
t = model.embed_text("a dog barks in the distance")
|
| 13 |
a = model.embed_audio("dog.wav")
|
| 14 |
i = model.embed_image("dog.jpg")
|
| 15 |
+
v = model.embed_video("dog.mp4") # or a list of PIL frames
|
| 16 |
|
| 17 |
The embed_* methods reproduce the repository's reference ``inference.py`` exactly (same
|
| 18 |
chat templates, truncation, pooling, whitening, Matryoshka truncation and normalization);
|
| 19 |
outputs are bitwise-identical to that loader on the same hardware. Non-audio inputs never
|
| 20 |
execute the generation-2 adapter branch (the gate returns the frozen layers' output
|
| 21 |
+
untouched), so text/image/video outputs are bit-for-bit those of generation 1 and of the
|
| 22 |
+
base's computation path.
|
| 23 |
|
| 24 |
Requires: transformers>=4.46 (with the Qwen2.5-Omni model classes), torchvision, pillow,
|
| 25 |
+
soundfile, librosa; video embedding additionally requires qwen-vl-utils>=0.0.14 (the
|
| 26 |
+
base model's own video preprocessing). A CUDA GPU is recommended (~14 GB at bf16).
|
| 27 |
"""
|
| 28 |
|
| 29 |
from __future__ import annotations
|
|
|
|
| 441 |
pooled = last_token_pool(h, inputs["attention_mask"])
|
| 442 |
return self._finish(pooled, dim)
|
| 443 |
|
| 444 |
+
@torch.no_grad()
|
| 445 |
+
def embed_video(self, video, fps: Optional[float] = None,
|
| 446 |
+
max_frames: Optional[int] = None,
|
| 447 |
+
dim: Optional[int] = None) -> torch.Tensor:
|
| 448 |
+
"""Embed a video through the frozen base model's own video path.
|
| 449 |
+
|
| 450 |
+
``video`` is a file path/URL, or a pre-extracted frame sequence (a list
|
| 451 |
+
of PIL images and/or frame-image paths). Preprocessing follows the base
|
| 452 |
+
model's official usage (the Qwen3-VL-Embedding reference scripts):
|
| 453 |
+
``qwen_vl_utils.process_vision_info`` with ``image_patch_size=16``,
|
| 454 |
+
1 fps sampling up to 64 frames for path inputs, uniform temporal
|
| 455 |
+
sampling of frame sequences, a 7,864,320 total-pixel budget
|
| 456 |
+
(10 x 768 x 32 x 32), and ``do_resize=False`` at the processor because
|
| 457 |
+
the vision utility already smart-resizes. Like images, video is a
|
| 458 |
+
non-audio input: it takes the frozen path (no whitening, no adapters).
|
| 459 |
+
Path inputs additionally need a video decoder backend supported by
|
| 460 |
+
``qwen_vl_utils`` (e.g. torchvision/decord); frame sequences do not.
|
| 461 |
+
"""
|
| 462 |
+
import numpy as np
|
| 463 |
+
|
| 464 |
+
self._ensure_backbones()
|
| 465 |
+
if self._rt["gate"].active:
|
| 466 |
+
# The video path runs through the same (hook-carrying) decoder
|
| 467 |
+
# layers as text and images; non-audio inputs must execute with
|
| 468 |
+
# the gate closed so the adapter branch never runs.
|
| 469 |
+
raise RuntimeError("adapter gate is open during a video embed — "
|
| 470 |
+
"non-audio inputs must run with the gate closed")
|
| 471 |
+
try:
|
| 472 |
+
from qwen_vl_utils import process_vision_info
|
| 473 |
+
except ImportError as e: # pragma: no cover - environment dependent
|
| 474 |
+
raise ImportError(
|
| 475 |
+
"video embedding uses the base model's own preprocessing "
|
| 476 |
+
"package: pip install 'qwen-vl-utils>=0.0.14'") from e
|
| 477 |
+
|
| 478 |
+
# Constants from the base's reference implementation.
|
| 479 |
+
video_total_pixels = 10 * 768 * 32 * 32
|
| 480 |
+
default_fps, default_max_frames = 1.0, 64
|
| 481 |
+
|
| 482 |
+
if isinstance(video, (str, os.PathLike)):
|
| 483 |
+
v = str(video)
|
| 484 |
+
content = v if v.startswith(("http://", "https://")) \
|
| 485 |
+
else "file://" + os.path.abspath(v)
|
| 486 |
+
vkw = {"fps": fps or default_fps,
|
| 487 |
+
"max_frames": max_frames or default_max_frames,
|
| 488 |
+
"total_pixels": video_total_pixels}
|
| 489 |
+
else:
|
| 490 |
+
frames = list(video)
|
| 491 |
+
if not frames:
|
| 492 |
+
raise ValueError("empty frame sequence")
|
| 493 |
+
mf = max_frames or default_max_frames
|
| 494 |
+
if len(frames) > mf:
|
| 495 |
+
# Uniform temporal sampling, as in the base's sample_frames.
|
| 496 |
+
idx = np.linspace(0, len(frames) - 1, mf, dtype=int)
|
| 497 |
+
frames = [frames[i] for i in idx]
|
| 498 |
+
content = ["file://" + os.path.abspath(str(f))
|
| 499 |
+
if isinstance(f, (str, os.PathLike)) else f
|
| 500 |
+
for f in frames]
|
| 501 |
+
vkw = {"total_pixels": video_total_pixels}
|
| 502 |
+
|
| 503 |
+
conversation = [{"role": "user",
|
| 504 |
+
"content": [{"type": "video", "video": content, **vkw}]}]
|
| 505 |
+
_, video_inputs, video_kwargs = process_vision_info(
|
| 506 |
+
conversation, image_patch_size=16,
|
| 507 |
+
return_video_metadata=True, return_video_kwargs=True)
|
| 508 |
+
videos, video_metadata = zip(*video_inputs)
|
| 509 |
+
text = _chat(DOC_INSTRUCTION, "<|vision_start|><|video_pad|><|vision_end|>")
|
| 510 |
+
inputs = self._rt["proc"](text=[text], videos=list(videos),
|
| 511 |
+
video_metadata=list(video_metadata),
|
| 512 |
+
do_resize=False, return_tensors="pt",
|
| 513 |
+
**video_kwargs).to(self._device)
|
| 514 |
+
h = self._rt["full"](**inputs).last_hidden_state
|
| 515 |
+
pooled = last_token_pool(h, inputs["attention_mask"])
|
| 516 |
+
return self._finish(pooled, dim)
|
| 517 |
+
|
| 518 |
# ------------------------------------------------------------- batched
|
| 519 |
@torch.no_grad()
|
| 520 |
def embed_text_batch(self, texts, instruction: str = DEFAULT_QUERY_INSTRUCTION,
|