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

embed_video: native preprocessing (frame tensors and torchcodec path decode); drops the qwen-vl-utils dependency; bitwise-equal to the previous implementation and the raw base on identical inputs

Browse files
Files changed (1) hide show
  1. modeling_fusion_embedding.py +191 -57
modeling_fusion_embedding.py CHANGED
@@ -22,8 +22,9 @@ untouched), so text/image/video outputs are bit-for-bit those of generation 1 an
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
@@ -245,6 +246,180 @@ class OmniAudioAdapter(nn.Module):
245
  # --------------------------------------------------------------------------- #
246
  # the AutoModel entry point
247
  # --------------------------------------------------------------------------- #
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  class FusionEmbeddingModel(PreTrainedModel):
249
  """fusion-embedding for transformers AutoModel (trust_remote_code).
250
 
@@ -447,20 +622,15 @@ class FusionEmbeddingModel(PreTrainedModel):
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
@@ -468,49 +638,13 @@ class FusionEmbeddingModel(PreTrainedModel):
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)
 
22
  base's computation path.
23
 
24
  Requires: transformers>=4.46 (with the Qwen2.5-Omni model classes), torchvision, pillow,
25
+ soundfile, librosa; embedding a video by file path additionally requires torchcodec
26
+ (decoded frame tensors and frame sequences need nothing extra). A CUDA GPU is
27
+ recommended (~14 GB at bf16).
28
  """
29
 
30
  from __future__ import annotations
 
246
  # --------------------------------------------------------------------------- #
247
  # the AutoModel entry point
248
  # --------------------------------------------------------------------------- #
249
+ # --------------------------------------------------------------------------- #
250
+ # video preprocessing (native)
251
+ #
252
+ # Faithful reimplementation of the base model's reference video preprocessing
253
+ # (the Qwen3-VL-Embedding scripts' vision pipeline at image_patch_size=16), so
254
+ # no extra vision package is needed: frame selection, the per-frame image
255
+ # resize applied to frame-sequence inputs, the per-video smart resize under the
256
+ # total-pixel budget, and the processor kwargs (do_resize=False,
257
+ # do_sample_frames=False, video_metadata) match the reference exactly; outputs
258
+ # are verified bitwise-equal against the reference implementation on identical
259
+ # inputs. Decoded-video inputs (frame tensors, file paths via torchcodec) take
260
+ # the reference path-input treatment: a single per-video resize, no per-frame
261
+ # image resize.
262
+ # --------------------------------------------------------------------------- #
263
+ _V_PATCH_FACTOR = 32 # image_patch_size 16 x spatial merge 2
264
+ _V_FRAME_FACTOR = 2
265
+ _V_DEFAULT_FPS = 1.0
266
+ _V_DEFAULT_MAX_FRAMES = 64
267
+ _V_MIN_PIXELS = 128 * _V_PATCH_FACTOR ** 2 # per-frame floor
268
+ _V_MAX_PIXELS = 768 * _V_PATCH_FACTOR ** 2 # per-frame ceiling
269
+ _V_TOTAL_PIXELS = 10 * _V_MAX_PIXELS # per-video budget
270
+ _V_IMG_MIN_PIXELS = 4 * _V_PATCH_FACTOR ** 2 # per-frame image defaults
271
+ _V_IMG_MAX_PIXELS = 16384 * _V_PATCH_FACTOR ** 2
272
+ _V_FPS_MIN_FRAMES = 4
273
+ _V_MAX_RATIO = 200
274
+
275
+
276
+ def _v_round(n: float, f: int) -> int:
277
+ return round(n / f) * f
278
+
279
+
280
+ def _v_ceil(n: float, f: int) -> int:
281
+ return math.ceil(n / f) * f
282
+
283
+
284
+ def _v_floor(n: float, f: int) -> int:
285
+ return math.floor(n / f) * f
286
+
287
+
288
+ def _v_smart_resize(height: int, width: int, factor: int,
289
+ min_pixels: int, max_pixels: int):
290
+ if max(height, width) / min(height, width) > _V_MAX_RATIO:
291
+ raise ValueError(
292
+ f"absolute aspect ratio must be smaller than {_V_MAX_RATIO}, "
293
+ f"got {max(height, width) / min(height, width)}")
294
+ h_bar = max(factor, _v_round(height, factor))
295
+ w_bar = max(factor, _v_round(width, factor))
296
+ if h_bar * w_bar > max_pixels:
297
+ beta = math.sqrt((height * width) / max_pixels)
298
+ h_bar = _v_floor(height / beta, factor)
299
+ w_bar = _v_floor(width / beta, factor)
300
+ elif h_bar * w_bar < min_pixels:
301
+ beta = math.sqrt(min_pixels / (height * width))
302
+ h_bar = _v_ceil(height * beta, factor)
303
+ w_bar = _v_ceil(width * beta, factor)
304
+ return h_bar, w_bar
305
+
306
+
307
+ def _v_frame_to_image(frame):
308
+ """Frame-sequence element -> resized RGB PIL image (reference fetch_image)."""
309
+ from PIL import Image
310
+
311
+ if isinstance(frame, (str, os.PathLike)):
312
+ image = Image.open(str(frame))
313
+ else:
314
+ image = frame
315
+ if image.mode == "RGBA":
316
+ white = Image.new("RGB", image.size, (255, 255, 255))
317
+ white.paste(image, mask=image.split()[3])
318
+ image = white
319
+ else:
320
+ image = image.convert("RGB")
321
+ width, height = image.size
322
+ rh, rw = _v_smart_resize(height, width, _V_PATCH_FACTOR,
323
+ _V_IMG_MIN_PIXELS, _V_IMG_MAX_PIXELS)
324
+ return image.resize((rw, rh))
325
+
326
+
327
+ def _v_prepare(video, fps, max_frames):
328
+ """Normalize any supported video input to (uint8 tensor [T,C,H,W], metadata).
329
+
330
+ Frame sequences follow the reference list-input treatment (per-frame image
331
+ resize, pad to an even count by repeating the last frame, synthetic
332
+ metadata at 2 fps). Frame tensors and file paths follow the reference
333
+ decoded-video treatment (frame selection only; single per-video resize).
334
+ """
335
+ import numpy as np
336
+
337
+ if isinstance(video, torch.Tensor):
338
+ if video.ndim != 4 or video.shape[1] not in (1, 3):
339
+ raise ValueError(
340
+ f"expected a [T, C, H, W] frame tensor, got {list(video.shape)}")
341
+ frames = video
342
+ if frames.shape[1] == 1:
343
+ frames = frames.expand(-1, 3, -1, -1)
344
+ if frames.dtype != torch.uint8:
345
+ frames = frames.clamp(0, 255).to(torch.uint8)
346
+ t = frames.shape[0]
347
+ mf = max_frames or _V_DEFAULT_MAX_FRAMES
348
+ if t > mf:
349
+ idx = np.linspace(0, t - 1, mf, dtype=int)
350
+ frames = frames[torch.as_tensor(idx.copy())]
351
+ t = mf
352
+ n = _v_ceil(t, _V_FRAME_FACTOR)
353
+ if t < n:
354
+ frames = torch.cat([frames, frames[-1:].expand(n - t, -1, -1, -1)])
355
+ metadata = dict(fps=2.0, frames_indices=list(range(n)),
356
+ total_num_frames=float(n))
357
+ return frames, metadata
358
+
359
+ if isinstance(video, (str, os.PathLike)):
360
+ v = str(video)
361
+ if v.startswith("file://"):
362
+ v = v[7:]
363
+ try:
364
+ from torchcodec.decoders import VideoDecoder
365
+ except ImportError as e:
366
+ raise ImportError(
367
+ "embedding a video by file path requires torchcodec "
368
+ "(pip install torchcodec); alternatively pass decoded frames "
369
+ "(a [T, C, H, W] tensor or a list of PIL images)") from e
370
+ decoder = VideoDecoder(v)
371
+ video_fps = decoder.metadata.average_fps
372
+ total = decoder.metadata.num_frames
373
+ want_fps = fps or _V_DEFAULT_FPS
374
+ min_frames = _v_ceil(_V_FPS_MIN_FRAMES, _V_FRAME_FACTOR)
375
+ max_f = _v_floor(max_frames or _V_DEFAULT_MAX_FRAMES, _V_FRAME_FACTOR)
376
+ n = total / video_fps * want_fps
377
+ n = min(min(max(n, min_frames), max_f), total)
378
+ n = _v_floor(n, _V_FRAME_FACTOR)
379
+ if not (_V_FRAME_FACTOR <= n <= total):
380
+ raise ValueError(
381
+ f"video too short: {total} frames; need >= {_V_FRAME_FACTOR}")
382
+ idx = torch.linspace(0, total - 1, n).round().long().tolist()
383
+ frames = decoder.get_frames_at(indices=idx).data
384
+ metadata = dict(fps=video_fps, frames_indices=idx,
385
+ total_num_frames=total, video_backend="torchcodec")
386
+ return frames, metadata
387
+
388
+ # frame sequence (PIL images and/or paths)
389
+ frames = list(video)
390
+ if not frames:
391
+ raise ValueError("empty frame sequence")
392
+ mf = max_frames or _V_DEFAULT_MAX_FRAMES
393
+ if len(frames) > mf:
394
+ idx = np.linspace(0, len(frames) - 1, mf, dtype=int)
395
+ frames = [frames[i] for i in idx]
396
+ images = [_v_frame_to_image(f) for f in frames]
397
+ n = _v_ceil(len(images), _V_FRAME_FACTOR)
398
+ if len(images) < n:
399
+ images.extend([images[-1]] * (n - len(images)))
400
+ tensor = torch.stack([
401
+ torch.from_numpy(np.array(image).transpose(2, 0, 1)) for image in images
402
+ ])
403
+ metadata = dict(fps=2.0, frames_indices=list(range(n)),
404
+ total_num_frames=float(n))
405
+ return tensor, metadata
406
+
407
+
408
+ def _v_resize_video(frames: torch.Tensor) -> torch.Tensor:
409
+ """Per-video smart resize under the total-pixel budget (reference exact)."""
410
+ from torchvision.transforms import InterpolationMode
411
+ from torchvision.transforms import functional as TF
412
+
413
+ n, _, height, width = frames.shape
414
+ max_pixels = max(min(_V_MAX_PIXELS, _V_TOTAL_PIXELS / n * _V_FRAME_FACTOR),
415
+ int(_V_MIN_PIXELS * 1.05))
416
+ rh, rw = _v_smart_resize(height, width, _V_PATCH_FACTOR,
417
+ _V_MIN_PIXELS, max_pixels)
418
+ return TF.resize(frames, [rh, rw],
419
+ interpolation=InterpolationMode.BICUBIC,
420
+ antialias=True).float()
421
+
422
+
423
  class FusionEmbeddingModel(PreTrainedModel):
424
  """fusion-embedding for transformers AutoModel (trust_remote_code).
425
 
 
622
  dim: Optional[int] = None) -> torch.Tensor:
623
  """Embed a video through the frozen base model's own video path.
624
 
625
+ ``video`` is a decoded frame tensor ([T, C, H, W], e.g. straight from
626
+ a torchcodec ``VideoDecoder``), a file path/URL (decoded with
627
+ torchcodec, 1 fps up to 64 frames), or a pre-extracted frame sequence
628
+ (PIL images and/or frame paths, sampled uniformly to 64).
629
+ Preprocessing natively reimplements the base model's reference
630
+ scripts (see the module-level helpers above); no extra vision package
631
+ is required. Like images, video is a non-audio input: it takes the
632
+ frozen path (no whitening, no adapters).
 
 
 
633
  """
 
 
634
  self._ensure_backbones()
635
  if self._rt["gate"].active:
636
  # The video path runs through the same (hook-carrying) decoder
 
638
  # the gate closed so the adapter branch never runs.
639
  raise RuntimeError("adapter gate is open during a video embed — "
640
  "non-audio inputs must run with the gate closed")
641
+ frames, metadata = _v_prepare(video, fps, max_frames)
642
+ frames = _v_resize_video(frames)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
643
  text = _chat(DOC_INSTRUCTION, "<|vision_start|><|video_pad|><|vision_end|>")
644
+ inputs = self._rt["proc"](text=[text], videos=[frames],
645
+ video_metadata=[metadata],
646
+ do_resize=False, do_sample_frames=False,
647
+ return_tensors="pt").to(self._device)
648
  h = self._rt["full"](**inputs).last_hidden_state
649
  pooled = last_token_pool(h, inputs["attention_mask"])
650
  return self._finish(pooled, dim)