Video-Text-to-Text
Transformers
Safetensors
English
Chinese
moss_vl
feature-extraction
Base
Video-Understanding
Image-Understanding
MOSS-VL
OpenMOSS
multimodal
video
vision-language
custom_code
Instructions to use OpenMOSS-Team/MOSS-VL-Base-0708 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use OpenMOSS-Team/MOSS-VL-Base-0708 with Transformers:
# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("OpenMOSS-Team/MOSS-VL-Base-0708", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Unify MOSS-VL processor and inference preprocessing defaults
Browse filesUse the shared processor and tokenizer, set image/video pixel budgets to 201326592, and disable vision tail padding with vision_seq_pad_multiple=1. Preserve model implementation and weights.
- config.json +1 -1
- processing_moss_vl.py +198 -129
- tokenizer.json +2 -2
- tokenizer_config.json +19 -1
- video_processing_moss_vl.py +348 -183
config.json
CHANGED
|
@@ -78,6 +78,6 @@
|
|
| 78 |
"temporal_patch_size": 1
|
| 79 |
},
|
| 80 |
"vision_end_token_id": 151653,
|
| 81 |
-
"vision_seq_pad_multiple":
|
| 82 |
"vision_start_token_id": 151652
|
| 83 |
}
|
|
|
|
| 78 |
"temporal_patch_size": 1
|
| 79 |
},
|
| 80 |
"vision_end_token_id": 151653,
|
| 81 |
+
"vision_seq_pad_multiple": 1,
|
| 82 |
"vision_start_token_id": 151652
|
| 83 |
}
|
processing_moss_vl.py
CHANGED
|
@@ -49,7 +49,7 @@ class MossVLImageProcessorFast(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,
|
|
@@ -70,7 +70,7 @@ class MossVLImageProcessorFast(Qwen2VLImageProcessorFast):
|
|
| 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.
|
|
@@ -79,7 +79,7 @@ class MossVLImageProcessorFast(Qwen2VLImageProcessorFast):
|
|
| 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,13 +98,13 @@ class MossVLImageProcessorFast(Qwen2VLImageProcessorFast):
|
|
| 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,
|
|
@@ -119,7 +119,7 @@ class MossVLImageProcessorFast(Qwen2VLImageProcessorFast):
|
|
| 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,12 +164,17 @@ class MossVLImageProcessorFast(Qwen2VLImageProcessorFast):
|
|
| 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 |
patches = patches.permute(0, 1, 4, 7, 5, 8, 3, 2, 6, 9)
|
| 168 |
flatten_patches = patches.reshape(
|
| 169 |
batch_size,
|
| 170 |
grid_t * grid_h * grid_w,
|
| 171 |
channel * temporal_patch_size * patch_size * patch_size,
|
| 172 |
-
)
|
| 173 |
|
| 174 |
processed_images_grouped[shape] = flatten_patches
|
| 175 |
processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size
|
|
@@ -187,17 +192,17 @@ def _to_numpy(x):
|
|
| 187 |
"""
|
| 188 |
Convert various tensor types to numpy array.
|
| 189 |
Supports torch.Tensor, tf.Tensor, jax.Array, np.ndarray, lists, and primitives.
|
| 190 |
-
|
| 191 |
Args:
|
| 192 |
x: Input value that can be a tensor from various frameworks or a Python primitive
|
| 193 |
-
|
| 194 |
Returns:
|
| 195 |
np.ndarray: NumPy array representation of the input
|
| 196 |
"""
|
| 197 |
# Already numpy
|
| 198 |
if isinstance(x, np.ndarray):
|
| 199 |
return x
|
| 200 |
-
|
| 201 |
# Torch tensor or TensorFlow tensor (both have .numpy() method)
|
| 202 |
if hasattr(x, 'numpy'):
|
| 203 |
# For torch tensors on CUDA, need to move to CPU first
|
|
@@ -205,15 +210,67 @@ def _to_numpy(x):
|
|
| 205 |
return x.cpu().numpy()
|
| 206 |
# For TensorFlow or already on CPU
|
| 207 |
return x.numpy()
|
| 208 |
-
|
| 209 |
# JAX arrays and other array-like objects that support __array__ protocol
|
| 210 |
if hasattr(x, '__array__'):
|
| 211 |
return np.asarray(x)
|
| 212 |
-
|
| 213 |
# Python primitives (list, tuple, int, float)
|
| 214 |
return np.array(x)
|
| 215 |
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
class MossVLImagesKwargs(ImagesKwargs):
|
| 218 |
min_pixels: Optional[int]
|
| 219 |
max_pixels: Optional[int]
|
|
@@ -277,20 +334,20 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 277 |
tokenizer_class = ("Qwen2Tokenizer", "Qwen2TokenizerFast")
|
| 278 |
|
| 279 |
def __init__(
|
| 280 |
-
self,
|
| 281 |
-
image_processor=None,
|
| 282 |
-
tokenizer=None,
|
| 283 |
-
video_processor=None,
|
| 284 |
chat_template=None,
|
| 285 |
**kwargs
|
| 286 |
):
|
| 287 |
super().__init__(image_processor, tokenizer, video_processor, chat_template=chat_template)
|
| 288 |
-
|
| 289 |
|
| 290 |
self.image_token = "<|image_pad|>" if not hasattr(tokenizer, "image_token") else tokenizer.image_token
|
| 291 |
self.video_token = "<|video_pad|>" if not hasattr(tokenizer, "video_token") else tokenizer.video_token
|
| 292 |
|
| 293 |
-
|
| 294 |
self.image_token_id = (
|
| 295 |
tokenizer.image_token_id
|
| 296 |
if getattr(tokenizer, "image_token_id", None)
|
|
@@ -301,7 +358,7 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 301 |
if getattr(tokenizer, "video_token_id", None)
|
| 302 |
else tokenizer.convert_tokens_to_ids(self.video_token)
|
| 303 |
)
|
| 304 |
-
|
| 305 |
self.vision_start_token = (
|
| 306 |
"<|vision_start|>" if not hasattr(tokenizer, "vision_start_token") else tokenizer.vision_start_token
|
| 307 |
)
|
|
@@ -315,15 +372,15 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 315 |
|
| 316 |
self.time_start_token = "<|time_start|>"
|
| 317 |
self.time_end_token = "<|time_end|>"
|
| 318 |
-
|
| 319 |
# EOS token for labels generation (assistant's response should end with this)
|
| 320 |
self.im_end_token = "<|im_end|>"
|
| 321 |
self.im_end_token_id = tokenizer.convert_tokens_to_ids(self.im_end_token)
|
| 322 |
-
|
| 323 |
# Vision-related token ids (all should be masked in labels)
|
| 324 |
self.vision_start_token_id = tokenizer.convert_tokens_to_ids(self.vision_start_token)
|
| 325 |
self.vision_end_token_id = tokenizer.convert_tokens_to_ids(self.vision_end_token)
|
| 326 |
-
|
| 327 |
# Token ids that should always be masked in labels (e.g. <|image_pad|>)
|
| 328 |
self.mask_token_ids = {self.image_token_id}
|
| 329 |
|
|
@@ -387,7 +444,7 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 387 |
tokenizer_init_kwargs=self.tokenizer.init_kwargs,
|
| 388 |
**kwargs,
|
| 389 |
)
|
| 390 |
-
|
| 391 |
# Step 1: Process images if provided
|
| 392 |
if images is not None:
|
| 393 |
images_kwargs = output_kwargs["images_kwargs"].copy()
|
|
@@ -417,12 +474,12 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 417 |
# Step 3: Process text with placeholder replacement
|
| 418 |
if text is None or (isinstance(text, str) and len(text.strip()) == 0):
|
| 419 |
raise ValueError("Text input is required for MossVL processor and cannot be empty.")
|
| 420 |
-
|
| 421 |
if not isinstance(text, list):
|
| 422 |
text = [text]
|
| 423 |
-
|
| 424 |
text = text.copy() # Copy to avoid in-place modifications
|
| 425 |
-
|
| 426 |
# Prepare labels_spans if provided
|
| 427 |
# labels_spans format: List[List[List[int]]] - batch of samples, each sample has multiple spans
|
| 428 |
# Each span is [start, end] (list, not tuple) so it can be modified in place
|
|
@@ -439,10 +496,10 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 439 |
has_images = images is not None and "pixel_values" in image_inputs
|
| 440 |
has_videos = videos is not None and "pixel_values_videos" in videos_inputs
|
| 441 |
needs_reorder = has_images and has_videos
|
| 442 |
-
|
| 443 |
image_pixel_values_list = []
|
| 444 |
video_pixel_values_list = []
|
| 445 |
-
|
| 446 |
# Step 3.0: Record the order of media in original text (before replacement)
|
| 447 |
# This will be used later to correctly order pixel_values and grid_thw
|
| 448 |
media_order_per_sample = []
|
|
@@ -453,25 +510,25 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 453 |
while pos < len(temp_text):
|
| 454 |
img_pos = temp_text.find(self.image_placeholder, pos)
|
| 455 |
vid_pos = temp_text.find(self.video_placeholder, pos)
|
| 456 |
-
|
| 457 |
if img_pos == -1 and vid_pos == -1:
|
| 458 |
break
|
| 459 |
-
|
| 460 |
if img_pos != -1 and (vid_pos == -1 or img_pos < vid_pos):
|
| 461 |
media_order.append(("image", img_pos))
|
| 462 |
pos = img_pos + len(self.image_placeholder)
|
| 463 |
elif vid_pos != -1:
|
| 464 |
media_order.append(("video", vid_pos))
|
| 465 |
pos = vid_pos + len(self.video_placeholder)
|
| 466 |
-
|
| 467 |
media_order_per_sample.append(media_order)
|
| 468 |
-
|
| 469 |
# Step 3.0.1: Check if any sample has no media (empty samples need blank image)
|
| 470 |
# If there are empty samples, we need to enter slow path to handle them properly
|
| 471 |
has_empty_samples = any(len(order) == 0 for order in media_order_per_sample)
|
| 472 |
if has_empty_samples:
|
| 473 |
needs_reorder = True
|
| 474 |
-
|
| 475 |
# Split pixel values for reordering if needed
|
| 476 |
if needs_reorder:
|
| 477 |
if has_images:
|
|
@@ -485,7 +542,9 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 485 |
elif len(patch_counts) > 1:
|
| 486 |
# Multiple images: split by cumulative counts
|
| 487 |
split_indices = np.cumsum(patch_counts)[:-1]
|
| 488 |
-
image_pixel_values_list =
|
|
|
|
|
|
|
| 489 |
|
| 490 |
if has_videos:
|
| 491 |
flat_video_values = videos_inputs["pixel_values_videos"]
|
|
@@ -497,8 +556,10 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 497 |
elif len(video_patch_counts) > 1:
|
| 498 |
# Multiple videos: split by cumulative counts
|
| 499 |
split_indices = np.cumsum(video_patch_counts)[:-1]
|
| 500 |
-
video_pixel_values_list =
|
| 501 |
-
|
|
|
|
|
|
|
| 502 |
# Step 3.1: Replace placeholders (simple replacement, no expansion yet)
|
| 503 |
# In MossVL, one image placeholder = one image token
|
| 504 |
# One video placeholder = one video token (will be expanded later)
|
|
@@ -515,33 +576,33 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 515 |
else:
|
| 516 |
text[i] = text[i].replace(self.image_placeholder, self.image_token)
|
| 517 |
text[i] = text[i].replace(self.video_placeholder, self.video_token)
|
| 518 |
-
|
| 519 |
-
# Step 3.2: Validate token counts
|
| 520 |
n_images_in_text = [t.count(self.image_token) for t in text]
|
| 521 |
n_videos_in_text = [t.count(self.video_token) for t in text]
|
| 522 |
-
|
| 523 |
# Count placeholders in text
|
| 524 |
total_images_in_text = sum(n_images_in_text)
|
| 525 |
total_videos_in_text = sum(n_videos_in_text)
|
| 526 |
-
|
| 527 |
# Count actual images and videos provided
|
| 528 |
total_images_provided = len(image_grid_thw) if image_grid_thw is not None else 0
|
| 529 |
total_videos_provided = len(video_grid_thw) if video_grid_thw is not None else 0
|
| 530 |
-
|
| 531 |
# Validate image counts
|
| 532 |
if total_images_in_text != total_images_provided:
|
| 533 |
raise ValueError(
|
| 534 |
"Number of image tokens does not match number of images provided. "
|
| 535 |
f"Found {total_images_in_text} image tokens in text and {total_images_provided} images."
|
| 536 |
)
|
| 537 |
-
|
| 538 |
# Validate video counts
|
| 539 |
if total_videos_in_text != total_videos_provided:
|
| 540 |
raise ValueError(
|
| 541 |
"Number of video tokens does not match number of videos provided. "
|
| 542 |
f"Found {total_videos_in_text} video tokens in text and {total_videos_provided} videos."
|
| 543 |
)
|
| 544 |
-
|
| 545 |
# Step 3.3: Expand video tokens with timestamps
|
| 546 |
# Now expand each video token to multiple tokens (one per frame) with timestamps
|
| 547 |
if video_grid_thw is not None:
|
|
@@ -579,10 +640,10 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 579 |
video_tokens.append(
|
| 580 |
f"{self.time_start_token}{curr_time:.1f} seconds{self.time_end_token}{self.image_token}"
|
| 581 |
)
|
| 582 |
-
|
| 583 |
# Wrap the entire video sequence with vision_start and vision_end tokens
|
| 584 |
video_placeholder = f"{self.vision_start_token}{''.join(video_tokens)}{self.vision_end_token}"
|
| 585 |
-
|
| 586 |
# Replace the video token with expanded sequence and update spans if needed
|
| 587 |
if should_create_labels:
|
| 588 |
text[i], labels_spans[i] = self._replace_and_update_spans(
|
|
@@ -591,22 +652,22 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 591 |
else:
|
| 592 |
text[i] = text[i].replace(self.video_token, video_placeholder, 1)
|
| 593 |
index += 1
|
| 594 |
-
|
| 595 |
|
| 596 |
|
| 597 |
# Step 4: Tokenize text
|
| 598 |
return_tensors = output_kwargs["text_kwargs"].pop("return_tensors", None)
|
| 599 |
return_mm_token_type_ids = output_kwargs["text_kwargs"].pop("return_mm_token_type_ids", None)
|
| 600 |
-
|
| 601 |
# Request offset_mapping if we need to create labels
|
| 602 |
if should_create_labels:
|
| 603 |
output_kwargs["text_kwargs"]["return_offsets_mapping"] = True
|
| 604 |
-
|
| 605 |
text_inputs = self.tokenizer(text, **output_kwargs["text_kwargs"])
|
| 606 |
-
|
| 607 |
# ignore check_special_mm_tokens nums in test and input ids.
|
| 608 |
# self._check_special_mm_tokens(text, text_inputs, modalities=["image", "video"])
|
| 609 |
-
|
| 610 |
# Create labels if labels_spans was provided
|
| 611 |
if should_create_labels:
|
| 612 |
offset_mapping = text_inputs.pop("offset_mapping")
|
|
@@ -626,49 +687,49 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 626 |
# Step 5: Concatenate pixel_values and grid_thw in sequence order
|
| 627 |
# Prepare output
|
| 628 |
output_data = {**text_inputs}
|
| 629 |
-
|
| 630 |
if not needs_reorder:
|
| 631 |
# Fast path: only one media type, no reordering needed
|
| 632 |
final_pixel_values = []
|
| 633 |
final_grid_thw = []
|
| 634 |
-
|
| 635 |
if has_images:
|
| 636 |
final_pixel_values.append(image_inputs["pixel_values"])
|
| 637 |
final_grid_thw.extend(image_grid_thw)
|
| 638 |
-
|
| 639 |
if has_videos:
|
| 640 |
final_pixel_values.append(videos_inputs["pixel_values_videos"])
|
| 641 |
final_grid_thw.extend(video_grid_thw)
|
| 642 |
-
|
| 643 |
if final_pixel_values:
|
| 644 |
output_data["pixel_values"] = np.concatenate(final_pixel_values, axis=0) if len(final_pixel_values) > 1 else final_pixel_values[0]
|
| 645 |
-
|
| 646 |
if final_grid_thw:
|
| 647 |
output_data["grid_thw"] = np.stack(final_grid_thw, axis=0)
|
| 648 |
-
|
| 649 |
# Calculate media_nums_per_sample
|
| 650 |
media_nums_per_sample = []
|
| 651 |
for batch_idx in range(len(text)):
|
| 652 |
media_order = media_order_per_sample[batch_idx]
|
| 653 |
media_nums_per_sample.append(len(media_order) if len(media_order) > 0 else 1)
|
| 654 |
-
|
| 655 |
# Don't add media_nums_per_sample to output_data yet
|
| 656 |
# Will add it after BatchFeature to keep it as list
|
| 657 |
-
|
| 658 |
else:
|
| 659 |
# Slow path: both images and videos exist, need reordering
|
| 660 |
final_pixel_values = []
|
| 661 |
final_grid_thw = []
|
| 662 |
media_nums_per_sample = []
|
| 663 |
-
|
| 664 |
# Global indices to track position in flattened image/video arrays
|
| 665 |
global_image_idx = 0
|
| 666 |
global_video_idx = 0
|
| 667 |
-
|
| 668 |
for batch_idx in range(len(text)):
|
| 669 |
# Use the recorded media order from Step 3.0
|
| 670 |
media_order = media_order_per_sample[batch_idx]
|
| 671 |
-
|
| 672 |
if len(media_order) == 0:
|
| 673 |
# If no media provided for this sample, add a blank image
|
| 674 |
media_nums_per_sample.append(1)
|
|
@@ -676,26 +737,26 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 676 |
patch_size = getattr(self.image_processor, "patch_size", None) or 16
|
| 677 |
temporal_patch_size = getattr(self.image_processor, "temporal_patch_size", None) or 1
|
| 678 |
merge_size = getattr(self.image_processor, "merge_size", None) or 2
|
| 679 |
-
|
| 680 |
factor = patch_size * merge_size
|
| 681 |
side = int(np.ceil(np.sqrt(min_pixels) / factor) * factor)
|
| 682 |
grid_h = side // patch_size
|
| 683 |
grid_w = side // patch_size
|
| 684 |
grid_t = 1
|
| 685 |
-
|
| 686 |
# Channel = 3 (RGB)
|
| 687 |
channel = 3
|
| 688 |
dim = channel * temporal_patch_size * patch_size * patch_size
|
| 689 |
num_patches = grid_t * grid_h * grid_w
|
| 690 |
-
|
| 691 |
blank_pixel_values = np.zeros((num_patches, dim), dtype=np.float32)
|
| 692 |
blank_grid_thw = np.array([grid_t, grid_h, grid_w], dtype=np.int64)
|
| 693 |
-
|
| 694 |
final_pixel_values.append(blank_pixel_values)
|
| 695 |
final_grid_thw.append(blank_grid_thw)
|
| 696 |
else:
|
| 697 |
media_nums_per_sample.append(len(media_order))
|
| 698 |
-
|
| 699 |
# Collect media data according to the recorded order
|
| 700 |
for media_type, _ in media_order:
|
| 701 |
if media_type == "image" and image_grid_thw is not None:
|
|
@@ -710,14 +771,18 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 710 |
final_pixel_values.append(video_pixel_values_list[global_video_idx])
|
| 711 |
final_grid_thw.append(video_grid_thw[global_video_idx])
|
| 712 |
global_video_idx += 1
|
| 713 |
-
|
| 714 |
# Concatenate/stack to unified format
|
| 715 |
if final_pixel_values:
|
| 716 |
-
output_data["pixel_values"] =
|
| 717 |
-
|
|
|
|
|
|
|
| 718 |
if final_grid_thw:
|
| 719 |
-
output_data["grid_thw"] =
|
| 720 |
-
|
|
|
|
|
|
|
| 721 |
# Don't add media_nums_per_sample to output_data yet
|
| 722 |
# Will add it after BatchFeature to keep it as list
|
| 723 |
|
|
@@ -730,18 +795,18 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 730 |
output_data.get("attention_mask", None)
|
| 731 |
)
|
| 732 |
output_data["cross_attention_mask"] = cross_attention_mask
|
| 733 |
-
|
| 734 |
# Add labels to output if created
|
| 735 |
if should_create_labels:
|
| 736 |
output_data["labels"] = labels
|
| 737 |
|
| 738 |
# BatchFeature will handle conversion to pt/tf/jax/np based on tensor_type
|
| 739 |
batch_feature = BatchFeature(data=output_data, tensor_type=return_tensors)
|
| 740 |
-
|
| 741 |
# Add media_nums_per_sample after BatchFeature to keep it as list (not tensor)
|
| 742 |
if media_nums_per_sample:
|
| 743 |
batch_feature["media_nums_per_sample"] = media_nums_per_sample
|
| 744 |
-
|
| 745 |
return batch_feature
|
| 746 |
|
| 747 |
def _create_cross_attention_mask(self, input_ids, grid_thw, media_nums_per_sample, attention_mask=None):
|
|
@@ -750,7 +815,7 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 750 |
Video frames are treated as individual images.
|
| 751 |
Mask values: True for masked, False for visible.
|
| 752 |
Causal masking: text can see images that appear at or before the text position.
|
| 753 |
-
|
| 754 |
Args:
|
| 755 |
input_ids: List of token ids
|
| 756 |
grid_thw: Grid sizes for each media item
|
|
@@ -759,7 +824,7 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 759 |
"""
|
| 760 |
batch_size = len(input_ids)
|
| 761 |
max_text_len = max(len(ids) for ids in input_ids)
|
| 762 |
-
|
| 763 |
# Calculate total frames per sample to find max_num_frames
|
| 764 |
total_frames_per_sample = []
|
| 765 |
media_idx = 0
|
|
@@ -768,46 +833,50 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 768 |
if num_media == 0:
|
| 769 |
total_frames_per_sample.append(0)
|
| 770 |
continue
|
| 771 |
-
|
| 772 |
sample_frames = 0
|
| 773 |
for _ in range(num_media):
|
| 774 |
# grid_thw is (N, 3) where first dim is t (num_frames)
|
| 775 |
t = grid_thw[media_idx][0]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
sample_frames += t
|
| 777 |
media_idx += 1
|
| 778 |
total_frames_per_sample.append(sample_frames)
|
| 779 |
-
|
| 780 |
max_num_frames = max(total_frames_per_sample) if total_frames_per_sample else 0
|
| 781 |
-
|
| 782 |
if max_num_frames == 0:
|
| 783 |
return None
|
| 784 |
-
|
| 785 |
# Vectorized implementation for speed
|
| 786 |
-
|
| 787 |
# 1. Pad input_ids to create a tensor
|
| 788 |
# We use -1 as pad value since token ids are positive
|
| 789 |
input_ids_tensor = torch.full((batch_size, max_text_len), -1, dtype=torch.long)
|
| 790 |
for b, ids in enumerate(input_ids):
|
| 791 |
l = len(ids)
|
| 792 |
input_ids_tensor[b, :l] = torch.tensor(ids, dtype=torch.long)
|
| 793 |
-
|
| 794 |
# 2. Identify image tokens
|
| 795 |
is_image_token = (input_ids_tensor == self.image_token_id)
|
| 796 |
-
|
| 797 |
# 3. Compute cumulative image tokens (how many image tokens appeared up to position t)
|
| 798 |
# shape: (batch_size, text_len)
|
| 799 |
cum_image_tokens = is_image_token.cumsum(dim=1)
|
| 800 |
-
|
| 801 |
# 4. Create frame indices
|
| 802 |
# shape: (1, 1, max_num_frames)
|
| 803 |
frame_indices = torch.arange(max_num_frames).reshape(1, 1, -1)
|
| 804 |
-
|
| 805 |
# 5. Determine visibility based on causal relationship
|
| 806 |
# Text at `t` sees frame `i` if `cum_image_tokens[t] > i`
|
| 807 |
# Because if frame `i` is the (i+1)-th image token, it becomes visible when count reaches i+1
|
| 808 |
# shape: (batch_size, text_len, max_num_frames)
|
| 809 |
visible_mask = cum_image_tokens.unsqueeze(-1) > frame_indices
|
| 810 |
-
|
| 811 |
# 6. Apply attention_mask if provided
|
| 812 |
if attention_mask is not None:
|
| 813 |
# Convert to tensor if needed
|
|
@@ -819,25 +888,25 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 819 |
for b, mask_row in enumerate(attention_mask):
|
| 820 |
l = len(mask_row)
|
| 821 |
attn_mask_tensor[b, :l] = torch.tensor(mask_row, dtype=torch.long)
|
| 822 |
-
|
| 823 |
# shape: (batch_size, text_len, 1)
|
| 824 |
valid_text = (attn_mask_tensor.unsqueeze(-1) == 1)
|
| 825 |
visible_mask = visible_mask & valid_text
|
| 826 |
-
|
| 827 |
# 7. Mask out frames that don't exist for a sample
|
| 828 |
# shape: (batch_size, 1, 1)
|
| 829 |
total_frames_tensor = torch.tensor(total_frames_per_sample).reshape(batch_size, 1, 1)
|
| 830 |
# shape: (batch_size, 1, max_num_frames)
|
| 831 |
valid_frames = frame_indices < total_frames_tensor
|
| 832 |
-
|
| 833 |
visible_mask = visible_mask & valid_frames
|
| 834 |
-
|
| 835 |
# 8. Create final mask (True for masked, False for visible)
|
| 836 |
mask = ~visible_mask
|
| 837 |
-
|
| 838 |
# 9. Add channel dimension: (batch_size, 1, text_len, max_num_frames)
|
| 839 |
mask = mask.unsqueeze(1)
|
| 840 |
-
|
| 841 |
return mask
|
| 842 |
|
| 843 |
def _replace_and_update_spans(
|
|
@@ -850,14 +919,14 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 850 |
) -> tuple:
|
| 851 |
"""
|
| 852 |
Replace occurrences of old_str with new_str and update spans accordingly.
|
| 853 |
-
|
| 854 |
Args:
|
| 855 |
text: The text to perform replacement on
|
| 856 |
old_str: String to be replaced
|
| 857 |
new_str: String to replace with
|
| 858 |
spans: List of [start, end] spans to update (modified in place)
|
| 859 |
replace_count: Maximum number of replacements (-1 for all)
|
| 860 |
-
|
| 861 |
Returns:
|
| 862 |
Tuple of (new_text, updated_spans)
|
| 863 |
"""
|
|
@@ -865,14 +934,14 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 865 |
result_text = text
|
| 866 |
count = 0
|
| 867 |
search_start = 0
|
| 868 |
-
|
| 869 |
while True:
|
| 870 |
pos = result_text.find(old_str, search_start)
|
| 871 |
if pos == -1:
|
| 872 |
break
|
| 873 |
if replace_count != -1 and count >= replace_count:
|
| 874 |
break
|
| 875 |
-
|
| 876 |
# Update all spans that come after this position
|
| 877 |
for span in spans:
|
| 878 |
if span[0] > pos:
|
|
@@ -882,12 +951,12 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 882 |
elif span[1] > pos:
|
| 883 |
# Span ends after replacement point (spans the replacement)
|
| 884 |
span[1] += delta
|
| 885 |
-
|
| 886 |
# Perform the replacement
|
| 887 |
result_text = result_text[:pos] + new_str + result_text[pos + len(old_str):]
|
| 888 |
search_start = pos + len(new_str)
|
| 889 |
count += 1
|
| 890 |
-
|
| 891 |
return result_text, spans
|
| 892 |
|
| 893 |
def _create_labels_from_spans(
|
|
@@ -900,7 +969,7 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 900 |
) -> List[List[int]]:
|
| 901 |
"""
|
| 902 |
Create labels from spans and offset_mapping.
|
| 903 |
-
|
| 904 |
Args:
|
| 905 |
input_ids: Tokenized input ids
|
| 906 |
offset_mapping: Character offsets for each token from tokenizer (special tokens included)
|
|
@@ -909,10 +978,10 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 909 |
mask_token_ids: Set of token ids that should always be masked (set to ignore_index)
|
| 910 |
in labels, regardless of whether they fall inside a span.
|
| 911 |
Defaults to self.mask_token_ids if not provided.
|
| 912 |
-
|
| 913 |
Returns:
|
| 914 |
labels: List of label ids, same shape as input_ids
|
| 915 |
-
|
| 916 |
Note:
|
| 917 |
- Tokenizer's offset_mapping already includes correct offsets for special tokens in text
|
| 918 |
- Only need to mask tokens inside <|vision_start|>...<|vision_end|>
|
|
@@ -921,20 +990,20 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 921 |
"""
|
| 922 |
if mask_token_ids is None:
|
| 923 |
mask_token_ids = self.mask_token_ids
|
| 924 |
-
|
| 925 |
batch_labels = []
|
| 926 |
-
|
| 927 |
for batch_idx in range(len(input_ids)):
|
| 928 |
ids = input_ids[batch_idx]
|
| 929 |
offsets = offset_mapping[batch_idx]
|
| 930 |
spans = labels_spans[batch_idx]
|
| 931 |
-
|
| 932 |
labels = [ignore_index] * len(ids)
|
| 933 |
-
|
| 934 |
# Process each span: find token range and set labels
|
| 935 |
for span_start, span_end in spans:
|
| 936 |
in_vision = False
|
| 937 |
-
|
| 938 |
# Find tokens that overlap with this span
|
| 939 |
for token_idx, (token_id, (char_start, char_end)) in enumerate(zip(ids, offsets)):
|
| 940 |
# Skip tokens completely before this span
|
|
@@ -943,7 +1012,7 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 943 |
# Stop when tokens are completely after this span
|
| 944 |
if char_start >= span_end:
|
| 945 |
break
|
| 946 |
-
|
| 947 |
# Token overlaps with span, process it
|
| 948 |
# Track vision region: <|vision_start|> ... <|vision_end|>
|
| 949 |
if token_id == self.vision_start_token_id:
|
|
@@ -952,34 +1021,34 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 952 |
if token_id == self.vision_end_token_id:
|
| 953 |
in_vision = False
|
| 954 |
continue
|
| 955 |
-
|
| 956 |
# Skip tokens inside vision region
|
| 957 |
if in_vision:
|
| 958 |
continue
|
| 959 |
-
|
| 960 |
# Always mask special tokens that should never have labels
|
| 961 |
if token_id in mask_token_ids:
|
| 962 |
continue
|
| 963 |
-
|
| 964 |
# Set label for this token
|
| 965 |
labels[token_idx] = token_id
|
| 966 |
-
|
| 967 |
batch_labels.append(labels)
|
| 968 |
-
|
| 969 |
return batch_labels
|
| 970 |
|
| 971 |
def _calculate_timestamps(
|
| 972 |
-
self,
|
| 973 |
-
frames_indices: Optional[Union[List[int], np.ndarray]],
|
| 974 |
-
total_num_frames: int,
|
| 975 |
-
video_fps: float,
|
| 976 |
-
duration: float,
|
| 977 |
merge_size: int = 1,
|
| 978 |
actual_timestamps: Optional[List[float]] = None
|
| 979 |
):
|
| 980 |
"""
|
| 981 |
Calculate timestamps for video frames.
|
| 982 |
-
|
| 983 |
Args:
|
| 984 |
frames_indices: Actual frame indices extracted (if available)
|
| 985 |
total_num_frames: Total number of sampled frames
|
|
@@ -987,25 +1056,25 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 987 |
duration: Video duration in seconds
|
| 988 |
merge_size: Temporal merge size
|
| 989 |
actual_timestamps: Pre-calculated actual timestamps (for segments)
|
| 990 |
-
|
| 991 |
Returns:
|
| 992 |
List of timestamps (one per merged temporal patch)
|
| 993 |
"""
|
| 994 |
# If actual timestamps are provided (from segment), use them directly
|
| 995 |
if actual_timestamps is not None:
|
| 996 |
timestamps = list(actual_timestamps)
|
| 997 |
-
|
| 998 |
# Pad timestamps to be multiple of merge_size
|
| 999 |
if len(timestamps) % merge_size != 0:
|
| 1000 |
timestamps.extend([timestamps[-1]] * (merge_size - len(timestamps) % merge_size))
|
| 1001 |
-
|
| 1002 |
# Frames are merged by merge_size, so we average the timestamps within each temporal patch
|
| 1003 |
timestamps = [
|
| 1004 |
-
(timestamps[i] + timestamps[i + merge_size - 1]) / 2
|
| 1005 |
for i in range(0, len(timestamps), merge_size)
|
| 1006 |
]
|
| 1007 |
return timestamps
|
| 1008 |
-
|
| 1009 |
# Use frames_indices if available, otherwise generate uniformly sampled indices
|
| 1010 |
if frames_indices is not None:
|
| 1011 |
if isinstance(frames_indices, np.ndarray):
|
|
@@ -1019,24 +1088,24 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 1019 |
else:
|
| 1020 |
# Uniformly sample frames across the video duration
|
| 1021 |
indices = np.linspace(0, duration * video_fps - 1, total_num_frames).astype(np.int32).tolist()
|
| 1022 |
-
|
| 1023 |
# Pad indices to be multiple of merge_size
|
| 1024 |
if len(indices) % merge_size != 0:
|
| 1025 |
indices.extend([indices[-1]] * (merge_size - len(indices) % merge_size))
|
| 1026 |
-
|
| 1027 |
# Convert frame indices to timestamps
|
| 1028 |
timestamps = [idx / video_fps for idx in indices]
|
| 1029 |
-
|
| 1030 |
# Frames are merged by merge_size, so we average the timestamps within each temporal patch
|
| 1031 |
timestamps = [
|
| 1032 |
-
(timestamps[i] + timestamps[i + merge_size - 1]) / 2
|
| 1033 |
for i in range(0, len(timestamps), merge_size)
|
| 1034 |
]
|
| 1035 |
return timestamps
|
| 1036 |
|
| 1037 |
def batch_decode(self, *args, **kwargs):
|
| 1038 |
"""
|
| 1039 |
-
This method forwards all its arguments to the tokenizer's batch_decode.
|
| 1040 |
Please refer to the docstring of this method for more information.
|
| 1041 |
"""
|
| 1042 |
return self.tokenizer.batch_decode(*args, **kwargs)
|
|
@@ -1056,7 +1125,7 @@ class MossVLProcessor(ProcessorMixin):
|
|
| 1056 |
|
| 1057 |
Args:
|
| 1058 |
generated_outputs (`torch.Tensor` or `np.ndarray`):
|
| 1059 |
-
The output of the model `generate` function. The output is expected to be a tensor
|
| 1060 |
of shape `(batch_size, sequence_length)` or `(sequence_length,)`.
|
| 1061 |
skip_special_tokens (`bool`, *optional*, defaults to `True`):
|
| 1062 |
Whether or not to remove special tokens in the output.
|
|
|
|
| 49 |
"""
|
| 50 |
# Multi-image batch total pixels limit (read from config)
|
| 51 |
multi_image_max_pixels = None
|
| 52 |
+
|
| 53 |
|
| 54 |
def _preprocess(
|
| 55 |
self,
|
|
|
|
| 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.
|
|
|
|
| 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,
|
|
|
|
| 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 |
|
| 221 |
|
| 222 |
+
def _split_array_or_tensor(x, split_indices):
|
| 223 |
+
"""Split along the first dimension while preserving tensor/array type."""
|
| 224 |
+
split_indices = [int(idx) for idx in split_indices]
|
| 225 |
+
if isinstance(x, torch.Tensor):
|
| 226 |
+
if not split_indices:
|
| 227 |
+
return [x]
|
| 228 |
+
chunks = []
|
| 229 |
+
start = 0
|
| 230 |
+
for end in split_indices:
|
| 231 |
+
chunks.append(x[start:end])
|
| 232 |
+
start = end
|
| 233 |
+
chunks.append(x[start:])
|
| 234 |
+
return chunks
|
| 235 |
+
return np.split(x, split_indices)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _concat_array_or_tensor(items, axis=0):
|
| 239 |
+
"""Concatenate while preserving tensor/array type and device."""
|
| 240 |
+
if not items:
|
| 241 |
+
return None
|
| 242 |
+
|
| 243 |
+
if any(isinstance(item, torch.Tensor) for item in items):
|
| 244 |
+
ref = next(item for item in items if isinstance(item, torch.Tensor))
|
| 245 |
+
tensor_items = [
|
| 246 |
+
item
|
| 247 |
+
if isinstance(item, torch.Tensor)
|
| 248 |
+
else torch.as_tensor(item, device=ref.device, dtype=ref.dtype)
|
| 249 |
+
for item in items
|
| 250 |
+
]
|
| 251 |
+
return torch.cat(tensor_items, dim=axis)
|
| 252 |
+
|
| 253 |
+
return np.concatenate(items, axis=axis)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _stack_array_or_tensor(items, axis=0):
|
| 257 |
+
"""Stack while preserving tensor/array type and device."""
|
| 258 |
+
if not items:
|
| 259 |
+
return None
|
| 260 |
+
|
| 261 |
+
if any(isinstance(item, torch.Tensor) for item in items):
|
| 262 |
+
ref = next(item for item in items if isinstance(item, torch.Tensor))
|
| 263 |
+
tensor_items = [
|
| 264 |
+
item
|
| 265 |
+
if isinstance(item, torch.Tensor)
|
| 266 |
+
else torch.as_tensor(item, device=ref.device, dtype=ref.dtype)
|
| 267 |
+
for item in items
|
| 268 |
+
]
|
| 269 |
+
return torch.stack(tensor_items, dim=axis)
|
| 270 |
+
|
| 271 |
+
return np.stack(items, axis=axis)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
class MossVLImagesKwargs(ImagesKwargs):
|
| 275 |
min_pixels: Optional[int]
|
| 276 |
max_pixels: Optional[int]
|
|
|
|
| 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:
|
|
|
|
| 542 |
elif len(patch_counts) > 1:
|
| 543 |
# Multiple images: split by cumulative counts
|
| 544 |
split_indices = np.cumsum(patch_counts)[:-1]
|
| 545 |
+
image_pixel_values_list = _split_array_or_tensor(
|
| 546 |
+
flat_pixel_values, split_indices
|
| 547 |
+
)
|
| 548 |
|
| 549 |
if has_videos:
|
| 550 |
flat_video_values = videos_inputs["pixel_values_videos"]
|
|
|
|
| 556 |
elif len(video_patch_counts) > 1:
|
| 557 |
# Multiple videos: split by cumulative counts
|
| 558 |
split_indices = np.cumsum(video_patch_counts)[:-1]
|
| 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)
|
| 840 |
t = grid_thw[media_idx][0]
|
| 841 |
+
if isinstance(t, torch.Tensor):
|
| 842 |
+
t = int(t.item())
|
| 843 |
+
else:
|
| 844 |
+
t = int(t)
|
| 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.
|
tokenizer.json
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e7bbd0f9784004df51aca562befc3c7a8f294b4045aa8685536c35804c9aa493
|
| 3 |
+
size 11423411
|
tokenizer_config.json
CHANGED
|
@@ -225,6 +225,22 @@
|
|
| 225 |
"rstrip": false,
|
| 226 |
"single_word": false,
|
| 227 |
"special": true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
}
|
| 229 |
},
|
| 230 |
"additional_special_tokens": [
|
|
@@ -242,7 +258,9 @@
|
|
| 242 |
"<|image_pad|>",
|
| 243 |
"<|video_pad|>",
|
| 244 |
"<|time_start|>",
|
| 245 |
-
"<|time_end|>"
|
|
|
|
|
|
|
| 246 |
],
|
| 247 |
"bos_token": null,
|
| 248 |
"clean_up_tokenization_spaces": false,
|
|
|
|
| 225 |
"rstrip": false,
|
| 226 |
"single_word": false,
|
| 227 |
"special": true
|
| 228 |
+
},
|
| 229 |
+
"151671": {
|
| 230 |
+
"content": "<|silence|>",
|
| 231 |
+
"lstrip": false,
|
| 232 |
+
"normalized": false,
|
| 233 |
+
"rstrip": false,
|
| 234 |
+
"single_word": false,
|
| 235 |
+
"special": true
|
| 236 |
+
},
|
| 237 |
+
"151672": {
|
| 238 |
+
"content": "<|response|>",
|
| 239 |
+
"lstrip": false,
|
| 240 |
+
"normalized": false,
|
| 241 |
+
"rstrip": false,
|
| 242 |
+
"single_word": false,
|
| 243 |
+
"special": true
|
| 244 |
}
|
| 245 |
},
|
| 246 |
"additional_special_tokens": [
|
|
|
|
| 258 |
"<|image_pad|>",
|
| 259 |
"<|video_pad|>",
|
| 260 |
"<|time_start|>",
|
| 261 |
+
"<|time_end|>",
|
| 262 |
+
"<|silence|>",
|
| 263 |
+
"<|response|>"
|
| 264 |
],
|
| 265 |
"bos_token": null,
|
| 266 |
"clean_up_tokenization_spaces": false,
|
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,40 +42,9 @@ from transformers.video_utils import VideoMetadata, group_videos_by_shape, reord
|
|
| 38 |
|
| 39 |
logger = logging.get_logger(__name__)
|
| 40 |
|
| 41 |
-
|
| 42 |
TORCHCODEC_TIMESTAMP_EPSILON = 1e-6
|
| 43 |
|
| 44 |
|
| 45 |
-
def clamp_timestamps_for_torchcodec(timestamps: List[float], torchcodec_metadata) -> List[float]:
|
| 46 |
-
if not timestamps:
|
| 47 |
-
return timestamps
|
| 48 |
-
|
| 49 |
-
min_pts = torchcodec_metadata.begin_stream_seconds_from_content
|
| 50 |
-
if min_pts is None:
|
| 51 |
-
min_pts = 0.0
|
| 52 |
-
# TorchCodec can reject timestamps exactly equal to the reported stream
|
| 53 |
-
# begin due to tiny metadata/decoder precision differences.
|
| 54 |
-
safe_min_pts = min_pts + TORCHCODEC_TIMESTAMP_EPSILON
|
| 55 |
-
|
| 56 |
-
max_pts_candidates = []
|
| 57 |
-
if torchcodec_metadata.num_frames_from_content and torchcodec_metadata.average_fps:
|
| 58 |
-
max_pts_candidates.append(
|
| 59 |
-
(torchcodec_metadata.num_frames_from_content - 1) / torchcodec_metadata.average_fps + min_pts
|
| 60 |
-
)
|
| 61 |
-
if torchcodec_metadata.end_stream_seconds_from_content is not None:
|
| 62 |
-
# TorchCodec requires requested PTS to be strictly smaller than the content end.
|
| 63 |
-
max_pts_candidates.append(torchcodec_metadata.end_stream_seconds_from_content - TORCHCODEC_TIMESTAMP_EPSILON)
|
| 64 |
-
if not max_pts_candidates and torchcodec_metadata.duration_seconds is not None:
|
| 65 |
-
max_pts_candidates.append(torchcodec_metadata.duration_seconds - TORCHCODEC_TIMESTAMP_EPSILON)
|
| 66 |
-
|
| 67 |
-
if max_pts_candidates:
|
| 68 |
-
max_pts = max(safe_min_pts, min(max_pts_candidates))
|
| 69 |
-
return [max(safe_min_pts, min(float(t), max_pts)) for t in timestamps]
|
| 70 |
-
if safe_min_pts > 0:
|
| 71 |
-
return [max(safe_min_pts, float(t)) for t in timestamps]
|
| 72 |
-
return [float(t) for t in timestamps]
|
| 73 |
-
|
| 74 |
-
|
| 75 |
# -----------------------------------------------------------------------------
|
| 76 |
# Torchcodec video frame extraction utilities
|
| 77 |
# -----------------------------------------------------------------------------
|
|
@@ -79,10 +52,10 @@ def clamp_timestamps_for_torchcodec(timestamps: List[float], torchcodec_metadata
|
|
| 79 |
def check_video_for_extra_streams_and_errors(video_path: str) -> dict:
|
| 80 |
"""
|
| 81 |
Check if video file has abnormal streams or errors reported by ffprobe.
|
| 82 |
-
|
| 83 |
Args:
|
| 84 |
video_path: Path to the video file.
|
| 85 |
-
|
| 86 |
Returns:
|
| 87 |
A dictionary containing:
|
| 88 |
- 'has_extra_streams': bool, whether there are streams other than video and audio.
|
|
@@ -100,7 +73,7 @@ def check_video_for_extra_streams_and_errors(video_path: str) -> dict:
|
|
| 100 |
'stream_details': [],
|
| 101 |
'num_streams': 0
|
| 102 |
}
|
| 103 |
-
|
| 104 |
command = [
|
| 105 |
"ffprobe",
|
| 106 |
"-v", "error",
|
|
@@ -109,7 +82,7 @@ def check_video_for_extra_streams_and_errors(video_path: str) -> dict:
|
|
| 109 |
"-of", "json",
|
| 110 |
video_path
|
| 111 |
]
|
| 112 |
-
|
| 113 |
try:
|
| 114 |
process = subprocess.run(
|
| 115 |
command,
|
|
@@ -118,12 +91,12 @@ def check_video_for_extra_streams_and_errors(video_path: str) -> dict:
|
|
| 118 |
check=False
|
| 119 |
)
|
| 120 |
result['ffprobe_successful'] = (process.returncode == 0)
|
| 121 |
-
|
| 122 |
if process.stderr:
|
| 123 |
result['ffprobe_output_error'] = process.stderr
|
| 124 |
unsupported_codec_pattern = re.compile(r"Unsupported codec with id \d+ for input stream \d+")
|
| 125 |
result['unsupported_codec_errors'] = unsupported_codec_pattern.findall(process.stderr)
|
| 126 |
-
|
| 127 |
if process.stdout:
|
| 128 |
ffprobe_data = json.loads(process.stdout)
|
| 129 |
if 'streams' in ffprobe_data:
|
|
@@ -134,7 +107,7 @@ def check_video_for_extra_streams_and_errors(video_path: str) -> dict:
|
|
| 134 |
result['stream_details'].append({'index': stream_index, 'codec_type': stream_type})
|
| 135 |
if stream_type not in ['video', 'audio']:
|
| 136 |
result['has_extra_streams'] = True
|
| 137 |
-
|
| 138 |
if 'format' in ffprobe_data and 'nb_streams' in ffprobe_data['format']:
|
| 139 |
if result['num_streams'] == 0:
|
| 140 |
result['num_streams'] = ffprobe_data['format']['nb_streams']
|
|
@@ -152,18 +125,18 @@ def check_video_for_extra_streams_and_errors(video_path: str) -> dict:
|
|
| 152 |
except Exception as e:
|
| 153 |
result['ffprobe_output_error'] = f"An unexpected error occurred: {e}"
|
| 154 |
result['ffprobe_successful'] = False
|
| 155 |
-
|
| 156 |
return result
|
| 157 |
|
| 158 |
|
| 159 |
def remove_video_extra_stream_ffmpeg(input_video: str, output_video: str) -> bool:
|
| 160 |
"""
|
| 161 |
Remove extra streams from video using ffmpeg.
|
| 162 |
-
|
| 163 |
Args:
|
| 164 |
input_video: Path to input video.
|
| 165 |
output_video: Path to output video.
|
| 166 |
-
|
| 167 |
Returns:
|
| 168 |
bool: True if successful, False otherwise.
|
| 169 |
"""
|
|
@@ -179,7 +152,7 @@ def remove_video_extra_stream_ffmpeg(input_video: str, output_video: str) -> boo
|
|
| 179 |
"-movflags", "faststart",
|
| 180 |
output_video,
|
| 181 |
]
|
| 182 |
-
|
| 183 |
try:
|
| 184 |
subprocess.run(command_list, shell=False, check=True, capture_output=True)
|
| 185 |
return True
|
|
@@ -195,13 +168,27 @@ def remove_video_extra_stream_ffmpeg(input_video: str, output_video: str) -> boo
|
|
| 195 |
return False
|
| 196 |
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
def clean_video_streams(video_path: str) -> str:
|
| 199 |
"""
|
| 200 |
Clean video streams if extra streams are detected.
|
| 201 |
-
|
| 202 |
Args:
|
| 203 |
video_path: Path to the video file.
|
| 204 |
-
|
| 205 |
Returns:
|
| 206 |
str: Path to cleaned video (or original if no cleaning needed).
|
| 207 |
"""
|
|
@@ -212,35 +199,85 @@ def clean_video_streams(video_path: str) -> str:
|
|
| 212 |
file_name_without_ext, file_ext = os.path.splitext(base_name)
|
| 213 |
new_base_name = f"{file_name_without_ext}_fix{file_ext}"
|
| 214 |
video_path_output = os.path.join(output_folder, new_base_name)
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
return video_path
|
| 220 |
-
return video_path_output
|
| 221 |
return video_path
|
| 222 |
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
def split_indices(indices: List[Union[int, float]], num_chunks: int) -> List[List[Union[int, float]]]:
|
| 225 |
"""
|
| 226 |
Split an index list into roughly equal chunks.
|
| 227 |
-
|
| 228 |
Args:
|
| 229 |
indices: List of indices to split.
|
| 230 |
num_chunks: Number of chunks to create.
|
| 231 |
-
|
| 232 |
Returns:
|
| 233 |
List of index chunks.
|
| 234 |
-
|
| 235 |
-
Raises:
|
| 236 |
-
ValueError: If indices is empty or num_chunks is not positive.
|
| 237 |
"""
|
| 238 |
if len(indices) == 0:
|
| 239 |
raise ValueError("indices must not be empty")
|
| 240 |
if num_chunks <= 0:
|
| 241 |
raise ValueError("num_chunks must be positive")
|
| 242 |
|
| 243 |
-
# Never create empty decode jobs when there are fewer frames than workers.
|
| 244 |
num_chunks = min(num_chunks, len(indices))
|
| 245 |
chunk_size = len(indices) // num_chunks
|
| 246 |
chunks = []
|
|
@@ -253,12 +290,12 @@ def split_indices(indices: List[Union[int, float]], num_chunks: int) -> List[Lis
|
|
| 253 |
def decode_sequentially(indices: List[int], video_path: str, ffmpeg_threads: int = 0):
|
| 254 |
"""
|
| 255 |
Decode frames sequentially from a video.
|
| 256 |
-
|
| 257 |
Args:
|
| 258 |
indices: List of frame indices 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 |
"""
|
|
@@ -272,12 +309,12 @@ def decode_sequentially(indices: List[int], video_path: str, ffmpeg_threads: int
|
|
| 272 |
def decode_with_multithreading(indices: List[int], num_threads: int, video_path: str) -> dict:
|
| 273 |
"""
|
| 274 |
Decode frames using multithreading with joblib.
|
| 275 |
-
|
| 276 |
Args:
|
| 277 |
indices: List of frame indices to decode.
|
| 278 |
num_threads: Number of threads to use.
|
| 279 |
video_path: Path to the video file.
|
| 280 |
-
|
| 281 |
Returns:
|
| 282 |
dict: Contains 'data', 'duration_seconds', 'pts_seconds' tensors.
|
| 283 |
"""
|
|
@@ -285,7 +322,7 @@ def decode_with_multithreading(indices: List[int], num_threads: int, video_path:
|
|
| 285 |
results = Parallel(n_jobs=len(chunks), prefer="threads", verbose=0)(
|
| 286 |
delayed(decode_sequentially)(chunk, video_path) for chunk in chunks
|
| 287 |
)
|
| 288 |
-
|
| 289 |
return {
|
| 290 |
"data": torch.cat([frame_batch.data for frame_batch in results], dim=0),
|
| 291 |
"duration_seconds": torch.cat([frame_batch.duration_seconds for frame_batch in results], dim=0),
|
|
@@ -296,19 +333,18 @@ def decode_with_multithreading(indices: List[int], num_threads: int, video_path:
|
|
| 296 |
def decode_sequentially_timestamp(timestamp_list: List[float], video_path: str, ffmpeg_threads: int = 0):
|
| 297 |
"""
|
| 298 |
Decode frames sequentially from a video based on timestamps.
|
| 299 |
-
|
| 300 |
Args:
|
| 301 |
timestamp_list: List of timestamps (in seconds) to decode.
|
| 302 |
video_path: Path to the video file.
|
| 303 |
ffmpeg_threads: Number of ffmpeg threads to use.
|
| 304 |
-
|
| 305 |
Returns:
|
| 306 |
FrameBatch from torchcodec.
|
| 307 |
"""
|
| 308 |
decoder = VideoDecoder(video_path, num_ffmpeg_threads=ffmpeg_threads)
|
| 309 |
try:
|
| 310 |
metadata = decoder.metadata
|
| 311 |
-
|
| 312 |
timestamp_list = clamp_timestamps_for_torchcodec(timestamp_list, metadata)
|
| 313 |
|
| 314 |
return decoder.get_frames_played_at(timestamp_list)
|
|
@@ -319,12 +355,12 @@ def decode_sequentially_timestamp(timestamp_list: List[float], video_path: str,
|
|
| 319 |
def timestamp_decode_with_multithreading(timestamp_list: List[float], num_threads: int, video_path: str) -> dict:
|
| 320 |
"""
|
| 321 |
Decode frames using multithreading based on timestamps.
|
| 322 |
-
|
| 323 |
Args:
|
| 324 |
timestamp_list: List of timestamps (in seconds) to decode.
|
| 325 |
num_threads: Number of threads to use.
|
| 326 |
video_path: Path to the video file.
|
| 327 |
-
|
| 328 |
Returns:
|
| 329 |
dict: Contains 'data', 'duration_seconds', 'pts_seconds' tensors.
|
| 330 |
"""
|
|
@@ -332,16 +368,16 @@ def timestamp_decode_with_multithreading(timestamp_list: List[float], num_thread
|
|
| 332 |
results = Parallel(n_jobs=len(chunks), prefer="threads", verbose=0)(
|
| 333 |
delayed(decode_sequentially_timestamp)(chunk, video_path) for chunk in chunks
|
| 334 |
)
|
| 335 |
-
|
| 336 |
# Concatenate results from all threads
|
| 337 |
data_list = [frame_batch.data for frame_batch in results]
|
| 338 |
duration_list = [frame_batch.duration_seconds for frame_batch in results]
|
| 339 |
pts_list = [frame_batch.pts_seconds for frame_batch in results]
|
| 340 |
-
|
| 341 |
if not data_list:
|
| 342 |
logger.warning("No frames were successfully decoded.")
|
| 343 |
return {"data": torch.empty(0), "duration_seconds": torch.empty(0), "pts_seconds": torch.empty(0)}
|
| 344 |
-
|
| 345 |
return {
|
| 346 |
"data": torch.cat(data_list, dim=0),
|
| 347 |
"duration_seconds": torch.cat(duration_list, dim=0),
|
|
@@ -357,42 +393,42 @@ def extract_frames_with_torchcodec(
|
|
| 357 |
) -> Optional[dict]:
|
| 358 |
"""
|
| 359 |
Extract frames from video using torchcodec with multithreading.
|
| 360 |
-
|
| 361 |
Args:
|
| 362 |
video_path: Path to the video file.
|
| 363 |
sample_frames_count: Number of frames to sample.
|
| 364 |
num_threads: Number of threads to use for extraction.
|
| 365 |
sampling_method: Sampling method, either "index" (uniform frame indices) or "timestamp" (uniform timestamps).
|
| 366 |
-
|
| 367 |
Returns:
|
| 368 |
dict: Contains 'data' (N, C, H, W), 'duration_seconds' (N,), 'pts_seconds' (N,) tensors.
|
| 369 |
Returns None if extraction fails.
|
| 370 |
"""
|
| 371 |
try:
|
| 372 |
-
video_path =
|
| 373 |
decoder = VideoDecoder(video_path, num_ffmpeg_threads=0)
|
| 374 |
metadata = decoder.metadata
|
| 375 |
|
| 376 |
|
| 377 |
total_frames_in_video = metadata.num_frames_from_content
|
| 378 |
-
|
| 379 |
effective_sample_count = min(sample_frames_count, total_frames_in_video)
|
| 380 |
if effective_sample_count == 0:
|
| 381 |
logger.error("Cannot extract frames: video has 0 frames or specified frame count is 0")
|
| 382 |
return None
|
| 383 |
-
|
| 384 |
# Generate uniform frame indices
|
| 385 |
frame_indices = np.linspace(0, total_frames_in_video - 1, effective_sample_count).astype(np.int32)
|
| 386 |
# Ensure indices are valid and remove duplicates
|
| 387 |
frame_indices = np.unique(np.clip(frame_indices, 0, total_frames_in_video - 1))
|
| 388 |
-
|
| 389 |
result = decode_with_multithreading(frame_indices.tolist(), num_threads=num_threads, video_path=video_path)
|
| 390 |
# Add frame_indices to the result for later use
|
| 391 |
result["frame_indices"] = frame_indices
|
| 392 |
return result
|
| 393 |
|
| 394 |
|
| 395 |
-
|
| 396 |
except Exception:
|
| 397 |
traceback.print_exc()
|
| 398 |
return None
|
|
@@ -528,9 +564,28 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 528 |
return video_input["video_path"]
|
| 529 |
return video_input
|
| 530 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
def _get_video_duration_seconds(self, video_input: Union[str, Dict[str, Any]]) -> float:
|
| 532 |
"""Get video duration in seconds for weighted frame-budget allocation."""
|
| 533 |
-
video_path =
|
| 534 |
decoder = VideoDecoder(video_path, num_ffmpeg_threads=0)
|
| 535 |
try:
|
| 536 |
metadata = decoder.metadata
|
|
@@ -615,13 +670,13 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 615 |
) -> int:
|
| 616 |
"""
|
| 617 |
Calculate the number of frames to sample using fps-based logic with min/max constraints.
|
| 618 |
-
|
| 619 |
Logic:
|
| 620 |
1. Calculate target_frames based on fps and video duration
|
| 621 |
2. Apply min_frames and max_frames constraints
|
| 622 |
3. Apply max_allowed_frames protection (rough cap from total video_max_pixels budget)
|
| 623 |
4. Return the number of frames to sample
|
| 624 |
-
|
| 625 |
Args:
|
| 626 |
metadata (`VideoMetadata`):
|
| 627 |
Metadata of the video containing information about total duration, fps and total number of frames.
|
|
@@ -641,11 +696,11 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 641 |
raise ValueError("`num_frames` and `fps` are mutually exclusive arguments, please use only one!")
|
| 642 |
|
| 643 |
total_num_frames = metadata.total_num_frames
|
| 644 |
-
|
| 645 |
# Use provided min/max or fall back to defaults
|
| 646 |
effective_min_frames = min_frames if min_frames is not None else self.min_frames
|
| 647 |
effective_max_frames = max_frames if max_frames is not None else self.max_frames
|
| 648 |
-
|
| 649 |
# Rough per-video frame cap derived from the multi-video total budget
|
| 650 |
# (exact allocation happens later in _preprocess via weighted distribution)
|
| 651 |
per_frame_min_pixels = self.size.get("shortest_edge", None) if self.size else None
|
|
@@ -653,7 +708,7 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 653 |
if per_frame_min_pixels is not None and video_max_pixels is not None and per_frame_min_pixels > 0:
|
| 654 |
max_allowed_frames = video_max_pixels // per_frame_min_pixels
|
| 655 |
effective_max_frames = min(effective_max_frames, max_allowed_frames)
|
| 656 |
-
|
| 657 |
# Get video duration
|
| 658 |
if hasattr(metadata, 'duration') and metadata.duration is not None:
|
| 659 |
duration = metadata.duration
|
|
@@ -671,12 +726,12 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 671 |
|
| 672 |
# Use provided fps or default
|
| 673 |
target_fps = fps if fps is not None else self.video_fps
|
| 674 |
-
|
| 675 |
# Calculate target frames based on fps and duration
|
| 676 |
if num_frames is None:
|
| 677 |
# Calculate how many frames we should sample based on target fps
|
| 678 |
target_total_frames = int(math.ceil(duration * target_fps - 1e-6))
|
| 679 |
-
|
| 680 |
# Apply min/max constraints
|
| 681 |
sample_frames = max(target_total_frames, effective_min_frames)
|
| 682 |
sample_frames = min(sample_frames, effective_max_frames, total_num_frames)
|
|
@@ -687,6 +742,134 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 687 |
return sample_frames
|
| 688 |
|
| 689 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 690 |
def _fetch_video_segment(
|
| 691 |
self,
|
| 692 |
video_path: str,
|
|
@@ -697,14 +880,14 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 697 |
):
|
| 698 |
"""
|
| 699 |
Fetch video frames for a specific segment.
|
| 700 |
-
|
| 701 |
Args:
|
| 702 |
video_path: Path to the video file
|
| 703 |
segment: [start, end] for a segment (left-closed, right-open) or [time] for a single frame
|
| 704 |
min_frames: Minimum frames for this segment (weighted). Defaults to self.min_frames. Must be >= 1.
|
| 705 |
max_frames: Maximum frames for this segment (weighted). Defaults to self.max_frames. Must be >= 1.
|
| 706 |
video_fps: Target frames per second for video sampling. If None, uses self.video_fps.
|
| 707 |
-
|
| 708 |
Returns:
|
| 709 |
Tuple of (video_tensor, video_metadata)
|
| 710 |
"""
|
|
@@ -713,24 +896,24 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 713 |
max_frames = max(1, max_frames if max_frames is not None else self.max_frames)
|
| 714 |
# Use provided video_fps or fall back to self.video_fps
|
| 715 |
target_video_fps = video_fps if video_fps is not None else self.video_fps
|
| 716 |
-
|
| 717 |
-
video_path =
|
| 718 |
decoder = VideoDecoder(video_path, num_ffmpeg_threads=0)
|
| 719 |
try:
|
| 720 |
torchcodec_metadata = decoder.metadata
|
| 721 |
-
|
| 722 |
video_fps = torchcodec_metadata.average_fps
|
| 723 |
-
|
| 724 |
# Calculate duration
|
| 725 |
duration = None
|
| 726 |
if torchcodec_metadata.end_stream_seconds_from_content is not None and torchcodec_metadata.begin_stream_seconds_from_content is not None:
|
| 727 |
duration = torchcodec_metadata.end_stream_seconds_from_content - torchcodec_metadata.begin_stream_seconds_from_content
|
| 728 |
if duration is None or duration <= 0:
|
| 729 |
duration = torchcodec_metadata.duration_seconds
|
| 730 |
-
|
| 731 |
if len(segment) == 1:
|
| 732 |
# Single frame at specified time
|
| 733 |
-
actual_timestamps =
|
| 734 |
frame_batch = decoder.get_frames_played_at(actual_timestamps)
|
| 735 |
video_tensor = frame_batch.data
|
| 736 |
sample_count = 1
|
|
@@ -738,26 +921,26 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 738 |
# Segment [start, end) - left-closed, right-open interval
|
| 739 |
start_time, end_time = segment
|
| 740 |
segment_duration = end_time - start_time
|
| 741 |
-
|
| 742 |
# Calculate number of frames to sample for this segment
|
| 743 |
target_frames = int(math.ceil(segment_duration * target_video_fps))
|
| 744 |
target_frames = max(target_frames, min_frames)
|
| 745 |
target_frames = min(target_frames, max_frames)
|
| 746 |
-
|
| 747 |
# Generate timestamps for uniform sampling within segment
|
| 748 |
if target_frames == 1:
|
| 749 |
actual_timestamps = [start_time] # Use start_time for single frame
|
| 750 |
else:
|
| 751 |
# Sample uniformly within [start, end), endpoint=False for left-closed right-open
|
| 752 |
actual_timestamps = np.linspace(start_time, end_time, target_frames, endpoint=False).tolist()
|
| 753 |
-
|
| 754 |
-
actual_timestamps =
|
| 755 |
|
| 756 |
# Use multithreading for extraction
|
| 757 |
result = timestamp_decode_with_multithreading(actual_timestamps, self.num_extract_threads, video_path)
|
| 758 |
video_tensor = result["data"]
|
| 759 |
sample_count = len(actual_timestamps)
|
| 760 |
-
|
| 761 |
# Create VideoMetadata
|
| 762 |
video_metadata = VideoMetadata(
|
| 763 |
total_num_frames=sample_count,
|
|
@@ -768,39 +951,40 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 768 |
width=torchcodec_metadata.width,
|
| 769 |
frames_indices=None
|
| 770 |
)
|
| 771 |
-
|
| 772 |
# Store actual timestamps as a custom attribute for _calculate_timestamps to use
|
| 773 |
video_metadata.actual_timestamps = actual_timestamps
|
| 774 |
-
|
| 775 |
return video_tensor, video_metadata
|
| 776 |
finally:
|
| 777 |
del decoder
|
| 778 |
|
| 779 |
def fetch_videos(
|
| 780 |
-
self,
|
| 781 |
-
video_url_or_urls: Union[str, Dict[str, Any], List[Union[str, Dict[str, Any]]]],
|
| 782 |
-
sample_indices_fn=None,
|
| 783 |
video_fps: Optional[float] = None,
|
| 784 |
min_frames: Optional[int] = None,
|
| 785 |
max_frames: Optional[int] = None,
|
| 786 |
):
|
| 787 |
"""
|
| 788 |
Override fetch_videos to use torchcodec for frame extraction.
|
| 789 |
-
|
| 790 |
This method uses torchcodec with multithreading for efficient frame extraction.
|
| 791 |
Frame count is calculated by the calculate_num_frames method
|
| 792 |
(fps-based with min/max constraints).
|
| 793 |
-
|
| 794 |
Args:
|
| 795 |
video_url_or_urls: Can be one of:
|
| 796 |
- str: Single video path
|
| 797 |
-
- Dict: Video with
|
| 798 |
-
|
|
|
|
| 799 |
sample_indices_fn: (Not used) Kept for compatibility with base class signature.
|
| 800 |
video_fps: Target frames per second for video sampling. If None, uses self.video_fps.
|
| 801 |
min_frames: Minimum number of frames to sample. If None, uses self.min_frames.
|
| 802 |
max_frames: Maximum number of frames to sample. If None, uses self.max_frames.
|
| 803 |
-
|
| 804 |
Returns:
|
| 805 |
Tuple of (videos, metadata) where videos are torch.Tensors and metadata are VideoMetadata objects.
|
| 806 |
"""
|
|
@@ -820,9 +1004,11 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 820 |
effective_max_frames,
|
| 821 |
)
|
| 822 |
for x, allocated_max_frames in zip(video_url_or_urls, per_video_max_frames):
|
|
|
|
|
|
|
| 823 |
result = self.fetch_videos(
|
| 824 |
-
x,
|
| 825 |
-
video_fps=
|
| 826 |
min_frames=effective_min_frames,
|
| 827 |
max_frames=allocated_max_frames,
|
| 828 |
)
|
|
@@ -834,57 +1020,31 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 834 |
all_videos.append(result[0])
|
| 835 |
all_metadata.append(result[1])
|
| 836 |
return all_videos, all_metadata
|
| 837 |
-
|
| 838 |
-
#
|
|
|
|
| 839 |
if isinstance(video_url_or_urls, dict):
|
| 840 |
video_path = video_url_or_urls["video_path"]
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
for i, segment in enumerate(segments):
|
| 857 |
-
if len(segment) == 1:
|
| 858 |
-
# Single frame - no weighted min/max, just extract directly
|
| 859 |
-
video, meta = self._fetch_video_segment(video_path, segment, video_fps=effective_video_fps)
|
| 860 |
-
else:
|
| 861 |
-
# Time-range segment - apply weighted min/max frames
|
| 862 |
-
if total_segment_duration > 0:
|
| 863 |
-
weight = segment_durations[i] / total_segment_duration
|
| 864 |
-
else:
|
| 865 |
-
# Fallback: equal weight among time-range segments
|
| 866 |
-
num_range_segments = sum(1 for d in segment_durations if d is not None)
|
| 867 |
-
weight = 1.0 / num_range_segments if num_range_segments > 0 else 1.0
|
| 868 |
-
|
| 869 |
-
# Calculate weighted min/max frames (ensure >= 1)
|
| 870 |
-
weighted_min_frames = max(1, int(round(effective_min_frames * weight)))
|
| 871 |
-
weighted_max_frames = max(1, int(round(effective_max_frames * weight)))
|
| 872 |
-
|
| 873 |
-
video, meta = self._fetch_video_segment(
|
| 874 |
-
video_path, segment,
|
| 875 |
-
min_frames=weighted_min_frames,
|
| 876 |
-
max_frames=weighted_max_frames,
|
| 877 |
-
video_fps=effective_video_fps,
|
| 878 |
-
)
|
| 879 |
-
videos.append(video)
|
| 880 |
-
metadata.append(meta)
|
| 881 |
-
return videos, metadata
|
| 882 |
-
|
| 883 |
# Single video path
|
| 884 |
video_path = video_url_or_urls
|
| 885 |
-
|
| 886 |
# Clean video streams first (remove extra streams if needed)
|
| 887 |
-
video_path =
|
| 888 |
|
| 889 |
decoder = None
|
| 890 |
try:
|
|
@@ -895,13 +1055,13 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 895 |
duration = None
|
| 896 |
if torchcodec_metadata.end_stream_seconds_from_content is not None and torchcodec_metadata.begin_stream_seconds_from_content is not None:
|
| 897 |
duration = torchcodec_metadata.end_stream_seconds_from_content - torchcodec_metadata.begin_stream_seconds_from_content
|
| 898 |
-
|
| 899 |
if duration is None or duration <= 0:
|
| 900 |
duration = torchcodec_metadata.duration_seconds
|
| 901 |
-
|
| 902 |
# Use num_frames_from_content for accurate frame count (consistent with extraction)
|
| 903 |
total_frames_in_video = torchcodec_metadata.num_frames_from_content
|
| 904 |
-
|
| 905 |
# Create VideoMetadata object for sample_frames method
|
| 906 |
temp_metadata = VideoMetadata(
|
| 907 |
total_num_frames=total_frames_in_video,
|
|
@@ -912,31 +1072,31 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 912 |
width=torchcodec_metadata.width,
|
| 913 |
frames_indices=None
|
| 914 |
)
|
| 915 |
-
|
| 916 |
# Use calculate_num_frames method to get the number of frames to sample
|
| 917 |
sample_frames_count = self.calculate_num_frames(
|
| 918 |
-
temp_metadata,
|
| 919 |
fps=effective_video_fps,
|
| 920 |
min_frames=effective_min_frames,
|
| 921 |
max_frames=effective_max_frames,
|
| 922 |
)
|
| 923 |
-
|
| 924 |
# Ensure sample count is valid
|
| 925 |
effective_sample_count = min(sample_frames_count, total_frames_in_video)
|
| 926 |
if effective_sample_count == 0:
|
| 927 |
raise ValueError(f"Cannot extract frames: video has 0 frames or specified frame count is 0")
|
| 928 |
-
|
| 929 |
# Generate uniform frame indices
|
| 930 |
frame_indices = np.linspace(0, total_frames_in_video - 1, effective_sample_count).astype(np.int32)
|
| 931 |
# Ensure indices are valid and remove duplicates
|
| 932 |
frame_indices = np.unique(np.clip(frame_indices, 0, total_frames_in_video - 1))
|
| 933 |
-
|
| 934 |
# Extract frames using multithreading (decoder is created inside each thread for thread safety)
|
| 935 |
result = decode_with_multithreading(frame_indices.tolist(), num_threads=self.num_extract_threads, video_path=video_path)
|
| 936 |
-
|
| 937 |
# Extract frame tensor (N, C, H, W)
|
| 938 |
frames_tensor = result["data"]
|
| 939 |
-
|
| 940 |
# Create final VideoMetadata object
|
| 941 |
video_metadata = VideoMetadata(
|
| 942 |
total_num_frames=len(frame_indices),
|
|
@@ -947,15 +1107,15 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 947 |
width=torchcodec_metadata.width,
|
| 948 |
frames_indices=frame_indices
|
| 949 |
)
|
| 950 |
-
|
| 951 |
# Ensure frames are in (T, C, H, W) format
|
| 952 |
if frames_tensor.dim() == 4: # (N, C, H, W)
|
| 953 |
video_tensor = frames_tensor
|
| 954 |
else:
|
| 955 |
raise ValueError(f"Unexpected frame tensor shape: {frames_tensor.shape}")
|
| 956 |
-
|
| 957 |
return video_tensor, video_metadata
|
| 958 |
-
|
| 959 |
except Exception as e:
|
| 960 |
logger.error(f"Error loading video {video_path}: {e}")
|
| 961 |
traceback.print_exc()
|
|
@@ -1060,12 +1220,17 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 1060 |
merge_size,
|
| 1061 |
patch_size,
|
| 1062 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1063 |
patches = patches.permute(0, 1, 4, 7, 5, 8, 3, 2, 6, 9)
|
| 1064 |
flatten_patches = patches.reshape(
|
| 1065 |
batch_size,
|
| 1066 |
grid_t * grid_h * grid_w,
|
| 1067 |
channel * temporal_patch_size * patch_size * patch_size,
|
| 1068 |
-
)
|
| 1069 |
|
| 1070 |
processed_videos_grouped[shape] = flatten_patches
|
| 1071 |
processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size
|
|
@@ -1088,15 +1253,15 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 1088 |
) -> BatchFeature:
|
| 1089 |
"""
|
| 1090 |
Preprocess videos for the model.
|
| 1091 |
-
|
| 1092 |
This method overrides the base class to handle two video input formats:
|
| 1093 |
1. String path: "path/to/video.mp4"
|
| 1094 |
-
2. Dict with
|
| 1095 |
-
|
| 1096 |
Args:
|
| 1097 |
videos: Video input(s) in one of the supported formats.
|
| 1098 |
**kwargs: Additional arguments passed to _preprocess.
|
| 1099 |
-
|
| 1100 |
Returns:
|
| 1101 |
BatchFeature with pixel_values_videos, video_grid_thw, and optionally video_metadata.
|
| 1102 |
"""
|
|
@@ -1105,11 +1270,11 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 1105 |
captured_kwargs=kwargs.keys(),
|
| 1106 |
valid_processor_keys=list(self.valid_kwargs.__annotations__.keys()) + ["return_tensors"],
|
| 1107 |
)
|
| 1108 |
-
|
| 1109 |
# Set default kwargs from self
|
| 1110 |
for kwarg_name in self.valid_kwargs.__annotations__:
|
| 1111 |
kwargs.setdefault(kwarg_name, getattr(self, kwarg_name, None))
|
| 1112 |
-
|
| 1113 |
# Pop kwargs that are handled separately
|
| 1114 |
return_tensors = kwargs.pop("return_tensors", None)
|
| 1115 |
return_metadata = kwargs.pop("return_metadata", False)
|
|
@@ -1118,42 +1283,42 @@ class MossVLVideoProcessor(BaseVideoProcessor):
|
|
| 1118 |
kwargs.pop("video_metadata", None) # We generate our own metadata
|
| 1119 |
kwargs.pop("do_sample_frames", None) # We handle sampling ourselves
|
| 1120 |
kwargs.pop("data_format", None) # Not used
|
| 1121 |
-
|
| 1122 |
# Normalize input to list format
|
| 1123 |
if not isinstance(videos, list):
|
| 1124 |
videos = [videos]
|
| 1125 |
-
|
| 1126 |
# Get video processing params from kwargs (may be passed explicitly for per-batch configuration)
|
| 1127 |
video_fps = kwargs.pop("video_fps", None)
|
| 1128 |
min_frames = kwargs.pop("min_frames", None)
|
| 1129 |
max_frames = kwargs.pop("max_frames", None)
|
| 1130 |
-
|
| 1131 |
# Use fetch_videos to handle both string and dict formats
|
| 1132 |
video_tensors, video_metadata = self.fetch_videos(
|
| 1133 |
-
videos,
|
| 1134 |
video_fps=video_fps,
|
| 1135 |
min_frames=min_frames,
|
| 1136 |
max_frames=max_frames,
|
| 1137 |
)
|
| 1138 |
-
|
| 1139 |
# Prepare video tensors using _prepare_input_videos
|
| 1140 |
prepared_videos = self._prepare_input_videos(
|
| 1141 |
videos=video_tensors,
|
| 1142 |
input_data_format=input_data_format,
|
| 1143 |
device=device,
|
| 1144 |
)
|
| 1145 |
-
|
| 1146 |
# Process kwargs for _preprocess
|
| 1147 |
kwargs = self._further_process_kwargs(**kwargs)
|
| 1148 |
self._validate_preprocess_kwargs(**kwargs)
|
| 1149 |
-
|
| 1150 |
# Call _preprocess with prepared videos
|
| 1151 |
result = self._preprocess(videos=prepared_videos, return_tensors=return_tensors, **kwargs)
|
| 1152 |
-
|
| 1153 |
# Add metadata if requested
|
| 1154 |
if return_metadata:
|
| 1155 |
result["video_metadata"] = video_metadata
|
| 1156 |
-
|
| 1157 |
return result
|
| 1158 |
|
| 1159 |
|
|
|
|
| 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
|
| 50 |
# -----------------------------------------------------------------------------
|
|
|
|
| 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 = []
|
|
|
|
| 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 |
"""
|
|
|
|
| 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)
|
|
|
|
| 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 |
"""
|
|
|
|
| 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
|
|
|
|
| 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()
|
|
|
|
| 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 |
|