inference.py: embed_video (base's own video path, gate-guarded)
Browse files- inference.py +74 -0
inference.py
CHANGED
|
@@ -173,6 +173,80 @@ class FusionEmbedder:
|
|
| 173 |
pooled = self._pool(h, inputs["attention_mask"])
|
| 174 |
return self._finish(pooled, dim)
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
# ------------------------------------------------------------------ cross-modal readout
|
| 177 |
@staticmethod
|
| 178 |
def center(embs: torch.Tensor) -> torch.Tensor:
|
|
|
|
| 173 |
pooled = self._pool(h, inputs["attention_mask"])
|
| 174 |
return self._finish(pooled, dim)
|
| 175 |
|
| 176 |
+
# ------------------------------------------------------------------ video
|
| 177 |
+
@torch.no_grad()
|
| 178 |
+
def embed_video(self, video, fps: Optional[float] = None,
|
| 179 |
+
max_frames: Optional[int] = None,
|
| 180 |
+
dim: Optional[int] = None) -> torch.Tensor:
|
| 181 |
+
"""Embed a video through the frozen base model's own video path.
|
| 182 |
+
|
| 183 |
+
``video`` is a file path/URL, or a pre-extracted frame sequence (a list of
|
| 184 |
+
PIL images and/or frame-image paths). Preprocessing follows the base model's
|
| 185 |
+
official usage (the Qwen3-VL-Embedding reference scripts):
|
| 186 |
+
``qwen_vl_utils.process_vision_info`` with ``image_patch_size=16``, 1 fps
|
| 187 |
+
sampling up to 64 frames for path inputs, uniform temporal sampling of frame
|
| 188 |
+
sequences, a 7,864,320 total-pixel budget, and ``do_resize=False`` at the
|
| 189 |
+
processor because the vision utility already smart-resizes. Like images,
|
| 190 |
+
video is a non-audio input and takes the frozen path (no whitening, no
|
| 191 |
+
adapters). Requires qwen-vl-utils>=0.0.14; path inputs additionally need a
|
| 192 |
+
video decoder backend supported by it.
|
| 193 |
+
"""
|
| 194 |
+
import numpy as np
|
| 195 |
+
|
| 196 |
+
gate = getattr(self.model, "_adapter_gate", None)
|
| 197 |
+
if gate is not None and gate.active:
|
| 198 |
+
# The video path runs through the same (hook-carrying) decoder layers;
|
| 199 |
+
# non-audio inputs must run with the gate closed so the adapter branch
|
| 200 |
+
# never runs. Mirrors the encode_text/embed_image guards.
|
| 201 |
+
raise RuntimeError("adapter gate is open during a video embed — "
|
| 202 |
+
"non-audio inputs must run with the gate closed")
|
| 203 |
+
try:
|
| 204 |
+
from qwen_vl_utils import process_vision_info
|
| 205 |
+
except ImportError as e:
|
| 206 |
+
raise ImportError(
|
| 207 |
+
"video embedding uses the base model's own preprocessing "
|
| 208 |
+
"package: pip install 'qwen-vl-utils>=0.0.14'") from e
|
| 209 |
+
|
| 210 |
+
# Constants from the base's reference implementation.
|
| 211 |
+
video_total_pixels = 10 * 768 * 32 * 32
|
| 212 |
+
default_fps, default_max_frames = 1.0, 64
|
| 213 |
+
|
| 214 |
+
if isinstance(video, (str, os.PathLike)):
|
| 215 |
+
v = str(video)
|
| 216 |
+
content = v if v.startswith(("http://", "https://")) \
|
| 217 |
+
else "file://" + os.path.abspath(v)
|
| 218 |
+
vkw = {"fps": fps or default_fps,
|
| 219 |
+
"max_frames": max_frames or default_max_frames,
|
| 220 |
+
"total_pixels": video_total_pixels}
|
| 221 |
+
else:
|
| 222 |
+
frames = list(video)
|
| 223 |
+
if not frames:
|
| 224 |
+
raise ValueError("empty frame sequence")
|
| 225 |
+
mf = max_frames or default_max_frames
|
| 226 |
+
if len(frames) > mf:
|
| 227 |
+
# Uniform temporal sampling, as in the base's sample_frames.
|
| 228 |
+
idx = np.linspace(0, len(frames) - 1, mf, dtype=int)
|
| 229 |
+
frames = [frames[i] for i in idx]
|
| 230 |
+
content = ["file://" + os.path.abspath(str(f))
|
| 231 |
+
if isinstance(f, (str, os.PathLike)) else f
|
| 232 |
+
for f in frames]
|
| 233 |
+
vkw = {"total_pixels": video_total_pixels}
|
| 234 |
+
|
| 235 |
+
conversation = [{"role": "user",
|
| 236 |
+
"content": [{"type": "video", "video": content, **vkw}]}]
|
| 237 |
+
_, video_inputs, video_kwargs = process_vision_info(
|
| 238 |
+
conversation, image_patch_size=16,
|
| 239 |
+
return_video_metadata=True, return_video_kwargs=True)
|
| 240 |
+
videos, video_metadata = zip(*video_inputs)
|
| 241 |
+
text = _chat(DOC_INSTRUCTION, "<|vision_start|><|video_pad|><|vision_end|>")
|
| 242 |
+
inputs = self.proc(text=[text], videos=list(videos),
|
| 243 |
+
video_metadata=list(video_metadata),
|
| 244 |
+
do_resize=False, return_tensors="pt",
|
| 245 |
+
**video_kwargs).to(self.device)
|
| 246 |
+
h = self.full(**inputs).last_hidden_state
|
| 247 |
+
pooled = self._pool(h, inputs["attention_mask"])
|
| 248 |
+
return self._finish(pooled, dim)
|
| 249 |
+
|
| 250 |
# ------------------------------------------------------------------ cross-modal readout
|
| 251 |
@staticmethod
|
| 252 |
def center(embs: torch.Tensor) -> torch.Tensor:
|