CCCCyx commited on
Commit
5a5a054
·
verified ·
1 Parent(s): dcf0713

Replace processor with 0708 version (Fast image/video processor, transformers 4.57+); keep 0408 pixel caps

Browse files
preprocessor_config.json CHANGED
@@ -1,26 +1,26 @@
1
  {
2
- "auto_map": {
3
- "AutoProcessor": "processing_moss_vl.MossVLProcessor",
4
- "AutoImageProcessor": "processing_moss_vl.MossVLImageProcessor"
5
- },
6
- "size": {
7
- "longest_edge": 16777216,
8
- "shortest_edge": 4096
9
- },
10
- "multi_image_max_pixels": 65536000,
11
- "patch_size": 16,
12
- "temporal_patch_size": 1,
13
- "merge_size": 2,
14
- "image_mean": [
15
- 0.5,
16
- 0.5,
17
- 0.5
18
- ],
19
- "image_std": [
20
- 0.5,
21
- 0.5,
22
- 0.5
23
- ],
24
- "processor_class": "MossVLProcessor",
25
- "image_processor_type": "MossVLImageProcessor"
26
  }
 
1
  {
2
+ "auto_map": {
3
+ "AutoProcessor": "processing_moss_vl.MossVLProcessor",
4
+ "AutoImageProcessor": "processing_moss_vl.MossVLImageProcessorFast"
5
+ },
6
+ "size": {
7
+ "longest_edge": 16777216,
8
+ "shortest_edge": 4096
9
+ },
10
+ "multi_image_max_pixels": 65536000,
11
+ "patch_size": 16,
12
+ "temporal_patch_size": 1,
13
+ "merge_size": 2,
14
+ "image_mean": [
15
+ 0.5,
16
+ 0.5,
17
+ 0.5
18
+ ],
19
+ "image_std": [
20
+ 0.5,
21
+ 0.5,
22
+ 0.5
23
+ ],
24
+ "processor_class": "MossVLProcessor",
25
+ "image_processor_type": "MossVLImageProcessorFast"
26
  }
processing_moss_vl.py CHANGED
@@ -23,8 +23,8 @@ import torch
23
  from torchvision.transforms.v2 import functional as F
24
  from PIL import Image
25
  from transformers.feature_extraction_utils import BatchFeature
26
- from transformers.image_utils import ImageInput, PILImageResampling, SizeDict
27
- from transformers.image_transforms import group_images_by_shape, reorder_images
28
  from transformers.utils import TensorType
29
  from transformers.processing_utils import (
30
  ImagesKwargs,
@@ -35,27 +35,28 @@ from transformers.processing_utils import (
35
  )
36
  from transformers.tokenization_utils_base import PreTokenizedInput, TextInput
37
  from transformers.utils import logging
38
- from transformers.models.qwen2_vl.image_processing_qwen2_vl import Qwen2VLImageProcessor, smart_resize
 
39
 
40
 
41
  logger = logging.get_logger(__name__)
42
 
43
 
44
- class MossVLImageProcessor(Qwen2VLImageProcessor):
45
  """
46
  Custom image processor that overrides _preprocess to support multi_image_max_pixels.
47
- Inherits from Qwen2VLImageProcessor.
48
  """
49
  # Multi-image batch total pixels limit (read from config)
50
  multi_image_max_pixels = None
51
-
52
 
53
  def _preprocess(
54
  self,
55
  images: list["torch.Tensor"],
56
  do_resize: bool,
57
  size: SizeDict,
58
- resample: Optional[Union["PILImageResampling", "F.InterpolationMode", int]],
59
  do_rescale: bool,
60
  rescale_factor: float,
61
  do_normalize: bool,
@@ -69,18 +70,16 @@ class MossVLImageProcessor(Qwen2VLImageProcessor):
69
  **kwargs,
70
  ):
71
  """Override _preprocess to use custom smart_resize with batch-level max_pixels.
72
-
73
  multi_image_max_pixels is treated as a batch-level total budget, proportionally allocated
74
  to each image based on its original pixel count. min_pixels remains a per-image
75
  constraint. multi_image_max_pixels can be configured separately from longest_edge.
76
  """
77
- if resample is None:
78
- resample = kwargs.pop("interpolation", None)
79
  min_pixels = size["shortest_edge"]
80
  max_pixels = size["longest_edge"] # Per-image upper limit
81
  # Use multi_image_max_pixels if configured, otherwise fall back to longest_edge
82
  multi_image_max_pixels = getattr(self, "multi_image_max_pixels", None) or max_pixels
83
-
84
  # Calculate total original pixels across all images in the batch
85
  # This is used to proportionally allocate max_pixels to each image
86
  total_original_pixels = sum(img.shape[-2] * img.shape[-1] for img in images)
@@ -99,13 +98,13 @@ class MossVLImageProcessor(Qwen2VLImageProcessor):
99
  proportional_max_pixels = int(multi_image_max_pixels * proportion)
100
  else:
101
  proportional_max_pixels = multi_image_max_pixels
102
-
103
  # Ensure proportional max_pixels is within [min_pixels, max_pixels] range
104
  # min_pixels: per-image lower limit (shortest_edge)
105
  # max_pixels: per-image upper limit (longest_edge)
106
  proportional_max_pixels = max(proportional_max_pixels, min_pixels)
107
  proportional_max_pixels = min(proportional_max_pixels, max_pixels)
108
-
109
  resized_height, resized_width = smart_resize(
110
  height,
111
  width,
@@ -116,11 +115,11 @@ class MossVLImageProcessor(Qwen2VLImageProcessor):
116
  stacked_images = self.resize(
117
  image=stacked_images,
118
  size=SizeDict(height=resized_height, width=resized_width),
119
- resample=resample,
120
  )
121
  resized_images_grouped[shape] = stacked_images
122
  resized_images = reorder_images(resized_images_grouped, grouped_images_index)
123
-
124
  # Warn if multi-image batch exceeds multi_image_max_pixels due to min_pixels constraint
125
  if len(images) > 1:
126
  total_resized_pixels = sum(img.shape[-2] * img.shape[-1] for img in resized_images)
@@ -165,12 +164,17 @@ class MossVLImageProcessor(Qwen2VLImageProcessor):
165
  )
166
  # Reorder dimensions to group grid and patch information for subsequent flattening.
167
  # (batch, grid_t, grid_h, grid_w, merge_h, merge_w, channel, temp_patch_size, patch_h, patch_w)
 
 
 
 
 
168
  patches = patches.permute(0, 1, 4, 7, 5, 8, 3, 2, 6, 9)
169
  flatten_patches = patches.reshape(
170
  batch_size,
171
  grid_t * grid_h * grid_w,
172
  channel * temporal_patch_size * patch_size * patch_size,
173
- )
174
 
175
  processed_images_grouped[shape] = flatten_patches
176
  processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size
@@ -188,17 +192,17 @@ def _to_numpy(x):
188
  """
189
  Convert various tensor types to numpy array.
190
  Supports torch.Tensor, tf.Tensor, jax.Array, np.ndarray, lists, and primitives.
191
-
192
  Args:
193
  x: Input value that can be a tensor from various frameworks or a Python primitive
194
-
195
  Returns:
196
  np.ndarray: NumPy array representation of the input
197
  """
198
  # Already numpy
199
  if isinstance(x, np.ndarray):
200
  return x
201
-
202
  # Torch tensor or TensorFlow tensor (both have .numpy() method)
203
  if hasattr(x, 'numpy'):
204
  # For torch tensors on CUDA, need to move to CPU first
@@ -206,11 +210,11 @@ def _to_numpy(x):
206
  return x.cpu().numpy()
207
  # For TensorFlow or already on CPU
208
  return x.numpy()
209
-
210
  # JAX arrays and other array-like objects that support __array__ protocol
211
  if hasattr(x, '__array__'):
212
  return np.asarray(x)
213
-
214
  # Python primitives (list, tuple, int, float)
215
  return np.array(x)
216
 
@@ -325,23 +329,25 @@ class MossVLProcessor(ProcessorMixin):
325
  """
326
 
327
  attributes = ["image_processor", "tokenizer", "video_processor"]
 
 
328
  tokenizer_class = ("Qwen2Tokenizer", "Qwen2TokenizerFast")
329
 
330
  def __init__(
331
- self,
332
- image_processor=None,
333
- tokenizer=None,
334
- video_processor=None,
335
  chat_template=None,
336
  **kwargs
337
  ):
338
  super().__init__(image_processor, tokenizer, video_processor, chat_template=chat_template)
339
-
340
 
341
  self.image_token = "<|image_pad|>" if not hasattr(tokenizer, "image_token") else tokenizer.image_token
342
  self.video_token = "<|video_pad|>" if not hasattr(tokenizer, "video_token") else tokenizer.video_token
343
 
344
-
345
  self.image_token_id = (
346
  tokenizer.image_token_id
347
  if getattr(tokenizer, "image_token_id", None)
@@ -352,7 +358,7 @@ class MossVLProcessor(ProcessorMixin):
352
  if getattr(tokenizer, "video_token_id", None)
353
  else tokenizer.convert_tokens_to_ids(self.video_token)
354
  )
355
-
356
  self.vision_start_token = (
357
  "<|vision_start|>" if not hasattr(tokenizer, "vision_start_token") else tokenizer.vision_start_token
358
  )
@@ -366,15 +372,15 @@ class MossVLProcessor(ProcessorMixin):
366
 
367
  self.time_start_token = "<|time_start|>"
368
  self.time_end_token = "<|time_end|>"
369
-
370
  # EOS token for labels generation (assistant's response should end with this)
371
  self.im_end_token = "<|im_end|>"
372
  self.im_end_token_id = tokenizer.convert_tokens_to_ids(self.im_end_token)
373
-
374
  # Vision-related token ids (all should be masked in labels)
375
  self.vision_start_token_id = tokenizer.convert_tokens_to_ids(self.vision_start_token)
376
  self.vision_end_token_id = tokenizer.convert_tokens_to_ids(self.vision_end_token)
377
-
378
  # Token ids that should always be masked in labels (e.g. <|image_pad|>)
379
  self.mask_token_ids = {self.image_token_id}
380
 
@@ -438,7 +444,7 @@ class MossVLProcessor(ProcessorMixin):
438
  tokenizer_init_kwargs=self.tokenizer.init_kwargs,
439
  **kwargs,
440
  )
441
-
442
  # Step 1: Process images if provided
443
  if images is not None:
444
  images_kwargs = output_kwargs["images_kwargs"].copy()
@@ -468,12 +474,12 @@ class MossVLProcessor(ProcessorMixin):
468
  # Step 3: Process text with placeholder replacement
469
  if text is None or (isinstance(text, str) and len(text.strip()) == 0):
470
  raise ValueError("Text input is required for MossVL processor and cannot be empty.")
471
-
472
  if not isinstance(text, list):
473
  text = [text]
474
-
475
  text = text.copy() # Copy to avoid in-place modifications
476
-
477
  # Prepare labels_spans if provided
478
  # labels_spans format: List[List[List[int]]] - batch of samples, each sample has multiple spans
479
  # Each span is [start, end] (list, not tuple) so it can be modified in place
@@ -490,10 +496,10 @@ class MossVLProcessor(ProcessorMixin):
490
  has_images = images is not None and "pixel_values" in image_inputs
491
  has_videos = videos is not None and "pixel_values_videos" in videos_inputs
492
  needs_reorder = has_images and has_videos
493
-
494
  image_pixel_values_list = []
495
  video_pixel_values_list = []
496
-
497
  # Step 3.0: Record the order of media in original text (before replacement)
498
  # This will be used later to correctly order pixel_values and grid_thw
499
  media_order_per_sample = []
@@ -504,25 +510,25 @@ class MossVLProcessor(ProcessorMixin):
504
  while pos < len(temp_text):
505
  img_pos = temp_text.find(self.image_placeholder, pos)
506
  vid_pos = temp_text.find(self.video_placeholder, pos)
507
-
508
  if img_pos == -1 and vid_pos == -1:
509
  break
510
-
511
  if img_pos != -1 and (vid_pos == -1 or img_pos < vid_pos):
512
  media_order.append(("image", img_pos))
513
  pos = img_pos + len(self.image_placeholder)
514
  elif vid_pos != -1:
515
  media_order.append(("video", vid_pos))
516
  pos = vid_pos + len(self.video_placeholder)
517
-
518
  media_order_per_sample.append(media_order)
519
-
520
  # Step 3.0.1: Check if any sample has no media (empty samples need blank image)
521
  # If there are empty samples, we need to enter slow path to handle them properly
522
  has_empty_samples = any(len(order) == 0 for order in media_order_per_sample)
523
  if has_empty_samples:
524
  needs_reorder = True
525
-
526
  # Split pixel values for reordering if needed
527
  if needs_reorder:
528
  if has_images:
@@ -553,7 +559,7 @@ class MossVLProcessor(ProcessorMixin):
553
  video_pixel_values_list = _split_array_or_tensor(
554
  flat_video_values, split_indices
555
  )
556
-
557
  # Step 3.1: Replace placeholders (simple replacement, no expansion yet)
558
  # In MossVL, one image placeholder = one image token
559
  # One video placeholder = one video token (will be expanded later)
@@ -570,33 +576,33 @@ class MossVLProcessor(ProcessorMixin):
570
  else:
571
  text[i] = text[i].replace(self.image_placeholder, self.image_token)
572
  text[i] = text[i].replace(self.video_placeholder, self.video_token)
573
-
574
- # Step 3.2: Validate token counts
575
  n_images_in_text = [t.count(self.image_token) for t in text]
576
  n_videos_in_text = [t.count(self.video_token) for t in text]
577
-
578
  # Count placeholders in text
579
  total_images_in_text = sum(n_images_in_text)
580
  total_videos_in_text = sum(n_videos_in_text)
581
-
582
  # Count actual images and videos provided
583
  total_images_provided = len(image_grid_thw) if image_grid_thw is not None else 0
584
  total_videos_provided = len(video_grid_thw) if video_grid_thw is not None else 0
585
-
586
  # Validate image counts
587
  if total_images_in_text != total_images_provided:
588
  raise ValueError(
589
  "Number of image tokens does not match number of images provided. "
590
  f"Found {total_images_in_text} image tokens in text and {total_images_provided} images."
591
  )
592
-
593
  # Validate video counts
594
  if total_videos_in_text != total_videos_provided:
595
  raise ValueError(
596
  "Number of video tokens does not match number of videos provided. "
597
  f"Found {total_videos_in_text} video tokens in text and {total_videos_provided} videos."
598
  )
599
-
600
  # Step 3.3: Expand video tokens with timestamps
601
  # Now expand each video token to multiple tokens (one per frame) with timestamps
602
  if video_grid_thw is not None:
@@ -634,10 +640,10 @@ class MossVLProcessor(ProcessorMixin):
634
  video_tokens.append(
635
  f"{self.time_start_token}{curr_time:.1f} seconds{self.time_end_token}{self.image_token}"
636
  )
637
-
638
  # Wrap the entire video sequence with vision_start and vision_end tokens
639
  video_placeholder = f"{self.vision_start_token}{''.join(video_tokens)}{self.vision_end_token}"
640
-
641
  # Replace the video token with expanded sequence and update spans if needed
642
  if should_create_labels:
643
  text[i], labels_spans[i] = self._replace_and_update_spans(
@@ -646,22 +652,22 @@ class MossVLProcessor(ProcessorMixin):
646
  else:
647
  text[i] = text[i].replace(self.video_token, video_placeholder, 1)
648
  index += 1
649
-
650
 
651
 
652
  # Step 4: Tokenize text
653
  return_tensors = output_kwargs["text_kwargs"].pop("return_tensors", None)
654
  return_mm_token_type_ids = output_kwargs["text_kwargs"].pop("return_mm_token_type_ids", None)
655
-
656
  # Request offset_mapping if we need to create labels
657
  if should_create_labels:
658
  output_kwargs["text_kwargs"]["return_offsets_mapping"] = True
659
-
660
  text_inputs = self.tokenizer(text, **output_kwargs["text_kwargs"])
661
-
662
  # ignore check_special_mm_tokens nums in test and input ids.
663
  # self._check_special_mm_tokens(text, text_inputs, modalities=["image", "video"])
664
-
665
  # Create labels if labels_spans was provided
666
  if should_create_labels:
667
  offset_mapping = text_inputs.pop("offset_mapping")
@@ -681,49 +687,49 @@ class MossVLProcessor(ProcessorMixin):
681
  # Step 5: Concatenate pixel_values and grid_thw in sequence order
682
  # Prepare output
683
  output_data = {**text_inputs}
684
-
685
  if not needs_reorder:
686
  # Fast path: only one media type, no reordering needed
687
  final_pixel_values = []
688
  final_grid_thw = []
689
-
690
  if has_images:
691
  final_pixel_values.append(image_inputs["pixel_values"])
692
  final_grid_thw.extend(image_grid_thw)
693
-
694
  if has_videos:
695
  final_pixel_values.append(videos_inputs["pixel_values_videos"])
696
  final_grid_thw.extend(video_grid_thw)
697
-
698
  if final_pixel_values:
699
  output_data["pixel_values"] = np.concatenate(final_pixel_values, axis=0) if len(final_pixel_values) > 1 else final_pixel_values[0]
700
-
701
  if final_grid_thw:
702
  output_data["grid_thw"] = np.stack(final_grid_thw, axis=0)
703
-
704
  # Calculate media_nums_per_sample
705
  media_nums_per_sample = []
706
  for batch_idx in range(len(text)):
707
  media_order = media_order_per_sample[batch_idx]
708
  media_nums_per_sample.append(len(media_order) if len(media_order) > 0 else 1)
709
-
710
  # Don't add media_nums_per_sample to output_data yet
711
  # Will add it after BatchFeature to keep it as list
712
-
713
  else:
714
  # Slow path: both images and videos exist, need reordering
715
  final_pixel_values = []
716
  final_grid_thw = []
717
  media_nums_per_sample = []
718
-
719
  # Global indices to track position in flattened image/video arrays
720
  global_image_idx = 0
721
  global_video_idx = 0
722
-
723
  for batch_idx in range(len(text)):
724
  # Use the recorded media order from Step 3.0
725
  media_order = media_order_per_sample[batch_idx]
726
-
727
  if len(media_order) == 0:
728
  # If no media provided for this sample, add a blank image
729
  media_nums_per_sample.append(1)
@@ -731,26 +737,26 @@ class MossVLProcessor(ProcessorMixin):
731
  patch_size = getattr(self.image_processor, "patch_size", None) or 16
732
  temporal_patch_size = getattr(self.image_processor, "temporal_patch_size", None) or 1
733
  merge_size = getattr(self.image_processor, "merge_size", None) or 2
734
-
735
  factor = patch_size * merge_size
736
  side = int(np.ceil(np.sqrt(min_pixels) / factor) * factor)
737
  grid_h = side // patch_size
738
  grid_w = side // patch_size
739
  grid_t = 1
740
-
741
  # Channel = 3 (RGB)
742
  channel = 3
743
  dim = channel * temporal_patch_size * patch_size * patch_size
744
  num_patches = grid_t * grid_h * grid_w
745
-
746
  blank_pixel_values = np.zeros((num_patches, dim), dtype=np.float32)
747
  blank_grid_thw = np.array([grid_t, grid_h, grid_w], dtype=np.int64)
748
-
749
  final_pixel_values.append(blank_pixel_values)
750
  final_grid_thw.append(blank_grid_thw)
751
  else:
752
  media_nums_per_sample.append(len(media_order))
753
-
754
  # Collect media data according to the recorded order
755
  for media_type, _ in media_order:
756
  if media_type == "image" and image_grid_thw is not None:
@@ -765,18 +771,18 @@ class MossVLProcessor(ProcessorMixin):
765
  final_pixel_values.append(video_pixel_values_list[global_video_idx])
766
  final_grid_thw.append(video_grid_thw[global_video_idx])
767
  global_video_idx += 1
768
-
769
  # Concatenate/stack to unified format
770
  if final_pixel_values:
771
  output_data["pixel_values"] = _concat_array_or_tensor(
772
  final_pixel_values, axis=0
773
  )
774
-
775
  if final_grid_thw:
776
  output_data["grid_thw"] = _stack_array_or_tensor(
777
  final_grid_thw, axis=0
778
  )
779
-
780
  # Don't add media_nums_per_sample to output_data yet
781
  # Will add it after BatchFeature to keep it as list
782
 
@@ -789,18 +795,18 @@ class MossVLProcessor(ProcessorMixin):
789
  output_data.get("attention_mask", None)
790
  )
791
  output_data["cross_attention_mask"] = cross_attention_mask
792
-
793
  # Add labels to output if created
794
  if should_create_labels:
795
  output_data["labels"] = labels
796
 
797
  # BatchFeature will handle conversion to pt/tf/jax/np based on tensor_type
798
  batch_feature = BatchFeature(data=output_data, tensor_type=return_tensors)
799
-
800
  # Add media_nums_per_sample after BatchFeature to keep it as list (not tensor)
801
  if media_nums_per_sample:
802
  batch_feature["media_nums_per_sample"] = media_nums_per_sample
803
-
804
  return batch_feature
805
 
806
  def _create_cross_attention_mask(self, input_ids, grid_thw, media_nums_per_sample, attention_mask=None):
@@ -809,7 +815,7 @@ class MossVLProcessor(ProcessorMixin):
809
  Video frames are treated as individual images.
810
  Mask values: True for masked, False for visible.
811
  Causal masking: text can see images that appear at or before the text position.
812
-
813
  Args:
814
  input_ids: List of token ids
815
  grid_thw: Grid sizes for each media item
@@ -818,7 +824,7 @@ class MossVLProcessor(ProcessorMixin):
818
  """
819
  batch_size = len(input_ids)
820
  max_text_len = max(len(ids) for ids in input_ids)
821
-
822
  # Calculate total frames per sample to find max_num_frames
823
  total_frames_per_sample = []
824
  media_idx = 0
@@ -827,7 +833,7 @@ class MossVLProcessor(ProcessorMixin):
827
  if num_media == 0:
828
  total_frames_per_sample.append(0)
829
  continue
830
-
831
  sample_frames = 0
832
  for _ in range(num_media):
833
  # grid_thw is (N, 3) where first dim is t (num_frames)
@@ -839,38 +845,38 @@ class MossVLProcessor(ProcessorMixin):
839
  sample_frames += t
840
  media_idx += 1
841
  total_frames_per_sample.append(sample_frames)
842
-
843
  max_num_frames = max(total_frames_per_sample) if total_frames_per_sample else 0
844
-
845
  if max_num_frames == 0:
846
  return None
847
-
848
  # Vectorized implementation for speed
849
-
850
  # 1. Pad input_ids to create a tensor
851
  # We use -1 as pad value since token ids are positive
852
  input_ids_tensor = torch.full((batch_size, max_text_len), -1, dtype=torch.long)
853
  for b, ids in enumerate(input_ids):
854
  l = len(ids)
855
  input_ids_tensor[b, :l] = torch.tensor(ids, dtype=torch.long)
856
-
857
  # 2. Identify image tokens
858
  is_image_token = (input_ids_tensor == self.image_token_id)
859
-
860
  # 3. Compute cumulative image tokens (how many image tokens appeared up to position t)
861
  # shape: (batch_size, text_len)
862
  cum_image_tokens = is_image_token.cumsum(dim=1)
863
-
864
  # 4. Create frame indices
865
  # shape: (1, 1, max_num_frames)
866
  frame_indices = torch.arange(max_num_frames).reshape(1, 1, -1)
867
-
868
  # 5. Determine visibility based on causal relationship
869
  # Text at `t` sees frame `i` if `cum_image_tokens[t] > i`
870
  # Because if frame `i` is the (i+1)-th image token, it becomes visible when count reaches i+1
871
  # shape: (batch_size, text_len, max_num_frames)
872
  visible_mask = cum_image_tokens.unsqueeze(-1) > frame_indices
873
-
874
  # 6. Apply attention_mask if provided
875
  if attention_mask is not None:
876
  # Convert to tensor if needed
@@ -882,25 +888,25 @@ class MossVLProcessor(ProcessorMixin):
882
  for b, mask_row in enumerate(attention_mask):
883
  l = len(mask_row)
884
  attn_mask_tensor[b, :l] = torch.tensor(mask_row, dtype=torch.long)
885
-
886
  # shape: (batch_size, text_len, 1)
887
  valid_text = (attn_mask_tensor.unsqueeze(-1) == 1)
888
  visible_mask = visible_mask & valid_text
889
-
890
  # 7. Mask out frames that don't exist for a sample
891
  # shape: (batch_size, 1, 1)
892
  total_frames_tensor = torch.tensor(total_frames_per_sample).reshape(batch_size, 1, 1)
893
  # shape: (batch_size, 1, max_num_frames)
894
  valid_frames = frame_indices < total_frames_tensor
895
-
896
  visible_mask = visible_mask & valid_frames
897
-
898
  # 8. Create final mask (True for masked, False for visible)
899
  mask = ~visible_mask
900
-
901
  # 9. Add channel dimension: (batch_size, 1, text_len, max_num_frames)
902
  mask = mask.unsqueeze(1)
903
-
904
  return mask
905
 
906
  def _replace_and_update_spans(
@@ -913,14 +919,14 @@ class MossVLProcessor(ProcessorMixin):
913
  ) -> tuple:
914
  """
915
  Replace occurrences of old_str with new_str and update spans accordingly.
916
-
917
  Args:
918
  text: The text to perform replacement on
919
  old_str: String to be replaced
920
  new_str: String to replace with
921
  spans: List of [start, end] spans to update (modified in place)
922
  replace_count: Maximum number of replacements (-1 for all)
923
-
924
  Returns:
925
  Tuple of (new_text, updated_spans)
926
  """
@@ -928,14 +934,14 @@ class MossVLProcessor(ProcessorMixin):
928
  result_text = text
929
  count = 0
930
  search_start = 0
931
-
932
  while True:
933
  pos = result_text.find(old_str, search_start)
934
  if pos == -1:
935
  break
936
  if replace_count != -1 and count >= replace_count:
937
  break
938
-
939
  # Update all spans that come after this position
940
  for span in spans:
941
  if span[0] > pos:
@@ -945,12 +951,12 @@ class MossVLProcessor(ProcessorMixin):
945
  elif span[1] > pos:
946
  # Span ends after replacement point (spans the replacement)
947
  span[1] += delta
948
-
949
  # Perform the replacement
950
  result_text = result_text[:pos] + new_str + result_text[pos + len(old_str):]
951
  search_start = pos + len(new_str)
952
  count += 1
953
-
954
  return result_text, spans
955
 
956
  def _create_labels_from_spans(
@@ -963,7 +969,7 @@ class MossVLProcessor(ProcessorMixin):
963
  ) -> List[List[int]]:
964
  """
965
  Create labels from spans and offset_mapping.
966
-
967
  Args:
968
  input_ids: Tokenized input ids
969
  offset_mapping: Character offsets for each token from tokenizer (special tokens included)
@@ -972,10 +978,10 @@ class MossVLProcessor(ProcessorMixin):
972
  mask_token_ids: Set of token ids that should always be masked (set to ignore_index)
973
  in labels, regardless of whether they fall inside a span.
974
  Defaults to self.mask_token_ids if not provided.
975
-
976
  Returns:
977
  labels: List of label ids, same shape as input_ids
978
-
979
  Note:
980
  - Tokenizer's offset_mapping already includes correct offsets for special tokens in text
981
  - Only need to mask tokens inside <|vision_start|>...<|vision_end|>
@@ -984,20 +990,20 @@ class MossVLProcessor(ProcessorMixin):
984
  """
985
  if mask_token_ids is None:
986
  mask_token_ids = self.mask_token_ids
987
-
988
  batch_labels = []
989
-
990
  for batch_idx in range(len(input_ids)):
991
  ids = input_ids[batch_idx]
992
  offsets = offset_mapping[batch_idx]
993
  spans = labels_spans[batch_idx]
994
-
995
  labels = [ignore_index] * len(ids)
996
-
997
  # Process each span: find token range and set labels
998
  for span_start, span_end in spans:
999
  in_vision = False
1000
-
1001
  # Find tokens that overlap with this span
1002
  for token_idx, (token_id, (char_start, char_end)) in enumerate(zip(ids, offsets)):
1003
  # Skip tokens completely before this span
@@ -1006,7 +1012,7 @@ class MossVLProcessor(ProcessorMixin):
1006
  # Stop when tokens are completely after this span
1007
  if char_start >= span_end:
1008
  break
1009
-
1010
  # Token overlaps with span, process it
1011
  # Track vision region: <|vision_start|> ... <|vision_end|>
1012
  if token_id == self.vision_start_token_id:
@@ -1015,34 +1021,34 @@ class MossVLProcessor(ProcessorMixin):
1015
  if token_id == self.vision_end_token_id:
1016
  in_vision = False
1017
  continue
1018
-
1019
  # Skip tokens inside vision region
1020
  if in_vision:
1021
  continue
1022
-
1023
  # Always mask special tokens that should never have labels
1024
  if token_id in mask_token_ids:
1025
  continue
1026
-
1027
  # Set label for this token
1028
  labels[token_idx] = token_id
1029
-
1030
  batch_labels.append(labels)
1031
-
1032
  return batch_labels
1033
 
1034
  def _calculate_timestamps(
1035
- self,
1036
- frames_indices: Optional[Union[List[int], np.ndarray]],
1037
- total_num_frames: int,
1038
- video_fps: float,
1039
- duration: float,
1040
  merge_size: int = 1,
1041
  actual_timestamps: Optional[List[float]] = None
1042
  ):
1043
  """
1044
  Calculate timestamps for video frames.
1045
-
1046
  Args:
1047
  frames_indices: Actual frame indices extracted (if available)
1048
  total_num_frames: Total number of sampled frames
@@ -1050,25 +1056,25 @@ class MossVLProcessor(ProcessorMixin):
1050
  duration: Video duration in seconds
1051
  merge_size: Temporal merge size
1052
  actual_timestamps: Pre-calculated actual timestamps (for segments)
1053
-
1054
  Returns:
1055
  List of timestamps (one per merged temporal patch)
1056
  """
1057
  # If actual timestamps are provided (from segment), use them directly
1058
  if actual_timestamps is not None:
1059
  timestamps = list(actual_timestamps)
1060
-
1061
  # Pad timestamps to be multiple of merge_size
1062
  if len(timestamps) % merge_size != 0:
1063
  timestamps.extend([timestamps[-1]] * (merge_size - len(timestamps) % merge_size))
1064
-
1065
  # Frames are merged by merge_size, so we average the timestamps within each temporal patch
1066
  timestamps = [
1067
- (timestamps[i] + timestamps[i + merge_size - 1]) / 2
1068
  for i in range(0, len(timestamps), merge_size)
1069
  ]
1070
  return timestamps
1071
-
1072
  # Use frames_indices if available, otherwise generate uniformly sampled indices
1073
  if frames_indices is not None:
1074
  if isinstance(frames_indices, np.ndarray):
@@ -1082,24 +1088,24 @@ class MossVLProcessor(ProcessorMixin):
1082
  else:
1083
  # Uniformly sample frames across the video duration
1084
  indices = np.linspace(0, duration * video_fps - 1, total_num_frames).astype(np.int32).tolist()
1085
-
1086
  # Pad indices to be multiple of merge_size
1087
  if len(indices) % merge_size != 0:
1088
  indices.extend([indices[-1]] * (merge_size - len(indices) % merge_size))
1089
-
1090
  # Convert frame indices to timestamps
1091
  timestamps = [idx / video_fps for idx in indices]
1092
-
1093
  # Frames are merged by merge_size, so we average the timestamps within each temporal patch
1094
  timestamps = [
1095
- (timestamps[i] + timestamps[i + merge_size - 1]) / 2
1096
  for i in range(0, len(timestamps), merge_size)
1097
  ]
1098
  return timestamps
1099
 
1100
  def batch_decode(self, *args, **kwargs):
1101
  """
1102
- This method forwards all its arguments to the tokenizer's batch_decode.
1103
  Please refer to the docstring of this method for more information.
1104
  """
1105
  return self.tokenizer.batch_decode(*args, **kwargs)
@@ -1119,7 +1125,7 @@ class MossVLProcessor(ProcessorMixin):
1119
 
1120
  Args:
1121
  generated_outputs (`torch.Tensor` or `np.ndarray`):
1122
- The output of the model `generate` function. The output is expected to be a tensor
1123
  of shape `(batch_size, sequence_length)` or `(sequence_length,)`.
1124
  skip_special_tokens (`bool`, *optional*, defaults to `True`):
1125
  Whether or not to remove special tokens in the output.
@@ -1138,4 +1144,5 @@ class MossVLProcessor(ProcessorMixin):
1138
  **kwargs,
1139
  )
1140
 
1141
- __all__ = ["MossVLProcessor", "MossVLImageProcessor"]
 
 
23
  from torchvision.transforms.v2 import functional as F
24
  from PIL import Image
25
  from transformers.feature_extraction_utils import BatchFeature
26
+ from transformers.image_utils import ImageInput, SizeDict
27
+ from transformers.image_processing_utils_fast import group_images_by_shape, reorder_images
28
  from transformers.utils import TensorType
29
  from transformers.processing_utils import (
30
  ImagesKwargs,
 
35
  )
36
  from transformers.tokenization_utils_base import PreTokenizedInput, TextInput
37
  from transformers.utils import logging
38
+ from transformers.models.qwen2_vl.image_processing_qwen2_vl_fast import Qwen2VLImageProcessorFast
39
+ from transformers.models.qwen2_vl.image_processing_qwen2_vl import smart_resize
40
 
41
 
42
  logger = logging.get_logger(__name__)
43
 
44
 
45
+ class MossVLImageProcessorFast(Qwen2VLImageProcessorFast):
46
  """
47
  Custom image processor that overrides _preprocess to support multi_image_max_pixels.
48
+ Inherits from Qwen2VLImageProcessorFast.
49
  """
50
  # Multi-image batch total pixels limit (read from config)
51
  multi_image_max_pixels = None
52
+
53
 
54
  def _preprocess(
55
  self,
56
  images: list["torch.Tensor"],
57
  do_resize: bool,
58
  size: SizeDict,
59
+ interpolation: Optional["F.InterpolationMode"],
60
  do_rescale: bool,
61
  rescale_factor: float,
62
  do_normalize: bool,
 
70
  **kwargs,
71
  ):
72
  """Override _preprocess to use custom smart_resize with batch-level max_pixels.
73
+
74
  multi_image_max_pixels is treated as a batch-level total budget, proportionally allocated
75
  to each image based on its original pixel count. min_pixels remains a per-image
76
  constraint. multi_image_max_pixels can be configured separately from longest_edge.
77
  """
 
 
78
  min_pixels = size["shortest_edge"]
79
  max_pixels = size["longest_edge"] # Per-image upper limit
80
  # Use multi_image_max_pixels if configured, otherwise fall back to longest_edge
81
  multi_image_max_pixels = getattr(self, "multi_image_max_pixels", None) or max_pixels
82
+
83
  # Calculate total original pixels across all images in the batch
84
  # This is used to proportionally allocate max_pixels to each image
85
  total_original_pixels = sum(img.shape[-2] * img.shape[-1] for img in images)
 
98
  proportional_max_pixels = int(multi_image_max_pixels * proportion)
99
  else:
100
  proportional_max_pixels = multi_image_max_pixels
101
+
102
  # Ensure proportional max_pixels is within [min_pixels, max_pixels] range
103
  # min_pixels: per-image lower limit (shortest_edge)
104
  # max_pixels: per-image upper limit (longest_edge)
105
  proportional_max_pixels = max(proportional_max_pixels, min_pixels)
106
  proportional_max_pixels = min(proportional_max_pixels, max_pixels)
107
+
108
  resized_height, resized_width = smart_resize(
109
  height,
110
  width,
 
115
  stacked_images = self.resize(
116
  image=stacked_images,
117
  size=SizeDict(height=resized_height, width=resized_width),
118
+ interpolation=interpolation,
119
  )
120
  resized_images_grouped[shape] = stacked_images
121
  resized_images = reorder_images(resized_images_grouped, grouped_images_index)
122
+
123
  # Warn if multi-image batch exceeds multi_image_max_pixels due to min_pixels constraint
124
  if len(images) > 1:
125
  total_resized_pixels = sum(img.shape[-2] * img.shape[-1] for img in resized_images)
 
164
  )
165
  # Reorder dimensions to group grid and patch information for subsequent flattening.
166
  # (batch, grid_t, grid_h, grid_w, merge_h, merge_w, channel, temp_patch_size, patch_h, patch_w)
167
+ # NPU ops support at most 8-D tensors; route the 10-D permute+reshape
168
+ # through CPU there. CUDA handles 10-D natively — keep it on-device.
169
+ patches_device = patches.device
170
+ if patches_device.type == "npu":
171
+ patches = patches.cpu()
172
  patches = patches.permute(0, 1, 4, 7, 5, 8, 3, 2, 6, 9)
173
  flatten_patches = patches.reshape(
174
  batch_size,
175
  grid_t * grid_h * grid_w,
176
  channel * temporal_patch_size * patch_size * patch_size,
177
+ ).to(patches_device)
178
 
179
  processed_images_grouped[shape] = flatten_patches
180
  processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size
 
192
  """
193
  Convert various tensor types to numpy array.
194
  Supports torch.Tensor, tf.Tensor, jax.Array, np.ndarray, lists, and primitives.
195
+
196
  Args:
197
  x: Input value that can be a tensor from various frameworks or a Python primitive
198
+
199
  Returns:
200
  np.ndarray: NumPy array representation of the input
201
  """
202
  # Already numpy
203
  if isinstance(x, np.ndarray):
204
  return x
205
+
206
  # Torch tensor or TensorFlow tensor (both have .numpy() method)
207
  if hasattr(x, 'numpy'):
208
  # For torch tensors on CUDA, need to move to CPU first
 
210
  return x.cpu().numpy()
211
  # For TensorFlow or already on CPU
212
  return x.numpy()
213
+
214
  # JAX arrays and other array-like objects that support __array__ protocol
215
  if hasattr(x, '__array__'):
216
  return np.asarray(x)
217
+
218
  # Python primitives (list, tuple, int, float)
219
  return np.array(x)
220
 
 
329
  """
330
 
331
  attributes = ["image_processor", "tokenizer", "video_processor"]
332
+ image_processor_class = "AutoImageProcessor"
333
+ video_processor_class = "AutoVideoProcessor"
334
  tokenizer_class = ("Qwen2Tokenizer", "Qwen2TokenizerFast")
335
 
336
  def __init__(
337
+ self,
338
+ image_processor=None,
339
+ tokenizer=None,
340
+ video_processor=None,
341
  chat_template=None,
342
  **kwargs
343
  ):
344
  super().__init__(image_processor, tokenizer, video_processor, chat_template=chat_template)
345
+
346
 
347
  self.image_token = "<|image_pad|>" if not hasattr(tokenizer, "image_token") else tokenizer.image_token
348
  self.video_token = "<|video_pad|>" if not hasattr(tokenizer, "video_token") else tokenizer.video_token
349
 
350
+
351
  self.image_token_id = (
352
  tokenizer.image_token_id
353
  if getattr(tokenizer, "image_token_id", None)
 
358
  if getattr(tokenizer, "video_token_id", None)
359
  else tokenizer.convert_tokens_to_ids(self.video_token)
360
  )
361
+
362
  self.vision_start_token = (
363
  "<|vision_start|>" if not hasattr(tokenizer, "vision_start_token") else tokenizer.vision_start_token
364
  )
 
372
 
373
  self.time_start_token = "<|time_start|>"
374
  self.time_end_token = "<|time_end|>"
375
+
376
  # EOS token for labels generation (assistant's response should end with this)
377
  self.im_end_token = "<|im_end|>"
378
  self.im_end_token_id = tokenizer.convert_tokens_to_ids(self.im_end_token)
379
+
380
  # Vision-related token ids (all should be masked in labels)
381
  self.vision_start_token_id = tokenizer.convert_tokens_to_ids(self.vision_start_token)
382
  self.vision_end_token_id = tokenizer.convert_tokens_to_ids(self.vision_end_token)
383
+
384
  # Token ids that should always be masked in labels (e.g. <|image_pad|>)
385
  self.mask_token_ids = {self.image_token_id}
386
 
 
444
  tokenizer_init_kwargs=self.tokenizer.init_kwargs,
445
  **kwargs,
446
  )
447
+
448
  # Step 1: Process images if provided
449
  if images is not None:
450
  images_kwargs = output_kwargs["images_kwargs"].copy()
 
474
  # Step 3: Process text with placeholder replacement
475
  if text is None or (isinstance(text, str) and len(text.strip()) == 0):
476
  raise ValueError("Text input is required for MossVL processor and cannot be empty.")
477
+
478
  if not isinstance(text, list):
479
  text = [text]
480
+
481
  text = text.copy() # Copy to avoid in-place modifications
482
+
483
  # Prepare labels_spans if provided
484
  # labels_spans format: List[List[List[int]]] - batch of samples, each sample has multiple spans
485
  # Each span is [start, end] (list, not tuple) so it can be modified in place
 
496
  has_images = images is not None and "pixel_values" in image_inputs
497
  has_videos = videos is not None and "pixel_values_videos" in videos_inputs
498
  needs_reorder = has_images and has_videos
499
+
500
  image_pixel_values_list = []
501
  video_pixel_values_list = []
502
+
503
  # Step 3.0: Record the order of media in original text (before replacement)
504
  # This will be used later to correctly order pixel_values and grid_thw
505
  media_order_per_sample = []
 
510
  while pos < len(temp_text):
511
  img_pos = temp_text.find(self.image_placeholder, pos)
512
  vid_pos = temp_text.find(self.video_placeholder, pos)
513
+
514
  if img_pos == -1 and vid_pos == -1:
515
  break
516
+
517
  if img_pos != -1 and (vid_pos == -1 or img_pos < vid_pos):
518
  media_order.append(("image", img_pos))
519
  pos = img_pos + len(self.image_placeholder)
520
  elif vid_pos != -1:
521
  media_order.append(("video", vid_pos))
522
  pos = vid_pos + len(self.video_placeholder)
523
+
524
  media_order_per_sample.append(media_order)
525
+
526
  # Step 3.0.1: Check if any sample has no media (empty samples need blank image)
527
  # If there are empty samples, we need to enter slow path to handle them properly
528
  has_empty_samples = any(len(order) == 0 for order in media_order_per_sample)
529
  if has_empty_samples:
530
  needs_reorder = True
531
+
532
  # Split pixel values for reordering if needed
533
  if needs_reorder:
534
  if has_images:
 
559
  video_pixel_values_list = _split_array_or_tensor(
560
  flat_video_values, split_indices
561
  )
562
+
563
  # Step 3.1: Replace placeholders (simple replacement, no expansion yet)
564
  # In MossVL, one image placeholder = one image token
565
  # One video placeholder = one video token (will be expanded later)
 
576
  else:
577
  text[i] = text[i].replace(self.image_placeholder, self.image_token)
578
  text[i] = text[i].replace(self.video_placeholder, self.video_token)
579
+
580
+ # Step 3.2: Validate token counts
581
  n_images_in_text = [t.count(self.image_token) for t in text]
582
  n_videos_in_text = [t.count(self.video_token) for t in text]
583
+
584
  # Count placeholders in text
585
  total_images_in_text = sum(n_images_in_text)
586
  total_videos_in_text = sum(n_videos_in_text)
587
+
588
  # Count actual images and videos provided
589
  total_images_provided = len(image_grid_thw) if image_grid_thw is not None else 0
590
  total_videos_provided = len(video_grid_thw) if video_grid_thw is not None else 0
591
+
592
  # Validate image counts
593
  if total_images_in_text != total_images_provided:
594
  raise ValueError(
595
  "Number of image tokens does not match number of images provided. "
596
  f"Found {total_images_in_text} image tokens in text and {total_images_provided} images."
597
  )
598
+
599
  # Validate video counts
600
  if total_videos_in_text != total_videos_provided:
601
  raise ValueError(
602
  "Number of video tokens does not match number of videos provided. "
603
  f"Found {total_videos_in_text} video tokens in text and {total_videos_provided} videos."
604
  )
605
+
606
  # Step 3.3: Expand video tokens with timestamps
607
  # Now expand each video token to multiple tokens (one per frame) with timestamps
608
  if video_grid_thw is not None:
 
640
  video_tokens.append(
641
  f"{self.time_start_token}{curr_time:.1f} seconds{self.time_end_token}{self.image_token}"
642
  )
643
+
644
  # Wrap the entire video sequence with vision_start and vision_end tokens
645
  video_placeholder = f"{self.vision_start_token}{''.join(video_tokens)}{self.vision_end_token}"
646
+
647
  # Replace the video token with expanded sequence and update spans if needed
648
  if should_create_labels:
649
  text[i], labels_spans[i] = self._replace_and_update_spans(
 
652
  else:
653
  text[i] = text[i].replace(self.video_token, video_placeholder, 1)
654
  index += 1
655
+
656
 
657
 
658
  # Step 4: Tokenize text
659
  return_tensors = output_kwargs["text_kwargs"].pop("return_tensors", None)
660
  return_mm_token_type_ids = output_kwargs["text_kwargs"].pop("return_mm_token_type_ids", None)
661
+
662
  # Request offset_mapping if we need to create labels
663
  if should_create_labels:
664
  output_kwargs["text_kwargs"]["return_offsets_mapping"] = True
665
+
666
  text_inputs = self.tokenizer(text, **output_kwargs["text_kwargs"])
667
+
668
  # ignore check_special_mm_tokens nums in test and input ids.
669
  # self._check_special_mm_tokens(text, text_inputs, modalities=["image", "video"])
670
+
671
  # Create labels if labels_spans was provided
672
  if should_create_labels:
673
  offset_mapping = text_inputs.pop("offset_mapping")
 
687
  # Step 5: Concatenate pixel_values and grid_thw in sequence order
688
  # Prepare output
689
  output_data = {**text_inputs}
690
+
691
  if not needs_reorder:
692
  # Fast path: only one media type, no reordering needed
693
  final_pixel_values = []
694
  final_grid_thw = []
695
+
696
  if has_images:
697
  final_pixel_values.append(image_inputs["pixel_values"])
698
  final_grid_thw.extend(image_grid_thw)
699
+
700
  if has_videos:
701
  final_pixel_values.append(videos_inputs["pixel_values_videos"])
702
  final_grid_thw.extend(video_grid_thw)
703
+
704
  if final_pixel_values:
705
  output_data["pixel_values"] = np.concatenate(final_pixel_values, axis=0) if len(final_pixel_values) > 1 else final_pixel_values[0]
706
+
707
  if final_grid_thw:
708
  output_data["grid_thw"] = np.stack(final_grid_thw, axis=0)
709
+
710
  # Calculate media_nums_per_sample
711
  media_nums_per_sample = []
712
  for batch_idx in range(len(text)):
713
  media_order = media_order_per_sample[batch_idx]
714
  media_nums_per_sample.append(len(media_order) if len(media_order) > 0 else 1)
715
+
716
  # Don't add media_nums_per_sample to output_data yet
717
  # Will add it after BatchFeature to keep it as list
718
+
719
  else:
720
  # Slow path: both images and videos exist, need reordering
721
  final_pixel_values = []
722
  final_grid_thw = []
723
  media_nums_per_sample = []
724
+
725
  # Global indices to track position in flattened image/video arrays
726
  global_image_idx = 0
727
  global_video_idx = 0
728
+
729
  for batch_idx in range(len(text)):
730
  # Use the recorded media order from Step 3.0
731
  media_order = media_order_per_sample[batch_idx]
732
+
733
  if len(media_order) == 0:
734
  # If no media provided for this sample, add a blank image
735
  media_nums_per_sample.append(1)
 
737
  patch_size = getattr(self.image_processor, "patch_size", None) or 16
738
  temporal_patch_size = getattr(self.image_processor, "temporal_patch_size", None) or 1
739
  merge_size = getattr(self.image_processor, "merge_size", None) or 2
740
+
741
  factor = patch_size * merge_size
742
  side = int(np.ceil(np.sqrt(min_pixels) / factor) * factor)
743
  grid_h = side // patch_size
744
  grid_w = side // patch_size
745
  grid_t = 1
746
+
747
  # Channel = 3 (RGB)
748
  channel = 3
749
  dim = channel * temporal_patch_size * patch_size * patch_size
750
  num_patches = grid_t * grid_h * grid_w
751
+
752
  blank_pixel_values = np.zeros((num_patches, dim), dtype=np.float32)
753
  blank_grid_thw = np.array([grid_t, grid_h, grid_w], dtype=np.int64)
754
+
755
  final_pixel_values.append(blank_pixel_values)
756
  final_grid_thw.append(blank_grid_thw)
757
  else:
758
  media_nums_per_sample.append(len(media_order))
759
+
760
  # Collect media data according to the recorded order
761
  for media_type, _ in media_order:
762
  if media_type == "image" and image_grid_thw is not None:
 
771
  final_pixel_values.append(video_pixel_values_list[global_video_idx])
772
  final_grid_thw.append(video_grid_thw[global_video_idx])
773
  global_video_idx += 1
774
+
775
  # Concatenate/stack to unified format
776
  if final_pixel_values:
777
  output_data["pixel_values"] = _concat_array_or_tensor(
778
  final_pixel_values, axis=0
779
  )
780
+
781
  if final_grid_thw:
782
  output_data["grid_thw"] = _stack_array_or_tensor(
783
  final_grid_thw, axis=0
784
  )
785
+
786
  # Don't add media_nums_per_sample to output_data yet
787
  # Will add it after BatchFeature to keep it as list
788
 
 
795
  output_data.get("attention_mask", None)
796
  )
797
  output_data["cross_attention_mask"] = cross_attention_mask
798
+
799
  # Add labels to output if created
800
  if should_create_labels:
801
  output_data["labels"] = labels
802
 
803
  # BatchFeature will handle conversion to pt/tf/jax/np based on tensor_type
804
  batch_feature = BatchFeature(data=output_data, tensor_type=return_tensors)
805
+
806
  # Add media_nums_per_sample after BatchFeature to keep it as list (not tensor)
807
  if media_nums_per_sample:
808
  batch_feature["media_nums_per_sample"] = media_nums_per_sample
809
+
810
  return batch_feature
811
 
812
  def _create_cross_attention_mask(self, input_ids, grid_thw, media_nums_per_sample, attention_mask=None):
 
815
  Video frames are treated as individual images.
816
  Mask values: True for masked, False for visible.
817
  Causal masking: text can see images that appear at or before the text position.
818
+
819
  Args:
820
  input_ids: List of token ids
821
  grid_thw: Grid sizes for each media item
 
824
  """
825
  batch_size = len(input_ids)
826
  max_text_len = max(len(ids) for ids in input_ids)
827
+
828
  # Calculate total frames per sample to find max_num_frames
829
  total_frames_per_sample = []
830
  media_idx = 0
 
833
  if num_media == 0:
834
  total_frames_per_sample.append(0)
835
  continue
836
+
837
  sample_frames = 0
838
  for _ in range(num_media):
839
  # grid_thw is (N, 3) where first dim is t (num_frames)
 
845
  sample_frames += t
846
  media_idx += 1
847
  total_frames_per_sample.append(sample_frames)
848
+
849
  max_num_frames = max(total_frames_per_sample) if total_frames_per_sample else 0
850
+
851
  if max_num_frames == 0:
852
  return None
853
+
854
  # Vectorized implementation for speed
855
+
856
  # 1. Pad input_ids to create a tensor
857
  # We use -1 as pad value since token ids are positive
858
  input_ids_tensor = torch.full((batch_size, max_text_len), -1, dtype=torch.long)
859
  for b, ids in enumerate(input_ids):
860
  l = len(ids)
861
  input_ids_tensor[b, :l] = torch.tensor(ids, dtype=torch.long)
862
+
863
  # 2. Identify image tokens
864
  is_image_token = (input_ids_tensor == self.image_token_id)
865
+
866
  # 3. Compute cumulative image tokens (how many image tokens appeared up to position t)
867
  # shape: (batch_size, text_len)
868
  cum_image_tokens = is_image_token.cumsum(dim=1)
869
+
870
  # 4. Create frame indices
871
  # shape: (1, 1, max_num_frames)
872
  frame_indices = torch.arange(max_num_frames).reshape(1, 1, -1)
873
+
874
  # 5. Determine visibility based on causal relationship
875
  # Text at `t` sees frame `i` if `cum_image_tokens[t] > i`
876
  # Because if frame `i` is the (i+1)-th image token, it becomes visible when count reaches i+1
877
  # shape: (batch_size, text_len, max_num_frames)
878
  visible_mask = cum_image_tokens.unsqueeze(-1) > frame_indices
879
+
880
  # 6. Apply attention_mask if provided
881
  if attention_mask is not None:
882
  # Convert to tensor if needed
 
888
  for b, mask_row in enumerate(attention_mask):
889
  l = len(mask_row)
890
  attn_mask_tensor[b, :l] = torch.tensor(mask_row, dtype=torch.long)
891
+
892
  # shape: (batch_size, text_len, 1)
893
  valid_text = (attn_mask_tensor.unsqueeze(-1) == 1)
894
  visible_mask = visible_mask & valid_text
895
+
896
  # 7. Mask out frames that don't exist for a sample
897
  # shape: (batch_size, 1, 1)
898
  total_frames_tensor = torch.tensor(total_frames_per_sample).reshape(batch_size, 1, 1)
899
  # shape: (batch_size, 1, max_num_frames)
900
  valid_frames = frame_indices < total_frames_tensor
901
+
902
  visible_mask = visible_mask & valid_frames
903
+
904
  # 8. Create final mask (True for masked, False for visible)
905
  mask = ~visible_mask
906
+
907
  # 9. Add channel dimension: (batch_size, 1, text_len, max_num_frames)
908
  mask = mask.unsqueeze(1)
909
+
910
  return mask
911
 
912
  def _replace_and_update_spans(
 
919
  ) -> tuple:
920
  """
921
  Replace occurrences of old_str with new_str and update spans accordingly.
922
+
923
  Args:
924
  text: The text to perform replacement on
925
  old_str: String to be replaced
926
  new_str: String to replace with
927
  spans: List of [start, end] spans to update (modified in place)
928
  replace_count: Maximum number of replacements (-1 for all)
929
+
930
  Returns:
931
  Tuple of (new_text, updated_spans)
932
  """
 
934
  result_text = text
935
  count = 0
936
  search_start = 0
937
+
938
  while True:
939
  pos = result_text.find(old_str, search_start)
940
  if pos == -1:
941
  break
942
  if replace_count != -1 and count >= replace_count:
943
  break
944
+
945
  # Update all spans that come after this position
946
  for span in spans:
947
  if span[0] > pos:
 
951
  elif span[1] > pos:
952
  # Span ends after replacement point (spans the replacement)
953
  span[1] += delta
954
+
955
  # Perform the replacement
956
  result_text = result_text[:pos] + new_str + result_text[pos + len(old_str):]
957
  search_start = pos + len(new_str)
958
  count += 1
959
+
960
  return result_text, spans
961
 
962
  def _create_labels_from_spans(
 
969
  ) -> List[List[int]]:
970
  """
971
  Create labels from spans and offset_mapping.
972
+
973
  Args:
974
  input_ids: Tokenized input ids
975
  offset_mapping: Character offsets for each token from tokenizer (special tokens included)
 
978
  mask_token_ids: Set of token ids that should always be masked (set to ignore_index)
979
  in labels, regardless of whether they fall inside a span.
980
  Defaults to self.mask_token_ids if not provided.
981
+
982
  Returns:
983
  labels: List of label ids, same shape as input_ids
984
+
985
  Note:
986
  - Tokenizer's offset_mapping already includes correct offsets for special tokens in text
987
  - Only need to mask tokens inside <|vision_start|>...<|vision_end|>
 
990
  """
991
  if mask_token_ids is None:
992
  mask_token_ids = self.mask_token_ids
993
+
994
  batch_labels = []
995
+
996
  for batch_idx in range(len(input_ids)):
997
  ids = input_ids[batch_idx]
998
  offsets = offset_mapping[batch_idx]
999
  spans = labels_spans[batch_idx]
1000
+
1001
  labels = [ignore_index] * len(ids)
1002
+
1003
  # Process each span: find token range and set labels
1004
  for span_start, span_end in spans:
1005
  in_vision = False
1006
+
1007
  # Find tokens that overlap with this span
1008
  for token_idx, (token_id, (char_start, char_end)) in enumerate(zip(ids, offsets)):
1009
  # Skip tokens completely before this span
 
1012
  # Stop when tokens are completely after this span
1013
  if char_start >= span_end:
1014
  break
1015
+
1016
  # Token overlaps with span, process it
1017
  # Track vision region: <|vision_start|> ... <|vision_end|>
1018
  if token_id == self.vision_start_token_id:
 
1021
  if token_id == self.vision_end_token_id:
1022
  in_vision = False
1023
  continue
1024
+
1025
  # Skip tokens inside vision region
1026
  if in_vision:
1027
  continue
1028
+
1029
  # Always mask special tokens that should never have labels
1030
  if token_id in mask_token_ids:
1031
  continue
1032
+
1033
  # Set label for this token
1034
  labels[token_idx] = token_id
1035
+
1036
  batch_labels.append(labels)
1037
+
1038
  return batch_labels
1039
 
1040
  def _calculate_timestamps(
1041
+ self,
1042
+ frames_indices: Optional[Union[List[int], np.ndarray]],
1043
+ total_num_frames: int,
1044
+ video_fps: float,
1045
+ duration: float,
1046
  merge_size: int = 1,
1047
  actual_timestamps: Optional[List[float]] = None
1048
  ):
1049
  """
1050
  Calculate timestamps for video frames.
1051
+
1052
  Args:
1053
  frames_indices: Actual frame indices extracted (if available)
1054
  total_num_frames: Total number of sampled frames
 
1056
  duration: Video duration in seconds
1057
  merge_size: Temporal merge size
1058
  actual_timestamps: Pre-calculated actual timestamps (for segments)
1059
+
1060
  Returns:
1061
  List of timestamps (one per merged temporal patch)
1062
  """
1063
  # If actual timestamps are provided (from segment), use them directly
1064
  if actual_timestamps is not None:
1065
  timestamps = list(actual_timestamps)
1066
+
1067
  # Pad timestamps to be multiple of merge_size
1068
  if len(timestamps) % merge_size != 0:
1069
  timestamps.extend([timestamps[-1]] * (merge_size - len(timestamps) % merge_size))
1070
+
1071
  # Frames are merged by merge_size, so we average the timestamps within each temporal patch
1072
  timestamps = [
1073
+ (timestamps[i] + timestamps[i + merge_size - 1]) / 2
1074
  for i in range(0, len(timestamps), merge_size)
1075
  ]
1076
  return timestamps
1077
+
1078
  # Use frames_indices if available, otherwise generate uniformly sampled indices
1079
  if frames_indices is not None:
1080
  if isinstance(frames_indices, np.ndarray):
 
1088
  else:
1089
  # Uniformly sample frames across the video duration
1090
  indices = np.linspace(0, duration * video_fps - 1, total_num_frames).astype(np.int32).tolist()
1091
+
1092
  # Pad indices to be multiple of merge_size
1093
  if len(indices) % merge_size != 0:
1094
  indices.extend([indices[-1]] * (merge_size - len(indices) % merge_size))
1095
+
1096
  # Convert frame indices to timestamps
1097
  timestamps = [idx / video_fps for idx in indices]
1098
+
1099
  # Frames are merged by merge_size, so we average the timestamps within each temporal patch
1100
  timestamps = [
1101
+ (timestamps[i] + timestamps[i + merge_size - 1]) / 2
1102
  for i in range(0, len(timestamps), merge_size)
1103
  ]
1104
  return timestamps
1105
 
1106
  def batch_decode(self, *args, **kwargs):
1107
  """
1108
+ This method forwards all its arguments to the tokenizer's batch_decode.
1109
  Please refer to the docstring of this method for more information.
1110
  """
1111
  return self.tokenizer.batch_decode(*args, **kwargs)
 
1125
 
1126
  Args:
1127
  generated_outputs (`torch.Tensor` or `np.ndarray`):
1128
+ The output of the model `generate` function. The output is expected to be a tensor
1129
  of shape `(batch_size, sequence_length)` or `(sequence_length,)`.
1130
  skip_special_tokens (`bool`, *optional*, defaults to `True`):
1131
  Whether or not to remove special tokens in the output.
 
1144
  **kwargs,
1145
  )
1146
 
1147
+
1148
+ __all__ = ["MossVLProcessor", "MossVLImageProcessorFast"]
video_preprocessor_config.json CHANGED
@@ -1,30 +1,30 @@
1
  {
2
- "auto_map": {
3
- "AutoProcessor": "processing_moss_vl.MossVLProcessor",
4
- "AutoVideoProcessor": "video_processing_moss_vl.MossVLVideoProcessor"
5
- },
6
- "size": {
7
- "longest_edge": 16777216,
8
- "shortest_edge": 4096
9
- },
10
- "video_max_pixels": 65536000,
11
- "patch_size": 16,
12
- "temporal_patch_size": 1,
13
- "merge_size": 2,
14
- "video_fps": 1.0,
15
- "min_frames": 1,
16
- "max_frames": 256,
17
- "num_extract_threads": 4,
18
- "image_mean": [
19
- 0.5,
20
- 0.5,
21
- 0.5
22
- ],
23
- "image_std": [
24
- 0.5,
25
- 0.5,
26
- 0.5
27
- ],
28
- "processor_class": "MossVLProcessor",
29
- "video_processor_type": "MossVLVideoProcessor"
30
  }
 
1
  {
2
+ "auto_map": {
3
+ "AutoProcessor": "processing_moss_vl.MossVLProcessor",
4
+ "AutoVideoProcessor": "video_processing_moss_vl.MossVLVideoProcessor"
5
+ },
6
+ "size": {
7
+ "longest_edge": 16777216,
8
+ "shortest_edge": 4096
9
+ },
10
+ "video_max_pixels": 65536000,
11
+ "patch_size": 16,
12
+ "temporal_patch_size": 1,
13
+ "merge_size": 2,
14
+ "video_fps": 1.0,
15
+ "min_frames": 1,
16
+ "max_frames": 256,
17
+ "num_extract_threads": 4,
18
+ "image_mean": [
19
+ 0.5,
20
+ 0.5,
21
+ 0.5
22
+ ],
23
+ "image_std": [
24
+ 0.5,
25
+ 0.5,
26
+ 0.5
27
+ ],
28
+ "processor_class": "MossVLProcessor",
29
+ "video_processor_type": "MossVLVideoProcessor"
30
  }
video_processing_moss_vl.py CHANGED
@@ -14,13 +14,17 @@
14
  # limitations under the License.
15
  """video processor class for Moss-VL."""
16
 
 
 
17
  import json
18
  import logging as system_logging
19
  import math
20
  import os
21
  import re
22
  import subprocess
 
23
  import traceback
 
24
  from typing import Any, Dict, List, Optional, Union
25
 
26
  import numpy as np
@@ -38,6 +42,8 @@ from transformers.video_utils import VideoMetadata, group_videos_by_shape, reord
38
 
39
  logger = logging.get_logger(__name__)
40
 
 
 
41
 
42
  # -----------------------------------------------------------------------------
43
  # Torchcodec video frame extraction utilities
@@ -46,10 +52,10 @@ logger = logging.get_logger(__name__)
46
  def check_video_for_extra_streams_and_errors(video_path: str) -> dict:
47
  """
48
  Check if video file has abnormal streams or errors reported by ffprobe.
49
-
50
  Args:
51
  video_path: Path to the video file.
52
-
53
  Returns:
54
  A dictionary containing:
55
  - 'has_extra_streams': bool, whether there are streams other than video and audio.
@@ -67,7 +73,7 @@ def check_video_for_extra_streams_and_errors(video_path: str) -> dict:
67
  'stream_details': [],
68
  'num_streams': 0
69
  }
70
-
71
  command = [
72
  "ffprobe",
73
  "-v", "error",
@@ -76,7 +82,7 @@ def check_video_for_extra_streams_and_errors(video_path: str) -> dict:
76
  "-of", "json",
77
  video_path
78
  ]
79
-
80
  try:
81
  process = subprocess.run(
82
  command,
@@ -85,12 +91,12 @@ def check_video_for_extra_streams_and_errors(video_path: str) -> dict:
85
  check=False
86
  )
87
  result['ffprobe_successful'] = (process.returncode == 0)
88
-
89
  if process.stderr:
90
  result['ffprobe_output_error'] = process.stderr
91
  unsupported_codec_pattern = re.compile(r"Unsupported codec with id \d+ for input stream \d+")
92
  result['unsupported_codec_errors'] = unsupported_codec_pattern.findall(process.stderr)
93
-
94
  if process.stdout:
95
  ffprobe_data = json.loads(process.stdout)
96
  if 'streams' in ffprobe_data:
@@ -101,7 +107,7 @@ def check_video_for_extra_streams_and_errors(video_path: str) -> dict:
101
  result['stream_details'].append({'index': stream_index, 'codec_type': stream_type})
102
  if stream_type not in ['video', 'audio']:
103
  result['has_extra_streams'] = True
104
-
105
  if 'format' in ffprobe_data and 'nb_streams' in ffprobe_data['format']:
106
  if result['num_streams'] == 0:
107
  result['num_streams'] = ffprobe_data['format']['nb_streams']
@@ -119,18 +125,18 @@ def check_video_for_extra_streams_and_errors(video_path: str) -> dict:
119
  except Exception as e:
120
  result['ffprobe_output_error'] = f"An unexpected error occurred: {e}"
121
  result['ffprobe_successful'] = False
122
-
123
  return result
124
 
125
 
126
  def remove_video_extra_stream_ffmpeg(input_video: str, output_video: str) -> bool:
127
  """
128
  Remove extra streams from video using ffmpeg.
129
-
130
  Args:
131
  input_video: Path to input video.
132
  output_video: Path to output video.
133
-
134
  Returns:
135
  bool: True if successful, False otherwise.
136
  """
@@ -146,7 +152,7 @@ def remove_video_extra_stream_ffmpeg(input_video: str, output_video: str) -> boo
146
  "-movflags", "faststart",
147
  output_video,
148
  ]
149
-
150
  try:
151
  subprocess.run(command_list, shell=False, check=True, capture_output=True)
152
  return True
@@ -162,13 +168,27 @@ def remove_video_extra_stream_ffmpeg(input_video: str, output_video: str) -> boo
162
  return False
163
 
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  def clean_video_streams(video_path: str) -> str:
166
  """
167
  Clean video streams if extra streams are detected.
168
-
169
  Args:
170
  video_path: Path to the video file.
171
-
172
  Returns:
173
  str: Path to cleaned video (or original if no cleaning needed).
174
  """
@@ -179,26 +199,86 @@ def clean_video_streams(video_path: str) -> str:
179
  file_name_without_ext, file_ext = os.path.splitext(base_name)
180
  new_base_name = f"{file_name_without_ext}_fix{file_ext}"
181
  video_path_output = os.path.join(output_folder, new_base_name)
182
-
183
- process_flag = remove_video_extra_stream_ffmpeg(video_path, video_path_output)
184
- if not process_flag:
185
- logger.warning("Failed to remove extra streams with ffmpeg")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  return video_path
187
- return video_path_output
188
  return video_path
189
 
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  def split_indices(indices: List[Union[int, float]], num_chunks: int) -> List[List[Union[int, float]]]:
192
  """
193
  Split an index list into roughly equal chunks.
194
-
195
  Args:
196
  indices: List of indices to split.
197
  num_chunks: Number of chunks to create.
198
-
199
  Returns:
200
  List of index chunks.
201
  """
 
 
 
 
 
 
202
  chunk_size = len(indices) // num_chunks
203
  chunks = []
204
  for i in range(num_chunks - 1):
@@ -210,12 +290,12 @@ def split_indices(indices: List[Union[int, float]], num_chunks: int) -> List[Lis
210
  def decode_sequentially(indices: List[int], video_path: str, ffmpeg_threads: int = 0):
211
  """
212
  Decode frames sequentially from a video.
213
-
214
  Args:
215
  indices: List of frame indices to decode.
216
  video_path: Path to the video file.
217
  ffmpeg_threads: Number of ffmpeg threads to use.
218
-
219
  Returns:
220
  FrameBatch from torchcodec.
221
  """
@@ -229,20 +309,20 @@ def decode_sequentially(indices: List[int], video_path: str, ffmpeg_threads: int
229
  def decode_with_multithreading(indices: List[int], num_threads: int, video_path: str) -> dict:
230
  """
231
  Decode frames using multithreading with joblib.
232
-
233
  Args:
234
  indices: List of frame indices to decode.
235
  num_threads: Number of threads to use.
236
  video_path: Path to the video file.
237
-
238
  Returns:
239
  dict: Contains 'data', 'duration_seconds', 'pts_seconds' tensors.
240
  """
241
  chunks = split_indices(indices, num_chunks=num_threads)
242
- results = Parallel(n_jobs=num_threads, prefer="threads", verbose=0)(
243
  delayed(decode_sequentially)(chunk, video_path) for chunk in chunks
244
  )
245
-
246
  return {
247
  "data": torch.cat([frame_batch.data for frame_batch in results], dim=0),
248
  "duration_seconds": torch.cat([frame_batch.duration_seconds for frame_batch in results], dim=0),
@@ -253,35 +333,19 @@ def decode_with_multithreading(indices: List[int], num_threads: int, video_path:
253
  def decode_sequentially_timestamp(timestamp_list: List[float], video_path: str, ffmpeg_threads: int = 0):
254
  """
255
  Decode frames sequentially from a video based on timestamps.
256
-
257
  Args:
258
  timestamp_list: List of timestamps (in seconds) to decode.
259
  video_path: Path to the video file.
260
  ffmpeg_threads: Number of ffmpeg threads to use.
261
-
262
  Returns:
263
  FrameBatch from torchcodec.
264
  """
265
  decoder = VideoDecoder(video_path, num_ffmpeg_threads=ffmpeg_threads)
266
  try:
267
  metadata = decoder.metadata
268
-
269
- min_pts = metadata.begin_stream_seconds_from_content
270
- if min_pts is None:
271
- min_pts = 0.0
272
-
273
- max_pts = None
274
- if metadata.num_frames_from_content and metadata.average_fps:
275
- max_pts = (metadata.num_frames_from_content - 1) / metadata.average_fps + min_pts
276
- elif metadata.end_stream_seconds_from_content is not None:
277
- max_pts = metadata.end_stream_seconds_from_content
278
- else:
279
- max_pts = metadata.duration_seconds
280
-
281
- if max_pts is not None and max_pts > 0:
282
- timestamp_list = [max(min_pts, min(t, max_pts)) for t in timestamp_list]
283
- elif min_pts > 0:
284
- timestamp_list = [max(min_pts, t) for t in timestamp_list]
285
 
286
  return decoder.get_frames_played_at(timestamp_list)
287
  finally:
@@ -291,29 +355,29 @@ def decode_sequentially_timestamp(timestamp_list: List[float], video_path: str,
291
  def timestamp_decode_with_multithreading(timestamp_list: List[float], num_threads: int, video_path: str) -> dict:
292
  """
293
  Decode frames using multithreading based on timestamps.
294
-
295
  Args:
296
  timestamp_list: List of timestamps (in seconds) to decode.
297
  num_threads: Number of threads to use.
298
  video_path: Path to the video file.
299
-
300
  Returns:
301
  dict: Contains 'data', 'duration_seconds', 'pts_seconds' tensors.
302
  """
303
  chunks = split_indices(timestamp_list, num_chunks=num_threads)
304
- results = Parallel(n_jobs=num_threads, prefer="threads", verbose=0)(
305
  delayed(decode_sequentially_timestamp)(chunk, video_path) for chunk in chunks
306
  )
307
-
308
  # Concatenate results from all threads
309
  data_list = [frame_batch.data for frame_batch in results]
310
  duration_list = [frame_batch.duration_seconds for frame_batch in results]
311
  pts_list = [frame_batch.pts_seconds for frame_batch in results]
312
-
313
  if not data_list:
314
  logger.warning("No frames were successfully decoded.")
315
  return {"data": torch.empty(0), "duration_seconds": torch.empty(0), "pts_seconds": torch.empty(0)}
316
-
317
  return {
318
  "data": torch.cat(data_list, dim=0),
319
  "duration_seconds": torch.cat(duration_list, dim=0),
@@ -329,42 +393,42 @@ def extract_frames_with_torchcodec(
329
  ) -> Optional[dict]:
330
  """
331
  Extract frames from video using torchcodec with multithreading.
332
-
333
  Args:
334
  video_path: Path to the video file.
335
  sample_frames_count: Number of frames to sample.
336
  num_threads: Number of threads to use for extraction.
337
  sampling_method: Sampling method, either "index" (uniform frame indices) or "timestamp" (uniform timestamps).
338
-
339
  Returns:
340
  dict: Contains 'data' (N, C, H, W), 'duration_seconds' (N,), 'pts_seconds' (N,) tensors.
341
  Returns None if extraction fails.
342
  """
343
  try:
344
- video_path = clean_video_streams(video_path)
345
  decoder = VideoDecoder(video_path, num_ffmpeg_threads=0)
346
  metadata = decoder.metadata
347
 
348
 
349
  total_frames_in_video = metadata.num_frames_from_content
350
-
351
  effective_sample_count = min(sample_frames_count, total_frames_in_video)
352
  if effective_sample_count == 0:
353
  logger.error("Cannot extract frames: video has 0 frames or specified frame count is 0")
354
  return None
355
-
356
  # Generate uniform frame indices
357
  frame_indices = np.linspace(0, total_frames_in_video - 1, effective_sample_count).astype(np.int32)
358
  # Ensure indices are valid and remove duplicates
359
  frame_indices = np.unique(np.clip(frame_indices, 0, total_frames_in_video - 1))
360
-
361
  result = decode_with_multithreading(frame_indices.tolist(), num_threads=num_threads, video_path=video_path)
362
  # Add frame_indices to the result for later use
363
  result["frame_indices"] = frame_indices
364
  return result
365
 
366
 
367
-
368
  except Exception:
369
  traceback.print_exc()
370
  return None
@@ -500,9 +564,28 @@ class MossVLVideoProcessor(BaseVideoProcessor):
500
  return video_input["video_path"]
501
  return video_input
502
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  def _get_video_duration_seconds(self, video_input: Union[str, Dict[str, Any]]) -> float:
504
  """Get video duration in seconds for weighted frame-budget allocation."""
505
- video_path = clean_video_streams(self._get_video_path_from_input(video_input))
506
  decoder = VideoDecoder(video_path, num_ffmpeg_threads=0)
507
  try:
508
  metadata = decoder.metadata
@@ -587,13 +670,13 @@ class MossVLVideoProcessor(BaseVideoProcessor):
587
  ) -> int:
588
  """
589
  Calculate the number of frames to sample using fps-based logic with min/max constraints.
590
-
591
  Logic:
592
  1. Calculate target_frames based on fps and video duration
593
  2. Apply min_frames and max_frames constraints
594
  3. Apply max_allowed_frames protection (rough cap from total video_max_pixels budget)
595
  4. Return the number of frames to sample
596
-
597
  Args:
598
  metadata (`VideoMetadata`):
599
  Metadata of the video containing information about total duration, fps and total number of frames.
@@ -613,11 +696,11 @@ class MossVLVideoProcessor(BaseVideoProcessor):
613
  raise ValueError("`num_frames` and `fps` are mutually exclusive arguments, please use only one!")
614
 
615
  total_num_frames = metadata.total_num_frames
616
-
617
  # Use provided min/max or fall back to defaults
618
  effective_min_frames = min_frames if min_frames is not None else self.min_frames
619
  effective_max_frames = max_frames if max_frames is not None else self.max_frames
620
-
621
  # Rough per-video frame cap derived from the multi-video total budget
622
  # (exact allocation happens later in _preprocess via weighted distribution)
623
  per_frame_min_pixels = self.size.get("shortest_edge", None) if self.size else None
@@ -625,7 +708,7 @@ class MossVLVideoProcessor(BaseVideoProcessor):
625
  if per_frame_min_pixels is not None and video_max_pixels is not None and per_frame_min_pixels > 0:
626
  max_allowed_frames = video_max_pixels // per_frame_min_pixels
627
  effective_max_frames = min(effective_max_frames, max_allowed_frames)
628
-
629
  # Get video duration
630
  if hasattr(metadata, 'duration') and metadata.duration is not None:
631
  duration = metadata.duration
@@ -643,12 +726,12 @@ class MossVLVideoProcessor(BaseVideoProcessor):
643
 
644
  # Use provided fps or default
645
  target_fps = fps if fps is not None else self.video_fps
646
-
647
  # Calculate target frames based on fps and duration
648
  if num_frames is None:
649
  # Calculate how many frames we should sample based on target fps
650
  target_total_frames = int(math.ceil(duration * target_fps - 1e-6))
651
-
652
  # Apply min/max constraints
653
  sample_frames = max(target_total_frames, effective_min_frames)
654
  sample_frames = min(sample_frames, effective_max_frames, total_num_frames)
@@ -659,6 +742,134 @@ class MossVLVideoProcessor(BaseVideoProcessor):
659
  return sample_frames
660
 
661
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
662
  def _fetch_video_segment(
663
  self,
664
  video_path: str,
@@ -669,14 +880,14 @@ class MossVLVideoProcessor(BaseVideoProcessor):
669
  ):
670
  """
671
  Fetch video frames for a specific segment.
672
-
673
  Args:
674
  video_path: Path to the video file
675
  segment: [start, end] for a segment (left-closed, right-open) or [time] for a single frame
676
  min_frames: Minimum frames for this segment (weighted). Defaults to self.min_frames. Must be >= 1.
677
  max_frames: Maximum frames for this segment (weighted). Defaults to self.max_frames. Must be >= 1.
678
  video_fps: Target frames per second for video sampling. If None, uses self.video_fps.
679
-
680
  Returns:
681
  Tuple of (video_tensor, video_metadata)
682
  """
@@ -685,50 +896,51 @@ class MossVLVideoProcessor(BaseVideoProcessor):
685
  max_frames = max(1, max_frames if max_frames is not None else self.max_frames)
686
  # Use provided video_fps or fall back to self.video_fps
687
  target_video_fps = video_fps if video_fps is not None else self.video_fps
688
-
689
- video_path = clean_video_streams(video_path)
690
  decoder = VideoDecoder(video_path, num_ffmpeg_threads=0)
691
  try:
692
  torchcodec_metadata = decoder.metadata
693
-
694
  video_fps = torchcodec_metadata.average_fps
695
-
696
  # Calculate duration
697
  duration = None
698
  if torchcodec_metadata.end_stream_seconds_from_content is not None and torchcodec_metadata.begin_stream_seconds_from_content is not None:
699
  duration = torchcodec_metadata.end_stream_seconds_from_content - torchcodec_metadata.begin_stream_seconds_from_content
700
  if duration is None or duration <= 0:
701
  duration = torchcodec_metadata.duration_seconds
702
-
703
  if len(segment) == 1:
704
  # Single frame at specified time
705
- timestamp = segment[0]
706
- frame_batch = decoder.get_frames_played_at([timestamp])
707
  video_tensor = frame_batch.data
708
- actual_timestamps = [timestamp]
709
  sample_count = 1
710
  else:
711
  # Segment [start, end) - left-closed, right-open interval
712
  start_time, end_time = segment
713
  segment_duration = end_time - start_time
714
-
715
  # Calculate number of frames to sample for this segment
716
  target_frames = int(math.ceil(segment_duration * target_video_fps))
717
  target_frames = max(target_frames, min_frames)
718
  target_frames = min(target_frames, max_frames)
719
-
720
  # Generate timestamps for uniform sampling within segment
721
  if target_frames == 1:
722
  actual_timestamps = [start_time] # Use start_time for single frame
723
  else:
724
  # Sample uniformly within [start, end), endpoint=False for left-closed right-open
725
  actual_timestamps = np.linspace(start_time, end_time, target_frames, endpoint=False).tolist()
726
-
 
 
727
  # Use multithreading for extraction
728
  result = timestamp_decode_with_multithreading(actual_timestamps, self.num_extract_threads, video_path)
729
  video_tensor = result["data"]
730
  sample_count = len(actual_timestamps)
731
-
732
  # Create VideoMetadata
733
  video_metadata = VideoMetadata(
734
  total_num_frames=sample_count,
@@ -739,39 +951,40 @@ class MossVLVideoProcessor(BaseVideoProcessor):
739
  width=torchcodec_metadata.width,
740
  frames_indices=None
741
  )
742
-
743
  # Store actual timestamps as a custom attribute for _calculate_timestamps to use
744
  video_metadata.actual_timestamps = actual_timestamps
745
-
746
  return video_tensor, video_metadata
747
  finally:
748
  del decoder
749
 
750
  def fetch_videos(
751
- self,
752
- video_url_or_urls: Union[str, Dict[str, Any], List[Union[str, Dict[str, Any]]]],
753
- sample_indices_fn=None,
754
  video_fps: Optional[float] = None,
755
  min_frames: Optional[int] = None,
756
  max_frames: Optional[int] = None,
757
  ):
758
  """
759
  Override fetch_videos to use torchcodec for frame extraction.
760
-
761
  This method uses torchcodec with multithreading for efficient frame extraction.
762
  Frame count is calculated by the calculate_num_frames method
763
  (fps-based with min/max constraints).
764
-
765
  Args:
766
  video_url_or_urls: Can be one of:
767
  - str: Single video path
768
- - Dict: Video with segments {"video_path": str, "segments": List[List[float]]}
769
- - List[Union[str, Dict]]: List of video paths or segment dicts
 
770
  sample_indices_fn: (Not used) Kept for compatibility with base class signature.
771
  video_fps: Target frames per second for video sampling. If None, uses self.video_fps.
772
  min_frames: Minimum number of frames to sample. If None, uses self.min_frames.
773
  max_frames: Maximum number of frames to sample. If None, uses self.max_frames.
774
-
775
  Returns:
776
  Tuple of (videos, metadata) where videos are torch.Tensors and metadata are VideoMetadata objects.
777
  """
@@ -791,9 +1004,11 @@ class MossVLVideoProcessor(BaseVideoProcessor):
791
  effective_max_frames,
792
  )
793
  for x, allocated_max_frames in zip(video_url_or_urls, per_video_max_frames):
 
 
794
  result = self.fetch_videos(
795
- x,
796
- video_fps=effective_video_fps,
797
  min_frames=effective_min_frames,
798
  max_frames=allocated_max_frames,
799
  )
@@ -805,57 +1020,31 @@ class MossVLVideoProcessor(BaseVideoProcessor):
805
  all_videos.append(result[0])
806
  all_metadata.append(result[1])
807
  return all_videos, all_metadata
808
-
809
- # Handle dict with segments - returns lists (one per segment)
 
810
  if isinstance(video_url_or_urls, dict):
811
  video_path = video_url_or_urls["video_path"]
812
- segments = video_url_or_urls["segments"]
813
-
814
- # Calculate total duration of all time-range segments (len == 2) for weighted min/max frames
815
- # Single-frame segments (len == 1) are excluded from weighting
816
- segment_durations = []
817
- for seg in segments:
818
- if len(seg) == 2:
819
- segment_durations.append(seg[1] - seg[0])
820
- else:
821
- segment_durations.append(None) # Single frame, no weighting
822
-
823
- total_segment_duration = sum(d for d in segment_durations if d is not None)
824
-
825
- videos = []
826
- metadata = []
827
- for i, segment in enumerate(segments):
828
- if len(segment) == 1:
829
- # Single frame - no weighted min/max, just extract directly
830
- video, meta = self._fetch_video_segment(video_path, segment, video_fps=effective_video_fps)
831
- else:
832
- # Time-range segment - apply weighted min/max frames
833
- if total_segment_duration > 0:
834
- weight = segment_durations[i] / total_segment_duration
835
- else:
836
- # Fallback: equal weight among time-range segments
837
- num_range_segments = sum(1 for d in segment_durations if d is not None)
838
- weight = 1.0 / num_range_segments if num_range_segments > 0 else 1.0
839
-
840
- # Calculate weighted min/max frames (ensure >= 1)
841
- weighted_min_frames = max(1, int(round(effective_min_frames * weight)))
842
- weighted_max_frames = max(1, int(round(effective_max_frames * weight)))
843
-
844
- video, meta = self._fetch_video_segment(
845
- video_path, segment,
846
- min_frames=weighted_min_frames,
847
- max_frames=weighted_max_frames,
848
- video_fps=effective_video_fps,
849
- )
850
- videos.append(video)
851
- metadata.append(meta)
852
- return videos, metadata
853
-
854
  # Single video path
855
  video_path = video_url_or_urls
856
-
857
  # Clean video streams first (remove extra streams if needed)
858
- video_path = clean_video_streams(video_path)
859
 
860
  decoder = None
861
  try:
@@ -866,13 +1055,13 @@ class MossVLVideoProcessor(BaseVideoProcessor):
866
  duration = None
867
  if torchcodec_metadata.end_stream_seconds_from_content is not None and torchcodec_metadata.begin_stream_seconds_from_content is not None:
868
  duration = torchcodec_metadata.end_stream_seconds_from_content - torchcodec_metadata.begin_stream_seconds_from_content
869
-
870
  if duration is None or duration <= 0:
871
  duration = torchcodec_metadata.duration_seconds
872
-
873
  # Use num_frames_from_content for accurate frame count (consistent with extraction)
874
  total_frames_in_video = torchcodec_metadata.num_frames_from_content
875
-
876
  # Create VideoMetadata object for sample_frames method
877
  temp_metadata = VideoMetadata(
878
  total_num_frames=total_frames_in_video,
@@ -883,31 +1072,31 @@ class MossVLVideoProcessor(BaseVideoProcessor):
883
  width=torchcodec_metadata.width,
884
  frames_indices=None
885
  )
886
-
887
  # Use calculate_num_frames method to get the number of frames to sample
888
  sample_frames_count = self.calculate_num_frames(
889
- temp_metadata,
890
  fps=effective_video_fps,
891
  min_frames=effective_min_frames,
892
  max_frames=effective_max_frames,
893
  )
894
-
895
  # Ensure sample count is valid
896
  effective_sample_count = min(sample_frames_count, total_frames_in_video)
897
  if effective_sample_count == 0:
898
  raise ValueError(f"Cannot extract frames: video has 0 frames or specified frame count is 0")
899
-
900
  # Generate uniform frame indices
901
  frame_indices = np.linspace(0, total_frames_in_video - 1, effective_sample_count).astype(np.int32)
902
  # Ensure indices are valid and remove duplicates
903
  frame_indices = np.unique(np.clip(frame_indices, 0, total_frames_in_video - 1))
904
-
905
  # Extract frames using multithreading (decoder is created inside each thread for thread safety)
906
  result = decode_with_multithreading(frame_indices.tolist(), num_threads=self.num_extract_threads, video_path=video_path)
907
-
908
  # Extract frame tensor (N, C, H, W)
909
  frames_tensor = result["data"]
910
-
911
  # Create final VideoMetadata object
912
  video_metadata = VideoMetadata(
913
  total_num_frames=len(frame_indices),
@@ -918,15 +1107,15 @@ class MossVLVideoProcessor(BaseVideoProcessor):
918
  width=torchcodec_metadata.width,
919
  frames_indices=frame_indices
920
  )
921
-
922
  # Ensure frames are in (T, C, H, W) format
923
  if frames_tensor.dim() == 4: # (N, C, H, W)
924
  video_tensor = frames_tensor
925
  else:
926
  raise ValueError(f"Unexpected frame tensor shape: {frames_tensor.shape}")
927
-
928
  return video_tensor, video_metadata
929
-
930
  except Exception as e:
931
  logger.error(f"Error loading video {video_path}: {e}")
932
  traceback.print_exc()
@@ -941,7 +1130,7 @@ class MossVLVideoProcessor(BaseVideoProcessor):
941
  do_convert_rgb: bool = True,
942
  do_resize: bool = True,
943
  size: Optional[SizeDict] = None,
944
- resample: PILImageResampling = PILImageResampling.BICUBIC,
945
  do_rescale: bool = True,
946
  rescale_factor: float = 1 / 255.0,
947
  do_normalize: bool = True,
@@ -991,7 +1180,7 @@ class MossVLVideoProcessor(BaseVideoProcessor):
991
  stacked_videos = self.resize(
992
  stacked_videos,
993
  size=SizeDict(height=resized_height, width=resized_width),
994
- resample=resample,
995
  )
996
  stacked_videos = stacked_videos.view(B, T, C, resized_height, resized_width)
997
  resized_videos_grouped[shape] = stacked_videos
@@ -1031,12 +1220,17 @@ class MossVLVideoProcessor(BaseVideoProcessor):
1031
  merge_size,
1032
  patch_size,
1033
  )
 
 
 
 
 
1034
  patches = patches.permute(0, 1, 4, 7, 5, 8, 3, 2, 6, 9)
1035
  flatten_patches = patches.reshape(
1036
  batch_size,
1037
  grid_t * grid_h * grid_w,
1038
  channel * temporal_patch_size * patch_size * patch_size,
1039
- )
1040
 
1041
  processed_videos_grouped[shape] = flatten_patches
1042
  processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size
@@ -1059,15 +1253,15 @@ class MossVLVideoProcessor(BaseVideoProcessor):
1059
  ) -> BatchFeature:
1060
  """
1061
  Preprocess videos for the model.
1062
-
1063
  This method overrides the base class to handle two video input formats:
1064
  1. String path: "path/to/video.mp4"
1065
- 2. Dict with segments: {"video_path": "...", "segment": [[start, end], [time], ...]}
1066
-
1067
  Args:
1068
  videos: Video input(s) in one of the supported formats.
1069
  **kwargs: Additional arguments passed to _preprocess.
1070
-
1071
  Returns:
1072
  BatchFeature with pixel_values_videos, video_grid_thw, and optionally video_metadata.
1073
  """
@@ -1076,11 +1270,11 @@ class MossVLVideoProcessor(BaseVideoProcessor):
1076
  captured_kwargs=kwargs.keys(),
1077
  valid_processor_keys=list(self.valid_kwargs.__annotations__.keys()) + ["return_tensors"],
1078
  )
1079
-
1080
  # Set default kwargs from self
1081
  for kwarg_name in self.valid_kwargs.__annotations__:
1082
  kwargs.setdefault(kwarg_name, getattr(self, kwarg_name, None))
1083
-
1084
  # Pop kwargs that are handled separately
1085
  return_tensors = kwargs.pop("return_tensors", None)
1086
  return_metadata = kwargs.pop("return_metadata", False)
@@ -1089,44 +1283,43 @@ class MossVLVideoProcessor(BaseVideoProcessor):
1089
  kwargs.pop("video_metadata", None) # We generate our own metadata
1090
  kwargs.pop("do_sample_frames", None) # We handle sampling ourselves
1091
  kwargs.pop("data_format", None) # Not used
1092
-
1093
  # Normalize input to list format
1094
  if not isinstance(videos, list):
1095
  videos = [videos]
1096
-
1097
  # Get video processing params from kwargs (may be passed explicitly for per-batch configuration)
1098
  video_fps = kwargs.pop("video_fps", None)
1099
  min_frames = kwargs.pop("min_frames", None)
1100
  max_frames = kwargs.pop("max_frames", None)
1101
-
1102
  # Use fetch_videos to handle both string and dict formats
1103
  video_tensors, video_metadata = self.fetch_videos(
1104
- videos,
1105
  video_fps=video_fps,
1106
  min_frames=min_frames,
1107
  max_frames=max_frames,
1108
  )
1109
-
1110
  # Prepare video tensors using _prepare_input_videos
1111
  prepared_videos = self._prepare_input_videos(
1112
  videos=video_tensors,
1113
  input_data_format=input_data_format,
1114
  device=device,
1115
  )
1116
-
1117
  # Process kwargs for _preprocess
1118
  kwargs = self._further_process_kwargs(**kwargs)
1119
  self._validate_preprocess_kwargs(**kwargs)
1120
-
1121
  # Call _preprocess with prepared videos
1122
  result = self._preprocess(videos=prepared_videos, return_tensors=return_tensors, **kwargs)
1123
-
1124
  # Add metadata if requested
1125
  if return_metadata:
1126
  result["video_metadata"] = video_metadata
1127
-
1128
  return result
1129
 
1130
 
1131
  __all__ = ["MossVLVideoProcessor"]
1132
-
 
14
  # limitations under the License.
15
  """video processor class for Moss-VL."""
16
 
17
+ import fcntl
18
+ import hashlib
19
  import json
20
  import logging as system_logging
21
  import math
22
  import os
23
  import re
24
  import subprocess
25
+ import tempfile
26
  import traceback
27
+ from functools import lru_cache
28
  from typing import Any, Dict, List, Optional, Union
29
 
30
  import numpy as np
 
42
 
43
  logger = logging.get_logger(__name__)
44
 
45
+ TORCHCODEC_TIMESTAMP_EPSILON = 1e-6
46
+
47
 
48
  # -----------------------------------------------------------------------------
49
  # Torchcodec video frame extraction utilities
 
52
  def check_video_for_extra_streams_and_errors(video_path: str) -> dict:
53
  """
54
  Check if video file has abnormal streams or errors reported by ffprobe.
55
+
56
  Args:
57
  video_path: Path to the video file.
58
+
59
  Returns:
60
  A dictionary containing:
61
  - 'has_extra_streams': bool, whether there are streams other than video and audio.
 
73
  'stream_details': [],
74
  'num_streams': 0
75
  }
76
+
77
  command = [
78
  "ffprobe",
79
  "-v", "error",
 
82
  "-of", "json",
83
  video_path
84
  ]
85
+
86
  try:
87
  process = subprocess.run(
88
  command,
 
91
  check=False
92
  )
93
  result['ffprobe_successful'] = (process.returncode == 0)
94
+
95
  if process.stderr:
96
  result['ffprobe_output_error'] = process.stderr
97
  unsupported_codec_pattern = re.compile(r"Unsupported codec with id \d+ for input stream \d+")
98
  result['unsupported_codec_errors'] = unsupported_codec_pattern.findall(process.stderr)
99
+
100
  if process.stdout:
101
  ffprobe_data = json.loads(process.stdout)
102
  if 'streams' in ffprobe_data:
 
107
  result['stream_details'].append({'index': stream_index, 'codec_type': stream_type})
108
  if stream_type not in ['video', 'audio']:
109
  result['has_extra_streams'] = True
110
+
111
  if 'format' in ffprobe_data and 'nb_streams' in ffprobe_data['format']:
112
  if result['num_streams'] == 0:
113
  result['num_streams'] = ffprobe_data['format']['nb_streams']
 
125
  except Exception as e:
126
  result['ffprobe_output_error'] = f"An unexpected error occurred: {e}"
127
  result['ffprobe_successful'] = False
128
+
129
  return result
130
 
131
 
132
  def remove_video_extra_stream_ffmpeg(input_video: str, output_video: str) -> bool:
133
  """
134
  Remove extra streams from video using ffmpeg.
135
+
136
  Args:
137
  input_video: Path to input video.
138
  output_video: Path to output video.
139
+
140
  Returns:
141
  bool: True if successful, False otherwise.
142
  """
 
152
  "-movflags", "faststart",
153
  output_video,
154
  ]
155
+
156
  try:
157
  subprocess.run(command_list, shell=False, check=True, capture_output=True)
158
  return True
 
168
  return False
169
 
170
 
171
+ def _video_clean_lock_path(video_path: str) -> str:
172
+ lock_dir = os.path.join(tempfile.gettempdir(), "mossvl_video_clean_locks")
173
+ os.makedirs(lock_dir, exist_ok=True)
174
+ path_key = os.path.realpath(video_path).encode("utf-8")
175
+ return os.path.join(lock_dir, f"{hashlib.sha256(path_key).hexdigest()}.lock")
176
+
177
+
178
+ def _is_reusable_clean_video(video_path: str) -> bool:
179
+ if not os.path.isfile(video_path) or os.path.getsize(video_path) <= 0:
180
+ return False
181
+ probe = check_video_for_extra_streams_and_errors(video_path)
182
+ return probe["ffprobe_successful"] and not probe["has_extra_streams"]
183
+
184
+
185
  def clean_video_streams(video_path: str) -> str:
186
  """
187
  Clean video streams if extra streams are detected.
188
+
189
  Args:
190
  video_path: Path to the video file.
191
+
192
  Returns:
193
  str: Path to cleaned video (or original if no cleaning needed).
194
  """
 
199
  file_name_without_ext, file_ext = os.path.splitext(base_name)
200
  new_base_name = f"{file_name_without_ext}_fix{file_ext}"
201
  video_path_output = os.path.join(output_folder, new_base_name)
202
+
203
+ try:
204
+ with open(_video_clean_lock_path(video_path), "a") as lock_file:
205
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
206
+ if _is_reusable_clean_video(video_path_output):
207
+ return video_path_output
208
+
209
+ temp_fd, temp_output = tempfile.mkstemp(
210
+ prefix=f".{file_name_without_ext}_fix.",
211
+ suffix=file_ext,
212
+ dir=output_folder,
213
+ )
214
+ os.close(temp_fd)
215
+ try:
216
+ if not remove_video_extra_stream_ffmpeg(video_path, temp_output):
217
+ logger.warning("Failed to remove extra streams with ffmpeg")
218
+ return video_path
219
+ os.replace(temp_output, video_path_output)
220
+ return video_path_output
221
+ finally:
222
+ if os.path.exists(temp_output):
223
+ os.unlink(temp_output)
224
+ except OSError as exc:
225
+ logger.warning(f"Failed to publish cleaned video for {video_path}: {exc}")
226
  return video_path
 
227
  return video_path
228
 
229
 
230
+ @lru_cache(maxsize=8192)
231
+ def cached_clean_video_streams(video_path: str) -> str:
232
+ return clean_video_streams(video_path)
233
+
234
+
235
+ def clamp_timestamps_for_torchcodec(timestamps: List[float], torchcodec_metadata) -> List[float]:
236
+ if not timestamps:
237
+ return timestamps
238
+
239
+ min_pts = torchcodec_metadata.begin_stream_seconds_from_content
240
+ if min_pts is None:
241
+ min_pts = 0.0
242
+ # TorchCodec can reject timestamps exactly equal to the reported stream
243
+ # begin due to tiny metadata/decoder precision differences.
244
+ safe_min_pts = min_pts + TORCHCODEC_TIMESTAMP_EPSILON
245
+
246
+ max_pts_candidates = []
247
+ if torchcodec_metadata.num_frames_from_content and torchcodec_metadata.average_fps:
248
+ max_pts_candidates.append(
249
+ (torchcodec_metadata.num_frames_from_content - 1) / torchcodec_metadata.average_fps + min_pts
250
+ )
251
+ if torchcodec_metadata.end_stream_seconds_from_content is not None:
252
+ # TorchCodec requires requested PTS to be strictly smaller than the content end.
253
+ max_pts_candidates.append(torchcodec_metadata.end_stream_seconds_from_content - TORCHCODEC_TIMESTAMP_EPSILON)
254
+ if not max_pts_candidates and torchcodec_metadata.duration_seconds is not None:
255
+ max_pts_candidates.append(torchcodec_metadata.duration_seconds - TORCHCODEC_TIMESTAMP_EPSILON)
256
+
257
+ if max_pts_candidates:
258
+ max_pts = max(safe_min_pts, min(max_pts_candidates))
259
+ return [max(safe_min_pts, min(float(t), max_pts)) for t in timestamps]
260
+ if safe_min_pts > 0:
261
+ return [max(safe_min_pts, float(t)) for t in timestamps]
262
+ return [float(t) for t in timestamps]
263
+
264
+
265
  def split_indices(indices: List[Union[int, float]], num_chunks: int) -> List[List[Union[int, float]]]:
266
  """
267
  Split an index list into roughly equal chunks.
268
+
269
  Args:
270
  indices: List of indices to split.
271
  num_chunks: Number of chunks to create.
272
+
273
  Returns:
274
  List of index chunks.
275
  """
276
+ if len(indices) == 0:
277
+ raise ValueError("indices must not be empty")
278
+ if num_chunks <= 0:
279
+ raise ValueError("num_chunks must be positive")
280
+
281
+ num_chunks = min(num_chunks, len(indices))
282
  chunk_size = len(indices) // num_chunks
283
  chunks = []
284
  for i in range(num_chunks - 1):
 
290
  def decode_sequentially(indices: List[int], video_path: str, ffmpeg_threads: int = 0):
291
  """
292
  Decode frames sequentially from a video.
293
+
294
  Args:
295
  indices: List of frame indices to decode.
296
  video_path: Path to the video file.
297
  ffmpeg_threads: Number of ffmpeg threads to use.
298
+
299
  Returns:
300
  FrameBatch from torchcodec.
301
  """
 
309
  def decode_with_multithreading(indices: List[int], num_threads: int, video_path: str) -> dict:
310
  """
311
  Decode frames using multithreading with joblib.
312
+
313
  Args:
314
  indices: List of frame indices to decode.
315
  num_threads: Number of threads to use.
316
  video_path: Path to the video file.
317
+
318
  Returns:
319
  dict: Contains 'data', 'duration_seconds', 'pts_seconds' tensors.
320
  """
321
  chunks = split_indices(indices, num_chunks=num_threads)
322
+ results = Parallel(n_jobs=len(chunks), prefer="threads", verbose=0)(
323
  delayed(decode_sequentially)(chunk, video_path) for chunk in chunks
324
  )
325
+
326
  return {
327
  "data": torch.cat([frame_batch.data for frame_batch in results], dim=0),
328
  "duration_seconds": torch.cat([frame_batch.duration_seconds for frame_batch in results], dim=0),
 
333
  def decode_sequentially_timestamp(timestamp_list: List[float], video_path: str, ffmpeg_threads: int = 0):
334
  """
335
  Decode frames sequentially from a video based on timestamps.
336
+
337
  Args:
338
  timestamp_list: List of timestamps (in seconds) to decode.
339
  video_path: Path to the video file.
340
  ffmpeg_threads: Number of ffmpeg threads to use.
341
+
342
  Returns:
343
  FrameBatch from torchcodec.
344
  """
345
  decoder = VideoDecoder(video_path, num_ffmpeg_threads=ffmpeg_threads)
346
  try:
347
  metadata = decoder.metadata
348
+ timestamp_list = clamp_timestamps_for_torchcodec(timestamp_list, metadata)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
 
350
  return decoder.get_frames_played_at(timestamp_list)
351
  finally:
 
355
  def timestamp_decode_with_multithreading(timestamp_list: List[float], num_threads: int, video_path: str) -> dict:
356
  """
357
  Decode frames using multithreading based on timestamps.
358
+
359
  Args:
360
  timestamp_list: List of timestamps (in seconds) to decode.
361
  num_threads: Number of threads to use.
362
  video_path: Path to the video file.
363
+
364
  Returns:
365
  dict: Contains 'data', 'duration_seconds', 'pts_seconds' tensors.
366
  """
367
  chunks = split_indices(timestamp_list, num_chunks=num_threads)
368
+ results = Parallel(n_jobs=len(chunks), prefer="threads", verbose=0)(
369
  delayed(decode_sequentially_timestamp)(chunk, video_path) for chunk in chunks
370
  )
371
+
372
  # Concatenate results from all threads
373
  data_list = [frame_batch.data for frame_batch in results]
374
  duration_list = [frame_batch.duration_seconds for frame_batch in results]
375
  pts_list = [frame_batch.pts_seconds for frame_batch in results]
376
+
377
  if not data_list:
378
  logger.warning("No frames were successfully decoded.")
379
  return {"data": torch.empty(0), "duration_seconds": torch.empty(0), "pts_seconds": torch.empty(0)}
380
+
381
  return {
382
  "data": torch.cat(data_list, dim=0),
383
  "duration_seconds": torch.cat(duration_list, dim=0),
 
393
  ) -> Optional[dict]:
394
  """
395
  Extract frames from video using torchcodec with multithreading.
396
+
397
  Args:
398
  video_path: Path to the video file.
399
  sample_frames_count: Number of frames to sample.
400
  num_threads: Number of threads to use for extraction.
401
  sampling_method: Sampling method, either "index" (uniform frame indices) or "timestamp" (uniform timestamps).
402
+
403
  Returns:
404
  dict: Contains 'data' (N, C, H, W), 'duration_seconds' (N,), 'pts_seconds' (N,) tensors.
405
  Returns None if extraction fails.
406
  """
407
  try:
408
+ video_path = cached_clean_video_streams(video_path)
409
  decoder = VideoDecoder(video_path, num_ffmpeg_threads=0)
410
  metadata = decoder.metadata
411
 
412
 
413
  total_frames_in_video = metadata.num_frames_from_content
414
+
415
  effective_sample_count = min(sample_frames_count, total_frames_in_video)
416
  if effective_sample_count == 0:
417
  logger.error("Cannot extract frames: video has 0 frames or specified frame count is 0")
418
  return None
419
+
420
  # Generate uniform frame indices
421
  frame_indices = np.linspace(0, total_frames_in_video - 1, effective_sample_count).astype(np.int32)
422
  # Ensure indices are valid and remove duplicates
423
  frame_indices = np.unique(np.clip(frame_indices, 0, total_frames_in_video - 1))
424
+
425
  result = decode_with_multithreading(frame_indices.tolist(), num_threads=num_threads, video_path=video_path)
426
  # Add frame_indices to the result for later use
427
  result["frame_indices"] = frame_indices
428
  return result
429
 
430
 
431
+
432
  except Exception:
433
  traceback.print_exc()
434
  return None
 
564
  return video_input["video_path"]
565
  return video_input
566
 
567
+ def _get_video_fps_from_input(
568
+ self,
569
+ video_input: Union[str, Dict[str, Any]],
570
+ default_fps: Optional[float],
571
+ ) -> Optional[float]:
572
+ if not isinstance(video_input, dict) or "fps" not in video_input:
573
+ return default_fps
574
+
575
+ raw_fps = video_input["fps"]
576
+ if raw_fps is None:
577
+ return default_fps
578
+ try:
579
+ fps = float(raw_fps)
580
+ except (TypeError, ValueError) as exc:
581
+ raise ValueError(f"Invalid video fps value: {raw_fps!r}") from exc
582
+ if fps <= 0:
583
+ raise ValueError(f"Invalid video fps value: {raw_fps!r}; fps must be positive")
584
+ return fps
585
+
586
  def _get_video_duration_seconds(self, video_input: Union[str, Dict[str, Any]]) -> float:
587
  """Get video duration in seconds for weighted frame-budget allocation."""
588
+ video_path = cached_clean_video_streams(self._get_video_path_from_input(video_input))
589
  decoder = VideoDecoder(video_path, num_ffmpeg_threads=0)
590
  try:
591
  metadata = decoder.metadata
 
670
  ) -> int:
671
  """
672
  Calculate the number of frames to sample using fps-based logic with min/max constraints.
673
+
674
  Logic:
675
  1. Calculate target_frames based on fps and video duration
676
  2. Apply min_frames and max_frames constraints
677
  3. Apply max_allowed_frames protection (rough cap from total video_max_pixels budget)
678
  4. Return the number of frames to sample
679
+
680
  Args:
681
  metadata (`VideoMetadata`):
682
  Metadata of the video containing information about total duration, fps and total number of frames.
 
696
  raise ValueError("`num_frames` and `fps` are mutually exclusive arguments, please use only one!")
697
 
698
  total_num_frames = metadata.total_num_frames
699
+
700
  # Use provided min/max or fall back to defaults
701
  effective_min_frames = min_frames if min_frames is not None else self.min_frames
702
  effective_max_frames = max_frames if max_frames is not None else self.max_frames
703
+
704
  # Rough per-video frame cap derived from the multi-video total budget
705
  # (exact allocation happens later in _preprocess via weighted distribution)
706
  per_frame_min_pixels = self.size.get("shortest_edge", None) if self.size else None
 
708
  if per_frame_min_pixels is not None and video_max_pixels is not None and per_frame_min_pixels > 0:
709
  max_allowed_frames = video_max_pixels // per_frame_min_pixels
710
  effective_max_frames = min(effective_max_frames, max_allowed_frames)
711
+
712
  # Get video duration
713
  if hasattr(metadata, 'duration') and metadata.duration is not None:
714
  duration = metadata.duration
 
726
 
727
  # Use provided fps or default
728
  target_fps = fps if fps is not None else self.video_fps
729
+
730
  # Calculate target frames based on fps and duration
731
  if num_frames is None:
732
  # Calculate how many frames we should sample based on target fps
733
  target_total_frames = int(math.ceil(duration * target_fps - 1e-6))
734
+
735
  # Apply min/max constraints
736
  sample_frames = max(target_total_frames, effective_min_frames)
737
  sample_frames = min(sample_frames, effective_max_frames, total_num_frames)
 
742
  return sample_frames
743
 
744
 
745
+ def _decode_timestamps_with_decoder(
746
+ self,
747
+ decoder: VideoDecoder,
748
+ timestamps: List[float],
749
+ chunk_size: int = 128,
750
+ ) -> torch.Tensor:
751
+ if not timestamps:
752
+ return torch.empty(0)
753
+
754
+ frame_chunks = []
755
+ for start in range(0, len(timestamps), chunk_size):
756
+ frame_batch = decoder.get_frames_played_at(timestamps[start:start + chunk_size])
757
+ frame_chunks.append(frame_batch.data)
758
+
759
+ if len(frame_chunks) == 1:
760
+ return frame_chunks[0]
761
+ return torch.cat(frame_chunks, dim=0)
762
+
763
+ def _clamp_timestamps_for_decoder(
764
+ self,
765
+ timestamps: List[float],
766
+ torchcodec_metadata,
767
+ ) -> List[float]:
768
+ return clamp_timestamps_for_torchcodec(timestamps, torchcodec_metadata)
769
+
770
+ def _fetch_video_segments_batched(
771
+ self,
772
+ video_path: str,
773
+ segments: List[List[float]],
774
+ min_frames: Optional[int] = None,
775
+ max_frames: Optional[int] = None,
776
+ video_fps: Optional[float] = None,
777
+ ):
778
+ min_frames = max(1, min_frames if min_frames is not None else self.min_frames)
779
+ max_frames = max(1, max_frames if max_frames is not None else self.max_frames)
780
+ target_video_fps = video_fps if video_fps is not None else self.video_fps
781
+
782
+ video_path = cached_clean_video_streams(video_path)
783
+ decoder = VideoDecoder(video_path, num_ffmpeg_threads=0)
784
+ try:
785
+ torchcodec_metadata = decoder.metadata
786
+ source_video_fps = torchcodec_metadata.average_fps
787
+
788
+ duration = None
789
+ if (
790
+ torchcodec_metadata.end_stream_seconds_from_content is not None
791
+ and torchcodec_metadata.begin_stream_seconds_from_content is not None
792
+ ):
793
+ duration = (
794
+ torchcodec_metadata.end_stream_seconds_from_content
795
+ - torchcodec_metadata.begin_stream_seconds_from_content
796
+ )
797
+ if duration is None or duration <= 0:
798
+ duration = torchcodec_metadata.duration_seconds
799
+
800
+ segment_durations = [
801
+ segment[1] - segment[0] if len(segment) == 2 else None
802
+ for segment in segments
803
+ ]
804
+ total_segment_duration = sum(d for d in segment_durations if d is not None)
805
+ num_range_segments = sum(1 for d in segment_durations if d is not None)
806
+
807
+ segment_timestamps = []
808
+ decode_timestamps = []
809
+ for i, segment in enumerate(segments):
810
+ if len(segment) == 1:
811
+ actual_timestamps = self._clamp_timestamps_for_decoder([segment[0]], torchcodec_metadata)
812
+ segment_timestamps.append(actual_timestamps)
813
+ decode_timestamps.extend(actual_timestamps)
814
+ continue
815
+
816
+ start_time, end_time = segment
817
+ segment_duration = end_time - start_time
818
+ target_frames = int(math.ceil(segment_duration * target_video_fps))
819
+
820
+ if total_segment_duration > 0:
821
+ weight = segment_durations[i] / total_segment_duration
822
+ else:
823
+ weight = 1.0 / num_range_segments if num_range_segments > 0 else 1.0
824
+
825
+ weighted_min_frames = max(1, int(round(min_frames * weight)))
826
+ weighted_max_frames = max(1, int(round(max_frames * weight)))
827
+ target_frames = max(target_frames, weighted_min_frames)
828
+ target_frames = min(target_frames, weighted_max_frames)
829
+
830
+ if target_frames == 1:
831
+ actual_timestamps = [start_time]
832
+ else:
833
+ actual_timestamps = np.linspace(
834
+ start_time,
835
+ end_time,
836
+ target_frames,
837
+ endpoint=False,
838
+ ).tolist()
839
+
840
+ actual_timestamps = self._clamp_timestamps_for_decoder(actual_timestamps, torchcodec_metadata)
841
+ segment_timestamps.append(actual_timestamps)
842
+ decode_timestamps.extend(actual_timestamps)
843
+
844
+ flat_frames = self._decode_timestamps_with_decoder(decoder, decode_timestamps)
845
+
846
+ videos = []
847
+ metadata = []
848
+ frame_offset = 0
849
+ for actual_timestamps in segment_timestamps:
850
+ sample_count = len(actual_timestamps)
851
+ video_tensor = flat_frames[frame_offset:frame_offset + sample_count]
852
+ frame_offset += sample_count
853
+
854
+ video_metadata = VideoMetadata(
855
+ total_num_frames=sample_count,
856
+ fps=source_video_fps,
857
+ duration=duration,
858
+ video_backend="torchcodec",
859
+ height=torchcodec_metadata.height,
860
+ width=torchcodec_metadata.width,
861
+ frames_indices=None,
862
+ )
863
+ video_metadata.actual_timestamps = actual_timestamps
864
+
865
+ videos.append(video_tensor)
866
+ metadata.append(video_metadata)
867
+
868
+ return videos, metadata
869
+ finally:
870
+ del decoder
871
+
872
+
873
  def _fetch_video_segment(
874
  self,
875
  video_path: str,
 
880
  ):
881
  """
882
  Fetch video frames for a specific segment.
883
+
884
  Args:
885
  video_path: Path to the video file
886
  segment: [start, end] for a segment (left-closed, right-open) or [time] for a single frame
887
  min_frames: Minimum frames for this segment (weighted). Defaults to self.min_frames. Must be >= 1.
888
  max_frames: Maximum frames for this segment (weighted). Defaults to self.max_frames. Must be >= 1.
889
  video_fps: Target frames per second for video sampling. If None, uses self.video_fps.
890
+
891
  Returns:
892
  Tuple of (video_tensor, video_metadata)
893
  """
 
896
  max_frames = max(1, max_frames if max_frames is not None else self.max_frames)
897
  # Use provided video_fps or fall back to self.video_fps
898
  target_video_fps = video_fps if video_fps is not None else self.video_fps
899
+
900
+ video_path = cached_clean_video_streams(video_path)
901
  decoder = VideoDecoder(video_path, num_ffmpeg_threads=0)
902
  try:
903
  torchcodec_metadata = decoder.metadata
904
+
905
  video_fps = torchcodec_metadata.average_fps
906
+
907
  # Calculate duration
908
  duration = None
909
  if torchcodec_metadata.end_stream_seconds_from_content is not None and torchcodec_metadata.begin_stream_seconds_from_content is not None:
910
  duration = torchcodec_metadata.end_stream_seconds_from_content - torchcodec_metadata.begin_stream_seconds_from_content
911
  if duration is None or duration <= 0:
912
  duration = torchcodec_metadata.duration_seconds
913
+
914
  if len(segment) == 1:
915
  # Single frame at specified time
916
+ actual_timestamps = self._clamp_timestamps_for_decoder([segment[0]], torchcodec_metadata)
917
+ frame_batch = decoder.get_frames_played_at(actual_timestamps)
918
  video_tensor = frame_batch.data
 
919
  sample_count = 1
920
  else:
921
  # Segment [start, end) - left-closed, right-open interval
922
  start_time, end_time = segment
923
  segment_duration = end_time - start_time
924
+
925
  # Calculate number of frames to sample for this segment
926
  target_frames = int(math.ceil(segment_duration * target_video_fps))
927
  target_frames = max(target_frames, min_frames)
928
  target_frames = min(target_frames, max_frames)
929
+
930
  # Generate timestamps for uniform sampling within segment
931
  if target_frames == 1:
932
  actual_timestamps = [start_time] # Use start_time for single frame
933
  else:
934
  # Sample uniformly within [start, end), endpoint=False for left-closed right-open
935
  actual_timestamps = np.linspace(start_time, end_time, target_frames, endpoint=False).tolist()
936
+
937
+ actual_timestamps = self._clamp_timestamps_for_decoder(actual_timestamps, torchcodec_metadata)
938
+
939
  # Use multithreading for extraction
940
  result = timestamp_decode_with_multithreading(actual_timestamps, self.num_extract_threads, video_path)
941
  video_tensor = result["data"]
942
  sample_count = len(actual_timestamps)
943
+
944
  # Create VideoMetadata
945
  video_metadata = VideoMetadata(
946
  total_num_frames=sample_count,
 
951
  width=torchcodec_metadata.width,
952
  frames_indices=None
953
  )
954
+
955
  # Store actual timestamps as a custom attribute for _calculate_timestamps to use
956
  video_metadata.actual_timestamps = actual_timestamps
957
+
958
  return video_tensor, video_metadata
959
  finally:
960
  del decoder
961
 
962
  def fetch_videos(
963
+ self,
964
+ video_url_or_urls: Union[str, Dict[str, Any], List[Union[str, Dict[str, Any]]]],
965
+ sample_indices_fn=None,
966
  video_fps: Optional[float] = None,
967
  min_frames: Optional[int] = None,
968
  max_frames: Optional[int] = None,
969
  ):
970
  """
971
  Override fetch_videos to use torchcodec for frame extraction.
972
+
973
  This method uses torchcodec with multithreading for efficient frame extraction.
974
  Frame count is calculated by the calculate_num_frames method
975
  (fps-based with min/max constraints).
976
+
977
  Args:
978
  video_url_or_urls: Can be one of:
979
  - str: Single video path
980
+ - Dict: Video descriptor with ``video_path`` and optional ``fps`` /
981
+ ``segments`` (``segment`` is accepted as an alias)
982
+ - List[Union[str, Dict]]: List of video paths or descriptors
983
  sample_indices_fn: (Not used) Kept for compatibility with base class signature.
984
  video_fps: Target frames per second for video sampling. If None, uses self.video_fps.
985
  min_frames: Minimum number of frames to sample. If None, uses self.min_frames.
986
  max_frames: Maximum number of frames to sample. If None, uses self.max_frames.
987
+
988
  Returns:
989
  Tuple of (videos, metadata) where videos are torch.Tensors and metadata are VideoMetadata objects.
990
  """
 
1004
  effective_max_frames,
1005
  )
1006
  for x, allocated_max_frames in zip(video_url_or_urls, per_video_max_frames):
1007
+ explicit_video_fps = self._get_video_fps_from_input(x, None)
1008
+ item_video_fps = explicit_video_fps if explicit_video_fps is not None else effective_video_fps
1009
  result = self.fetch_videos(
1010
+ x,
1011
+ video_fps=item_video_fps,
1012
  min_frames=effective_min_frames,
1013
  max_frames=allocated_max_frames,
1014
  )
 
1020
  all_videos.append(result[0])
1021
  all_metadata.append(result[1])
1022
  return all_videos, all_metadata
1023
+
1024
+ # Dict inputs may override FPS and optionally select segments. Without
1025
+ # segments, the dict describes a full video.
1026
  if isinstance(video_url_or_urls, dict):
1027
  video_path = video_url_or_urls["video_path"]
1028
+ input_video_fps = self._get_video_fps_from_input(video_url_or_urls, effective_video_fps)
1029
+ segments = video_url_or_urls.get("segments", video_url_or_urls.get("segment"))
1030
+
1031
+ if segments is not None:
1032
+ return self._fetch_video_segments_batched(
1033
+ video_path,
1034
+ segments,
1035
+ min_frames=effective_min_frames,
1036
+ max_frames=effective_max_frames,
1037
+ video_fps=input_video_fps,
1038
+ )
1039
+
1040
+ video_url_or_urls = video_path
1041
+ effective_video_fps = input_video_fps
1042
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1043
  # Single video path
1044
  video_path = video_url_or_urls
1045
+
1046
  # Clean video streams first (remove extra streams if needed)
1047
+ video_path = cached_clean_video_streams(video_path)
1048
 
1049
  decoder = None
1050
  try:
 
1055
  duration = None
1056
  if torchcodec_metadata.end_stream_seconds_from_content is not None and torchcodec_metadata.begin_stream_seconds_from_content is not None:
1057
  duration = torchcodec_metadata.end_stream_seconds_from_content - torchcodec_metadata.begin_stream_seconds_from_content
1058
+
1059
  if duration is None or duration <= 0:
1060
  duration = torchcodec_metadata.duration_seconds
1061
+
1062
  # Use num_frames_from_content for accurate frame count (consistent with extraction)
1063
  total_frames_in_video = torchcodec_metadata.num_frames_from_content
1064
+
1065
  # Create VideoMetadata object for sample_frames method
1066
  temp_metadata = VideoMetadata(
1067
  total_num_frames=total_frames_in_video,
 
1072
  width=torchcodec_metadata.width,
1073
  frames_indices=None
1074
  )
1075
+
1076
  # Use calculate_num_frames method to get the number of frames to sample
1077
  sample_frames_count = self.calculate_num_frames(
1078
+ temp_metadata,
1079
  fps=effective_video_fps,
1080
  min_frames=effective_min_frames,
1081
  max_frames=effective_max_frames,
1082
  )
1083
+
1084
  # Ensure sample count is valid
1085
  effective_sample_count = min(sample_frames_count, total_frames_in_video)
1086
  if effective_sample_count == 0:
1087
  raise ValueError(f"Cannot extract frames: video has 0 frames or specified frame count is 0")
1088
+
1089
  # Generate uniform frame indices
1090
  frame_indices = np.linspace(0, total_frames_in_video - 1, effective_sample_count).astype(np.int32)
1091
  # Ensure indices are valid and remove duplicates
1092
  frame_indices = np.unique(np.clip(frame_indices, 0, total_frames_in_video - 1))
1093
+
1094
  # Extract frames using multithreading (decoder is created inside each thread for thread safety)
1095
  result = decode_with_multithreading(frame_indices.tolist(), num_threads=self.num_extract_threads, video_path=video_path)
1096
+
1097
  # Extract frame tensor (N, C, H, W)
1098
  frames_tensor = result["data"]
1099
+
1100
  # Create final VideoMetadata object
1101
  video_metadata = VideoMetadata(
1102
  total_num_frames=len(frame_indices),
 
1107
  width=torchcodec_metadata.width,
1108
  frames_indices=frame_indices
1109
  )
1110
+
1111
  # Ensure frames are in (T, C, H, W) format
1112
  if frames_tensor.dim() == 4: # (N, C, H, W)
1113
  video_tensor = frames_tensor
1114
  else:
1115
  raise ValueError(f"Unexpected frame tensor shape: {frames_tensor.shape}")
1116
+
1117
  return video_tensor, video_metadata
1118
+
1119
  except Exception as e:
1120
  logger.error(f"Error loading video {video_path}: {e}")
1121
  traceback.print_exc()
 
1130
  do_convert_rgb: bool = True,
1131
  do_resize: bool = True,
1132
  size: Optional[SizeDict] = None,
1133
+ interpolation: PILImageResampling = PILImageResampling.BICUBIC,
1134
  do_rescale: bool = True,
1135
  rescale_factor: float = 1 / 255.0,
1136
  do_normalize: bool = True,
 
1180
  stacked_videos = self.resize(
1181
  stacked_videos,
1182
  size=SizeDict(height=resized_height, width=resized_width),
1183
+ interpolation=interpolation,
1184
  )
1185
  stacked_videos = stacked_videos.view(B, T, C, resized_height, resized_width)
1186
  resized_videos_grouped[shape] = stacked_videos
 
1220
  merge_size,
1221
  patch_size,
1222
  )
1223
+ patches_device = patches.device
1224
+ # NPU: max 8D tensors — route the 10D permute+reshape through CPU.
1225
+ # CUDA handles 10D natively — keep it on-device.
1226
+ if patches_device.type == "npu":
1227
+ patches = patches.cpu()
1228
  patches = patches.permute(0, 1, 4, 7, 5, 8, 3, 2, 6, 9)
1229
  flatten_patches = patches.reshape(
1230
  batch_size,
1231
  grid_t * grid_h * grid_w,
1232
  channel * temporal_patch_size * patch_size * patch_size,
1233
+ ).to(patches_device)
1234
 
1235
  processed_videos_grouped[shape] = flatten_patches
1236
  processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size
 
1253
  ) -> BatchFeature:
1254
  """
1255
  Preprocess videos for the model.
1256
+
1257
  This method overrides the base class to handle two video input formats:
1258
  1. String path: "path/to/video.mp4"
1259
+ 2. Dict descriptor with ``video_path`` and optional ``fps`` / ``segments``
1260
+
1261
  Args:
1262
  videos: Video input(s) in one of the supported formats.
1263
  **kwargs: Additional arguments passed to _preprocess.
1264
+
1265
  Returns:
1266
  BatchFeature with pixel_values_videos, video_grid_thw, and optionally video_metadata.
1267
  """
 
1270
  captured_kwargs=kwargs.keys(),
1271
  valid_processor_keys=list(self.valid_kwargs.__annotations__.keys()) + ["return_tensors"],
1272
  )
1273
+
1274
  # Set default kwargs from self
1275
  for kwarg_name in self.valid_kwargs.__annotations__:
1276
  kwargs.setdefault(kwarg_name, getattr(self, kwarg_name, None))
1277
+
1278
  # Pop kwargs that are handled separately
1279
  return_tensors = kwargs.pop("return_tensors", None)
1280
  return_metadata = kwargs.pop("return_metadata", False)
 
1283
  kwargs.pop("video_metadata", None) # We generate our own metadata
1284
  kwargs.pop("do_sample_frames", None) # We handle sampling ourselves
1285
  kwargs.pop("data_format", None) # Not used
1286
+
1287
  # Normalize input to list format
1288
  if not isinstance(videos, list):
1289
  videos = [videos]
1290
+
1291
  # Get video processing params from kwargs (may be passed explicitly for per-batch configuration)
1292
  video_fps = kwargs.pop("video_fps", None)
1293
  min_frames = kwargs.pop("min_frames", None)
1294
  max_frames = kwargs.pop("max_frames", None)
1295
+
1296
  # Use fetch_videos to handle both string and dict formats
1297
  video_tensors, video_metadata = self.fetch_videos(
1298
+ videos,
1299
  video_fps=video_fps,
1300
  min_frames=min_frames,
1301
  max_frames=max_frames,
1302
  )
1303
+
1304
  # Prepare video tensors using _prepare_input_videos
1305
  prepared_videos = self._prepare_input_videos(
1306
  videos=video_tensors,
1307
  input_data_format=input_data_format,
1308
  device=device,
1309
  )
1310
+
1311
  # Process kwargs for _preprocess
1312
  kwargs = self._further_process_kwargs(**kwargs)
1313
  self._validate_preprocess_kwargs(**kwargs)
1314
+
1315
  # Call _preprocess with prepared videos
1316
  result = self._preprocess(videos=prepared_videos, return_tensors=return_tensors, **kwargs)
1317
+
1318
  # Add metadata if requested
1319
  if return_metadata:
1320
  result["video_metadata"] = video_metadata
1321
+
1322
  return result
1323
 
1324
 
1325
  __all__ = ["MossVLVideoProcessor"]