abtonmoy commited on
Commit
2d30494
·
verified ·
1 Parent(s): 1ee57f7

inference.py: native video preprocessing (no qwen-vl-utils)

Browse files
Files changed (1) hide show
  1. inference.py +191 -55
inference.py CHANGED
@@ -16,12 +16,14 @@ this formatting, so use the templates provided here rather than constructing you
16
  a, t, i = fe.embed_audio("dog.wav"), fe.embed_text("a dog barks"), fe.embed_image("dog.jpg")
17
 
18
  Requires: fusion_embedding (pip install git+https://github.com/Eximius-Labs/fusion-embedding),
19
- transformers>=4.46, torchvision, pillow, soundfile, librosa.
 
20
  """
21
 
22
  from __future__ import annotations
23
 
24
  import dataclasses
 
25
  import os
26
  from typing import TYPE_CHECKING, Optional, Union
27
 
@@ -44,6 +46,180 @@ def _chat(instruction: str, user_content: str) -> str:
44
  f"<|im_start|>assistant\n")
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  class FusionEmbedder:
48
  def __init__(self, ckpt_path: str, device: str = "cuda", dtype=torch.bfloat16):
49
  from transformers import AutoFeatureExtractor, AutoModel, AutoProcessor
@@ -180,19 +356,15 @@ class FusionEmbedder:
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;
@@ -200,49 +372,13 @@ class FusionEmbedder:
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)
 
16
  a, t, i = fe.embed_audio("dog.wav"), fe.embed_text("a dog barks"), fe.embed_image("dog.jpg")
17
 
18
  Requires: fusion_embedding (pip install git+https://github.com/Eximius-Labs/fusion-embedding),
19
+ transformers>=4.46, torchvision, pillow, soundfile, librosa; embedding a video by
20
+ file path additionally requires torchcodec.
21
  """
22
 
23
  from __future__ import annotations
24
 
25
  import dataclasses
26
+ import math
27
  import os
28
  from typing import TYPE_CHECKING, Optional, Union
29
 
 
46
  f"<|im_start|>assistant\n")
47
 
48
 
49
+ # --------------------------------------------------------------------------- #
50
+ # video preprocessing (native)
51
+ #
52
+ # Faithful reimplementation of the base model's reference video preprocessing
53
+ # (the Qwen3-VL-Embedding scripts' vision pipeline at image_patch_size=16), so
54
+ # no extra vision package is needed: frame selection, the per-frame image
55
+ # resize applied to frame-sequence inputs, the per-video smart resize under the
56
+ # total-pixel budget, and the processor kwargs (do_resize=False,
57
+ # do_sample_frames=False, video_metadata) match the reference exactly; outputs
58
+ # are verified bitwise-equal against the reference implementation on identical
59
+ # inputs. Decoded-video inputs (frame tensors, file paths via torchcodec) take
60
+ # the reference path-input treatment: a single per-video resize, no per-frame
61
+ # image resize.
62
+ # --------------------------------------------------------------------------- #
63
+ _V_PATCH_FACTOR = 32 # image_patch_size 16 x spatial merge 2
64
+ _V_FRAME_FACTOR = 2
65
+ _V_DEFAULT_FPS = 1.0
66
+ _V_DEFAULT_MAX_FRAMES = 64
67
+ _V_MIN_PIXELS = 128 * _V_PATCH_FACTOR ** 2 # per-frame floor
68
+ _V_MAX_PIXELS = 768 * _V_PATCH_FACTOR ** 2 # per-frame ceiling
69
+ _V_TOTAL_PIXELS = 10 * _V_MAX_PIXELS # per-video budget
70
+ _V_IMG_MIN_PIXELS = 4 * _V_PATCH_FACTOR ** 2 # per-frame image defaults
71
+ _V_IMG_MAX_PIXELS = 16384 * _V_PATCH_FACTOR ** 2
72
+ _V_FPS_MIN_FRAMES = 4
73
+ _V_MAX_RATIO = 200
74
+
75
+
76
+ def _v_round(n: float, f: int) -> int:
77
+ return round(n / f) * f
78
+
79
+
80
+ def _v_ceil(n: float, f: int) -> int:
81
+ return math.ceil(n / f) * f
82
+
83
+
84
+ def _v_floor(n: float, f: int) -> int:
85
+ return math.floor(n / f) * f
86
+
87
+
88
+ def _v_smart_resize(height: int, width: int, factor: int,
89
+ min_pixels: int, max_pixels: int):
90
+ if max(height, width) / min(height, width) > _V_MAX_RATIO:
91
+ raise ValueError(
92
+ f"absolute aspect ratio must be smaller than {_V_MAX_RATIO}, "
93
+ f"got {max(height, width) / min(height, width)}")
94
+ h_bar = max(factor, _v_round(height, factor))
95
+ w_bar = max(factor, _v_round(width, factor))
96
+ if h_bar * w_bar > max_pixels:
97
+ beta = math.sqrt((height * width) / max_pixels)
98
+ h_bar = _v_floor(height / beta, factor)
99
+ w_bar = _v_floor(width / beta, factor)
100
+ elif h_bar * w_bar < min_pixels:
101
+ beta = math.sqrt(min_pixels / (height * width))
102
+ h_bar = _v_ceil(height * beta, factor)
103
+ w_bar = _v_ceil(width * beta, factor)
104
+ return h_bar, w_bar
105
+
106
+
107
+ def _v_frame_to_image(frame):
108
+ """Frame-sequence element -> resized RGB PIL image (reference fetch_image)."""
109
+ from PIL import Image
110
+
111
+ if isinstance(frame, (str, os.PathLike)):
112
+ image = Image.open(str(frame))
113
+ else:
114
+ image = frame
115
+ if image.mode == "RGBA":
116
+ white = Image.new("RGB", image.size, (255, 255, 255))
117
+ white.paste(image, mask=image.split()[3])
118
+ image = white
119
+ else:
120
+ image = image.convert("RGB")
121
+ width, height = image.size
122
+ rh, rw = _v_smart_resize(height, width, _V_PATCH_FACTOR,
123
+ _V_IMG_MIN_PIXELS, _V_IMG_MAX_PIXELS)
124
+ return image.resize((rw, rh))
125
+
126
+
127
+ def _v_prepare(video, fps, max_frames):
128
+ """Normalize any supported video input to (uint8 tensor [T,C,H,W], metadata).
129
+
130
+ Frame sequences follow the reference list-input treatment (per-frame image
131
+ resize, pad to an even count by repeating the last frame, synthetic
132
+ metadata at 2 fps). Frame tensors and file paths follow the reference
133
+ decoded-video treatment (frame selection only; single per-video resize).
134
+ """
135
+ import numpy as np
136
+
137
+ if isinstance(video, torch.Tensor):
138
+ if video.ndim != 4 or video.shape[1] not in (1, 3):
139
+ raise ValueError(
140
+ f"expected a [T, C, H, W] frame tensor, got {list(video.shape)}")
141
+ frames = video
142
+ if frames.shape[1] == 1:
143
+ frames = frames.expand(-1, 3, -1, -1)
144
+ if frames.dtype != torch.uint8:
145
+ frames = frames.clamp(0, 255).to(torch.uint8)
146
+ t = frames.shape[0]
147
+ mf = max_frames or _V_DEFAULT_MAX_FRAMES
148
+ if t > mf:
149
+ idx = np.linspace(0, t - 1, mf, dtype=int)
150
+ frames = frames[torch.as_tensor(idx.copy())]
151
+ t = mf
152
+ n = _v_ceil(t, _V_FRAME_FACTOR)
153
+ if t < n:
154
+ frames = torch.cat([frames, frames[-1:].expand(n - t, -1, -1, -1)])
155
+ metadata = dict(fps=2.0, frames_indices=list(range(n)),
156
+ total_num_frames=float(n))
157
+ return frames, metadata
158
+
159
+ if isinstance(video, (str, os.PathLike)):
160
+ v = str(video)
161
+ if v.startswith("file://"):
162
+ v = v[7:]
163
+ try:
164
+ from torchcodec.decoders import VideoDecoder
165
+ except ImportError as e:
166
+ raise ImportError(
167
+ "embedding a video by file path requires torchcodec "
168
+ "(pip install torchcodec); alternatively pass decoded frames "
169
+ "(a [T, C, H, W] tensor or a list of PIL images)") from e
170
+ decoder = VideoDecoder(v)
171
+ video_fps = decoder.metadata.average_fps
172
+ total = decoder.metadata.num_frames
173
+ want_fps = fps or _V_DEFAULT_FPS
174
+ min_frames = _v_ceil(_V_FPS_MIN_FRAMES, _V_FRAME_FACTOR)
175
+ max_f = _v_floor(max_frames or _V_DEFAULT_MAX_FRAMES, _V_FRAME_FACTOR)
176
+ n = total / video_fps * want_fps
177
+ n = min(min(max(n, min_frames), max_f), total)
178
+ n = _v_floor(n, _V_FRAME_FACTOR)
179
+ if not (_V_FRAME_FACTOR <= n <= total):
180
+ raise ValueError(
181
+ f"video too short: {total} frames; need >= {_V_FRAME_FACTOR}")
182
+ idx = torch.linspace(0, total - 1, n).round().long().tolist()
183
+ frames = decoder.get_frames_at(indices=idx).data
184
+ metadata = dict(fps=video_fps, frames_indices=idx,
185
+ total_num_frames=total, video_backend="torchcodec")
186
+ return frames, metadata
187
+
188
+ # frame sequence (PIL images and/or paths)
189
+ frames = list(video)
190
+ if not frames:
191
+ raise ValueError("empty frame sequence")
192
+ mf = max_frames or _V_DEFAULT_MAX_FRAMES
193
+ if len(frames) > mf:
194
+ idx = np.linspace(0, len(frames) - 1, mf, dtype=int)
195
+ frames = [frames[i] for i in idx]
196
+ images = [_v_frame_to_image(f) for f in frames]
197
+ n = _v_ceil(len(images), _V_FRAME_FACTOR)
198
+ if len(images) < n:
199
+ images.extend([images[-1]] * (n - len(images)))
200
+ tensor = torch.stack([
201
+ torch.from_numpy(np.array(image).transpose(2, 0, 1)) for image in images
202
+ ])
203
+ metadata = dict(fps=2.0, frames_indices=list(range(n)),
204
+ total_num_frames=float(n))
205
+ return tensor, metadata
206
+
207
+
208
+ def _v_resize_video(frames: torch.Tensor) -> torch.Tensor:
209
+ """Per-video smart resize under the total-pixel budget (reference exact)."""
210
+ from torchvision.transforms import InterpolationMode
211
+ from torchvision.transforms import functional as TF
212
+
213
+ n, _, height, width = frames.shape
214
+ max_pixels = max(min(_V_MAX_PIXELS, _V_TOTAL_PIXELS / n * _V_FRAME_FACTOR),
215
+ int(_V_MIN_PIXELS * 1.05))
216
+ rh, rw = _v_smart_resize(height, width, _V_PATCH_FACTOR,
217
+ _V_MIN_PIXELS, max_pixels)
218
+ return TF.resize(frames, [rh, rw],
219
+ interpolation=InterpolationMode.BICUBIC,
220
+ antialias=True).float()
221
+
222
+
223
  class FusionEmbedder:
224
  def __init__(self, ckpt_path: str, device: str = "cuda", dtype=torch.bfloat16):
225
  from transformers import AutoFeatureExtractor, AutoModel, AutoProcessor
 
356
  dim: Optional[int] = None) -> torch.Tensor:
357
  """Embed a video through the frozen base model's own video path.
358
 
359
+ ``video`` is a decoded frame tensor ([T, C, H, W], e.g. straight from
360
+ a torchcodec ``VideoDecoder``), a file path/URL (decoded with
361
+ torchcodec, 1 fps up to 64 frames), or a pre-extracted frame sequence
362
+ (PIL images and/or frame paths, sampled uniformly to 64).
363
+ Preprocessing natively reimplements the base model's reference
364
+ scripts (see the module-level helpers above); no extra vision package
365
+ is required. Like images, video is a non-audio input: it takes the
366
+ frozen path (no whitening, no adapters).
 
 
367
  """
 
 
368
  gate = getattr(self.model, "_adapter_gate", None)
369
  if gate is not None and gate.active:
370
  # The video path runs through the same (hook-carrying) decoder layers;
 
372
  # never runs. Mirrors the encode_text/embed_image guards.
373
  raise RuntimeError("adapter gate is open during a video embed — "
374
  "non-audio inputs must run with the gate closed")
375
+ frames, metadata = _v_prepare(video, fps, max_frames)
376
+ frames = _v_resize_video(frames)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  text = _chat(DOC_INSTRUCTION, "<|vision_start|><|video_pad|><|vision_end|>")
378
+ inputs = self.proc(text=[text], videos=[frames],
379
+ video_metadata=[metadata],
380
+ do_resize=False, do_sample_frames=False,
381
+ return_tensors="pt").to(self.device)
382
  h = self.full(**inputs).last_hidden_state
383
  pooled = self._pool(h, inputs["attention_mask"])
384
  return self._finish(pooled, dim)