diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..52373fe24473b1aa44333d318f578ae6bf04b49b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +tokenizer.json filter=lfs diff=lfs merge=lfs -text diff --git a/audio_model.py b/audio_model.py new file mode 100644 index 0000000000000000000000000000000000000000..98dd17a7c74a22749c86c04d36858450b2dc53c7 --- /dev/null +++ b/audio_model.py @@ -0,0 +1,174 @@ +# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Sound/Audio model components for multimodal integration. + +This module provides the SoundEncoder (wrapping Parakeet from HuggingFace transformers) +and SoundProjection (MLP to project audio embeddings to LLM hidden size). + +The Parakeet model in HuggingFace transformers is documented at: +https://huggingface.co/docs/transformers/en/model_doc/parakeet +""" + +from typing import Optional + +import torch +import torch.nn as nn + +from transformers import ParakeetEncoder, ParakeetEncoderConfig +from transformers.utils import logging + +logger = logging.get_logger(__name__) + + +class SquaredReLU(nn.Module): + """Squared ReLU activation function.""" + def forward(self, x): + return torch.pow(torch.nn.functional.relu(x), 2) + + +class RMSNorm(nn.Module): + def __init__(self, hidden_size, eps=1e-5): + super().__init__() + self.weight = nn.Parameter(torch.ones(hidden_size)) + self.eps = eps + + def forward(self, hidden_states): + input_dtype = hidden_states.dtype + hidden_states = hidden_states.to(torch.float32) + variance = hidden_states.pow(2).mean(-1, keepdim=True) + hidden_states = hidden_states * torch.rsqrt(variance + self.eps) + return (self.weight.to(torch.float32) * hidden_states).to(input_dtype) + + +class SoundProjection(nn.Module): + """MLP projection from sound encoder hidden size to LLM hidden size. + + Architecture: RMSNorm -> linear1 -> SquaredReLU -> linear2 + + This matches the Megatron checkpoint conversion structure: + - sound_projection.norm.weight + - sound_projection.linear1.weight + - sound_projection.linear2.weight + - sound_projection.linear1.bias (optional) + - sound_projection.linear2.bias (optional) + """ + + def __init__( + self, + sound_hidden_size: int, + projection_hidden_size: int, + llm_hidden_size: int, + bias: bool = True, + eps: float = 1e-5, + ): + super().__init__() + self.norm = RMSNorm(sound_hidden_size, eps=eps) + self.linear1 = nn.Linear(sound_hidden_size, projection_hidden_size, bias=bias) + self.activation = SquaredReLU() + self.linear2 = nn.Linear(projection_hidden_size, llm_hidden_size, bias=bias) + + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: + """Project sound embeddings to LLM embedding space. + + Args: + hidden_states: Sound encoder output [batch, seq_len, sound_hidden_size] + + Returns: + Projected embeddings [batch, seq_len, llm_hidden_size] + """ + hidden_states = self.norm(hidden_states) + hidden_states = self.linear1(hidden_states) + hidden_states = self.activation(hidden_states) + hidden_states = self.linear2(hidden_states) + return hidden_states + + +class SoundEncoder(nn.Module): + """Wrapper around the Parakeet encoder from HuggingFace transformers. + + The Parakeet model is an ASR model with a Fast Conformer encoder. + We use only the encoder portion to extract audio embeddings. + + Checkpoint structure: + - sound_encoder.encoder.feature_extractor.* -> Feature extraction (mel spectrogram) + - sound_encoder.encoder.pre_encode.* -> Pre-encoding convolutions + - sound_encoder.encoder.layers.* -> Conformer layers + + Reference: https://huggingface.co/docs/transformers/en/model_doc/parakeet + """ + + def __init__(self, config=None): + super().__init__() + + if config is not None: + # Build from config - handle both dict and config object + if hasattr(config, '__dict__'): + # It's a config object, extract relevant params for ParakeetConfig + config_dict = { + 'attention_bias': getattr(config, 'attention_bias', False), + 'hidden_size': getattr(config, 'hidden_size', 1024), + 'num_attention_heads': getattr(config, 'num_attention_heads', 8), + 'num_hidden_layers': getattr(config, 'num_hidden_layers', 24), + 'intermediate_size': getattr(config, 'intermediate_size', 4096), + 'conv_kernel_size': getattr(config, 'conv_kernel_size', 31), + 'convolution_bias': getattr(config, 'convolution_bias', False), + 'feat_in': getattr(config, 'feat_in', 80), + 'subsampling_factor': getattr(config, 'subsampling_factor', 8), + 'subsampling_conv_channels': getattr(config, 'subsampling_conv_channels', 256), + 'subsampling_conv_kernel_size': getattr(config, 'subsampling_conv_kernel_size', 3), + 'subsampling_conv_stride': getattr(config, 'subsampling_conv_stride', 2), + 'num_mel_bins': getattr(config, 'num_mel_bins', 128), + 'scale_input': getattr(config, 'scale_input', False), + } + elif isinstance(config, dict): + config_dict = config + else: + config_dict = {} + + # Create ParakeetConfig with the extracted parameters + parakeet_config = ParakeetEncoderConfig(**config_dict) + self.config = parakeet_config + self.encoder = ParakeetEncoder(parakeet_config) + else: + raise ValueError( + "config must be provided, " + "and ParakeetEncoder must be available in transformers." + ) + + def forward( + self, + input_features: torch.Tensor, + attention_mask: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + """Encode audio features. + + Args: + input_features: Mel spectrogram features [batch, seq_len, feature_dim] + attention_mask: Optional attention mask [batch, seq_len] + + Returns: + Audio embeddings [batch, encoded_seq_len, hidden_size] + """ + outputs = self.encoder( + input_features=input_features, + attention_mask=attention_mask, + ) + # Return the last hidden state + return outputs.last_hidden_state + + @property + def hidden_size(self) -> int: + """Return the hidden size of the encoder.""" + return self.config.hidden_size diff --git a/chat_template.jinja b/chat_template.jinja new file mode 100644 index 0000000000000000000000000000000000000000..6381c9df849b06a13c04a9992ed9cfe3f1e25a81 --- /dev/null +++ b/chat_template.jinja @@ -0,0 +1,273 @@ +{% macro render_extra_keys(json_dict, handled_keys) %} + {%- if json_dict is mapping %} + {%- for json_key in json_dict if json_key not in handled_keys %} + {%- if json_dict[json_key] is mapping or (json_dict[json_key] is sequence and json_dict[json_key] is not string) %} + {{- '\n<' ~ json_key ~ '>' ~ (json_dict[json_key] | tojson | safe) ~ '' }} + {%- else %} + {{- '\n<' ~ json_key ~ '>' ~ (json_dict[json_key] | string) ~ '' }} + {%- endif %} + {%- endfor %} + {%- endif %} +{%- endmacro -%} +{%- set enable_thinking = enable_thinking if enable_thinking is defined else True %} +{%- set reasoning_budget = reasoning_budget if reasoning_budget is defined else None %} +{%- set truncate_history_thinking = truncate_history_thinking if truncate_history_thinking is defined else True %} + +{#- Scan messages for VLM thinking toggles to override enable_thinking -#} +{%- set toggle = namespace(enable=enable_thinking) %} +{%- for m in messages %} + {%- if m['role'] == 'user' or m['role'] == 'system' -%} + {%- if m['content'] is string -%} + {%- set c = m['content'] %} + {%- if '/think' in c.replace('', '') -%} + {%- set toggle.enable = true -%} + {%- elif '/no_think' in c -%} + {%- set toggle.enable = false -%} + {%- endif -%} + {%- else -%} + {%- for part in m['content'] -%} + {%- if part['type'] == 'text' -%} + {%- set c = part['text'] %} + {%- if '/think' in c.replace('', '') -%} + {%- set toggle.enable = true -%} + {%- elif '/no_think' in c -%} + {%- set toggle.enable = false -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {%- endif -%} + {%- endif -%} +{%- endfor -%} +{#- Prepare message iteration similar to LM template -#} +{%- set ns = namespace(last_user_idx = -1) %} +{%- set loop_messages = messages %} +{%- for m in loop_messages %} + {%- if m["role"] == "user" %} + {%- set ns.last_user_idx = loop.index0 %} + {%- endif %} +{%- endfor -%} + +{%- if messages[0]["role"] == "system" %} + {%- set system_message = messages[0]["content"] %} + {%- set loop_messages = messages[1:] %} +{%- else %} + {%- set system_message = "" %} + {%- set loop_messages = messages %} +{%- endif %} +{%- if not tools is defined %} + {%- set tools = [] %} +{%- endif %} +{#- Recompute last_user_idx relative to loop_messages after handling system -#} +{%- set ns = namespace(last_user_idx = -1) %} +{%- for m in loop_messages %} + {%- if m["role"] == "user" %} + {%- set ns.last_user_idx = loop.index0 %} + {%- endif %} +{%- endfor -%} +{#- System preamble with LM formatting, sanitize thinking toggles -#} +{%- if system_message is defined %} + {%- set sys_content = system_message | string %} + {%- set sys_content = sys_content.replace('', '<_end_think>').replace('/think', '').replace('/no_think', '').replace('<_end_think>', '') %} + {{- "<|im_start|>system\n" + sys_content }} +{%- else %} + {%- if tools is iterable and tools | length > 0 %} + {{- "<|im_start|>system\n" }} + {%- endif %} +{%- endif %} +{%- if tools is iterable and tools | length > 0 %} + {%- if system_message is defined and system_message | length > 0 %} + {{- "\n\n" }} + {%- endif %} + {{- "# Tools\n\nYou have access to the following functions:\n\n" }} + {{- "" }} + {%- for tool in tools %} + {%- if tool.function is defined %} + {%- set tool = tool.function %} + {%- endif %} + {{- "\n\n" ~ tool.name ~ "" }} + {%- if tool.description is defined %} + {{- '\n' ~ (tool.description | trim) ~ '' }} + {%- endif %} + {{- '\n' }} + {%- if tool.parameters is defined and tool.parameters is mapping and tool.parameters.properties is defined and tool.parameters.properties is mapping %} + {%- for param_name, param_fields in tool.parameters.properties|items %} + {{- '\n' }} + {{- '\n' ~ param_name ~ '' }} + {%- if param_fields.type is defined %} + {{- '\n' ~ (param_fields.type | string) ~ '' }} + {%- endif %} + {%- if param_fields.description is defined %} + {{- '\n' ~ (param_fields.description | trim) ~ '' }} + {%- endif %} + {%- if param_fields.enum is defined %} + {{- '\n' ~ (param_fields.enum | tojson | safe) ~ '' }} + {%- endif %} + {%- set handled_keys = ['name', 'type', 'description', 'enum'] %} + {{- render_extra_keys(param_fields, handled_keys) }} + {{- '\n' }} + {%- endfor %} + {%- endif %} + {%- set handled_keys = ['type', 'properties', 'required'] %} + {{- render_extra_keys(tool.parameters, handled_keys) }} + {%- if tool.parameters is defined and tool.parameters.required is defined %} + {{- '\n' ~ (tool.parameters.required | tojson | safe) ~ '' }} + {%- endif %} + {{- '\n' }} + {%- set handled_keys = ['type', 'name', 'description', 'parameters'] %} + {{- render_extra_keys(tool, handled_keys) }} + {{- '\n' }} + {%- endfor %} + {{- "\n" }} + + {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n\n\n\nvalue_1\n\n\nThis is the value for the second parameter\nthat can span\nmultiple lines\n\n\n\n\n\nReminder:\n- Function calls MUST follow the specified format: an inner block must be nested within XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n' }} +{%- endif -%} +{%- if system_message is defined %} + {{- '<|im_end|>\n' }} +{%- else %} + {%- if tools is iterable and tools | length > 0 %} + {{- '<|im_end|>\n' }} + {%- endif %} +{%- endif -%} +{#- Iterate conversation -#} +{%- for message in loop_messages %} + {%- if message.role == "assistant" %} + {#- Use LM assistant handling -#} + {%- if message.reasoning_content is defined and message.reasoning_content is string and message.reasoning_content | trim | length > 0 %} + {%- set content = "\n" ~ message.reasoning_content ~ "\n\n" ~ (message.content | default('', true)) %} + {%- else %} + {%- set content = message.content | default('', true) %} + {%- if content is string -%} + {%- if '' not in content and '' not in content -%} + {%- set content = "" ~ content -%} + {%- endif -%} + {%- else -%} + {%- set content = content -%} + {%- endif -%} + {%- endif %} + {%- if message.tool_calls is defined and message.tool_calls is iterable and message.tool_calls | length > 0 %} + {{- '<|im_start|>assistant\n' }} + {%- set include_content = not (truncate_history_thinking and loop.index0 < ns.last_user_idx) %} + {%- if content is string and content | trim | length > 0 %} + {%- if include_content %} + {{- (content | trim) ~ '\n' -}} + {%- else %} + {%- set c = (content | string) %} + {%- if '' in c %} + {%- set c = c.split('')[-1] %} + {%- elif '' in c %} + {%- set c = c.split('')[0] %} + {%- endif %} + {%- set c = "" ~ c | trim %} + {%- if c | length > 0 %} + {{- c ~ '\n' -}} + {%- endif %} + {%- endif %} + {%- else %} + {{- "" -}} + {%- endif %} + {%- for tool_call in message.tool_calls %} + {%- if tool_call.function is defined %} + {%- set tool_call = tool_call.function %} + {%- endif %} + {{- '\n\n' -}} + {%- if tool_call.arguments is defined %} + {%- for args_name, args_value in tool_call.arguments|items %} + {{- '\n' -}} + {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %} + {{- args_value ~ '\n\n' -}} + {%- endfor %} + {%- endif %} + {{- '\n\n' -}} + {%- endfor %} + {{- '<|im_end|>\n' }} + {%- else %} + {%- if not (truncate_history_thinking and loop.index0 < ns.last_user_idx) %} + {{- '<|im_start|>assistant\n' ~ (content | default('', true) | string | trim) ~ '<|im_end|>\n' }} + {%- else %} + {%- set c = (content | default('', true) | string) %} + {%- if '' in c and '' in c %} + {%- set c = "" ~ c.split('')[-1] %} + {%- endif %} + {%- set c = c | trim %} + {%- if c | length > 0 %} + {{- '<|im_start|>assistant\n' ~ c ~ '<|im_end|>\n' }} + {%- else %} + {{- '<|im_start|>assistant\n<|im_end|>\n' }} + {%- endif %} + {%- endif %} + {%- endif %} + {%- elif message.role == "user" or message.role == "system" %} + {{- '<|im_start|>' + message.role + '\n' }} + {#- Build VLM multimodal content when content is a sequence -#} + {%- if message.content is string -%} + {%- set content = (message.content | string) %} + {%- else -%} + {%- set text_ns = namespace(val='') -%} + {%- set mm_content = '' -%} + {%- set counters = namespace(images=0, videos=0, audios=0) -%} + {%- for part in message['content'] -%} + {%- if part['type'] == 'image' or part['type'] == 'image_url' -%} + {%- set counters.images = counters.images + 1 -%} + {%- elif part['type'] == 'video' or part['type'] == 'video_url' -%} + {%- set counters.videos = counters.videos + 1 -%} + {%- elif part['type'] == 'audio' or part['type'] == 'audio_url' -%} + {%- set counters.audios = counters.audios + 1 -%} + {%- elif part['type'] == 'text' -%} + {%- set text_ns.val = text_ns.val + part['text'] -%} + {%- endif -%} + {%- endfor -%} + {%- if '' in text_ns.val -%} + {%- set counters.images = 0 -%} + {%- endif -%} + {%- if '', '<_end_think>').replace('/think', '').replace('/no_think', '').replace('<_end_think>', '') -%} + {%- set content = content | trim -%} + {%- if message.role == "user" and loop.index0 == ns.last_user_idx and reasoning_budget is not none -%} + {{- content + '\n\n{thinking token budget: ' + (reasoning_budget | string) + '}' -}} + {%- else -%} + {{- content -}} + {%- endif -%} + {{- '<|im_end|>\n' }} + {%- elif message.role == "tool" %} + {%- if loop.previtem and loop.previtem.role != "tool" %} + {{- '<|im_start|>user\n' }} + {%- endif %} + {{- '\n' }} + {{- message.content }} + {{- '\n\n' }} + {%- if not loop.last and loop.nextitem.role != "tool" %} + {{- '<|im_end|>\n' }} + {%- elif loop.last %} + {{- '<|im_end|>\n' }} + {%- endif %} + {%- else %} + {{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>\n' }} + {%- endif %} +{%- endfor -%} +{#- Generation prompt using computed thinking toggle -#} +{%- if add_generation_prompt %} + {%- if toggle.enable %} + {{- '<|im_start|>assistant\n\n' }} + {%- else %} + {{- '<|im_start|>assistant\n' }} + {%- endif %} +{%- endif %} \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000000000000000000000000000000000000..8854c4c9b8115b85ae96d1769ca9b27ac181c82d --- /dev/null +++ b/config.json @@ -0,0 +1,30394 @@ +{ + "architectures": [ + "NemotronH_Nano_Omni_Reasoning_V3" + ], + "auto_map": { + "AutoConfig": "configuration.NemotronH_Nano_Omni_Reasoning_V3_Config", + "AutoModel": "modeling.NemotronH_Nano_Omni_Reasoning_V3", + "AutoModelForCausalLM": "modeling.NemotronH_Nano_Omni_Reasoning_V3" + }, + "max_sequence_length": 131072, + "downsample_ratio": 0.5, + "force_image_size": 512, + "patch_size": 16, + "use_thumbnail": true, + "eos_token_id": 11, + "model_type": "NemotronH_Nano_Omni_Reasoning_V3", + "ps_version": "v2", + "template": "n5h_5p5_nanov2", + "torch_dtype": "bfloat16", + "image_tag_type": "internvl", + "img_context_token_id": 18, + "video_context_token_id": 131081, + "img_context_token": "", + "video_context_token": "' in c %}\n {# Keep only content after the last closing think. Also generation prompt causes this. #}\n {%- set c = c.split('')[-1] %}\n {%- elif '' in c %}\n {# If was opened but never closed, drop the trailing think segment #}\n {%- set c = c.split('')[0] %}\n {%- endif %}\n {%- set c = \"\" ~ c | trim %}\n {%- if c | length > 0 %}\n {{- c ~ '\\n' -}}\n {%- endif %}\n {%- endif %}\n {%- else %}\n {{- \"\" -}}\n {%- endif %}\n {%- for tool_call in message.tool_calls %}\n {%- if tool_call.function is defined %}\n {%- set tool_call = tool_call.function %}\n {%- endif %}\n {{- '\\n\\n' -}}\n {%- if tool_call.arguments is defined %}\n {%- for args_name, args_value in tool_call.arguments|items %}\n {{- '\\n' -}}\n {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}\n {{- args_value ~ '\\n\\n' -}}\n {%- endfor %}\n {%- endif %}\n {{- '\\n\\n' -}}\n {%- endfor %}\n {{- '<|im_end|>\\n' }}\n {%- else %}\n {# Assistant message doesn't have tool calls. #}\n {%- if not (truncate_history_thinking and loop.index0 < ns.last_user_idx) %}\n {{- '<|im_start|>assistant\\n' ~ (content | default('', true) | string | trim) ~ '<|im_end|>\\n' }}\n {%- else %}\n {%- set c = (content | default('', true) | string) %}\n {%- if '' in c and '' in c %}\n {%- set c = \"\" ~ c.split('')[-1] %}\n {%- endif %}\n {%- set c = c | trim %}\n {%- if c | length > 0 %}\n {{- '<|im_start|>assistant\\n' ~ c ~ '<|im_end|>\\n' }}\n {%- else %}\n {{- '<|im_start|>assistant\\n<|im_end|>\\n' }}\n {%- endif %}\n {%- endif %}\n {%- endif %}\n {%- elif message.role == \"user\" or message.role == \"system\" %}\n {{- '<|im_start|>' + message.role + '\\n' }}\n {%- set content = message.content | string %}\n {{- content }}\n {{- '<|im_end|>\\n' }}\n {%- elif message.role == \"tool\" %}\n {%- if loop.previtem and loop.previtem.role != \"tool\" %}\n {{- '<|im_start|>user\\n' }}\n {%- endif %}\n {{- '\\n' }}\n {{- message.content }}\n {{- '\\n\\n' }}\n {%- if not loop.last and loop.nextitem.role != \"tool\" %}\n {{- '<|im_end|>\\n' }}\n {%- elif loop.last %}\n {{- '<|im_end|>\\n' }}\n {%- endif %}\n {%- else %}\n {{- '<|im_start|>' + message.role + '\\n' + message.content + '<|im_end|>\\n' }}\n {%- endif %}\n{%- endfor %}\n\n{%- if add_generation_prompt %}\n {%- if enable_thinking %}\n {{- '<|im_start|>assistant\\n\\n' }}\n {%- else %}\n {{- '<|im_start|>assistant\\n' }}\n {%- endif %}\n{%- endif %}\n" +} diff --git a/video_io.py b/video_io.py new file mode 100644 index 0000000000000000000000000000000000000000..d4e8f84916753f8cac38e2e7199d4ca2b1ae8806 --- /dev/null +++ b/video_io.py @@ -0,0 +1,176 @@ +import os +import base64 +import mimetypes +from PIL import Image +import io +from transformers.video_utils import VideoMetadata + + +def encode_pil_to_jpeg_data_url(pil_image): + from io import BytesIO + buf = BytesIO() + pil_image.save(buf, format="JPEG") + b64 = base64.b64encode(buf.getvalue()).decode("utf-8") + return f"data:image/jpeg;base64,{b64}" + + +def sample_video_frames_to_data_urls(video_path_local, fps=1, nframe=0, nframe_max=-1): + """ + Sample frames from a video and return base64-encoded data URLs along with metadata. + + Args: + video_path_local: Path to the video file + fps: Target frames per second for sampling (if > 0, uses fps-based sampling) + nframe: Number of frames to sample (used if fps <= 0) + nframe_max: Maximum number of frames to sample + + Returns: + tuple: (frame_data_urls, metadata) + - frame_data_urls: List of base64-encoded frame images + - metadata: VideoMetadata dataclass containing info about the sampled frames: + - total_num_frames: Number of sampled frames + - fps: Effective frame rate of the sampled frames + - duration: Duration covered by the sampled frames (in seconds) + - video_backend: Backend used for video processing ('decord') + """ + import numpy as np + from PIL import Image + import decord + + vid = decord.VideoReader(video_path_local) + total_frames = len(vid) + video_fps = vid.get_avg_fps() + total_duration = total_frames / max(1e-6, video_fps) + + if fps > 0: + required_frames = int(total_duration * fps) + desired_frames = max(1, required_frames) + if nframe_max > 0 and desired_frames > nframe_max: + desired_frames = nframe_max + if desired_frames >= total_frames: + indices = list(range(total_frames)) + elif desired_frames == 1: + indices = [0] # Always use first frame for single frame sampling + else: + # Generate evenly spaced indices and ensure uniqueness + raw_indices = np.linspace(0, total_frames - 1, desired_frames) + indices = list(np.unique(np.round(raw_indices).astype(int))) + else: + desired_frames = max(1, int(nframe) if nframe and nframe > 0 else 8) + if nframe_max > 0 and desired_frames > nframe_max: + desired_frames = nframe_max + if desired_frames >= total_frames: + indices = list(range(total_frames)) + elif desired_frames == 1: + indices = [0] # Always use first frame for single frame sampling + else: + # Generate evenly spaced indices and ensure uniqueness + raw_indices = np.linspace(0, total_frames - 1, desired_frames) + indices = list(np.unique(np.round(raw_indices).astype(int))) + + images = [Image.fromarray(vid[i].asnumpy()) for i in indices] + frame_urls = [encode_pil_to_jpeg_data_url(im) for im in images] + + # Calculate timestamps for each sampled frame + timestamps = [float(idx) / video_fps for idx in indices] + + # Calculate metadata for the sampled frames + sampled_num_frames = len(indices) + + # Duration is the time span from first to last frame + if len(timestamps) > 1: + sampled_duration = timestamps[-1] - timestamps[0] + sampled_fps = (sampled_num_frames - 1) / sampled_duration if sampled_duration > 0 else 1.0 + else: + # Single frame case + sampled_duration = None + sampled_fps = None + + metadata = VideoMetadata( + total_num_frames=sampled_num_frames, + fps=sampled_fps, + duration=sampled_duration, + video_backend=None, + ) + + return frame_urls, metadata + + +def maybe_path_or_url_to_data_urls(path_or_url, fps=1, nframe=0, nframe_max=-1): + """ + Convert a path or URL to data URLs, handling videos, images, and remote files. + + Args: + path_or_url: Path or URL to the media file + fps: Target frames per second for video sampling (if > 0, uses fps-based sampling) + nframe: Number of frames to sample from video (used if fps <= 0) + nframe_max: Maximum number of frames to sample + + Returns: + tuple: (data_urls, metadata) + - data_urls: List of base64-encoded data URLs + - metadata: VideoMetadata dataclass with video metadata or None for images + """ + val = str(path_or_url or "") + low = val.lower() + + # Handle data URLs + if low.startswith("data:"): + if low.startswith("data:video/mp4"): + header, _, b64part = val.partition(",") + if not b64part: + return [val], None + import tempfile + tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) + try: + tmp.write(base64.b64decode(b64part)) + tmp.flush(); tmp.close() + return sample_video_frames_to_data_urls(tmp.name, fps=fps, nframe=nframe, nframe_max=nframe_max) + finally: + try: + os.unlink(tmp.name) + except Exception: + pass + return [val], None + + # Remote URL + if low.startswith("http://") or low.startswith("https://"): + if low.endswith(".mp4"): + try: + import tempfile, urllib.request + with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmpf: + urllib.request.urlretrieve(val, tmpf.name) + local_path = tmpf.name + result = sample_video_frames_to_data_urls(local_path, fps=fps, nframe=nframe, nframe_max=nframe_max) + try: + os.unlink(local_path) + except Exception: + pass + return result + except Exception: + return [val], None + return [val], None + + # Local path + if os.path.exists(val): + mime, _ = mimetypes.guess_type(val) + if mime and mime.startswith("image/"): + with open(val, "rb") as f: + b64 = base64.b64encode(f.read()).decode("utf-8") + return [f"data:{mime};base64,{b64}"], None + if mime == "video/mp4" or (mime is None and val.endswith(".mp4")): + return sample_video_frames_to_data_urls(val, fps=fps, nframe=nframe, nframe_max=nframe_max) + # Fallback: treat as binary image + with open(val, "rb") as f: + b64 = base64.b64encode(f.read()).decode("utf-8") + return [f"data:image/jpeg;base64,{b64}"], None + + return [val], None + + +def pil_image_from_base64(b64_str: str) -> Image.Image: + # Handle data URLs like "data:image/png;base64,...." + if b64_str.startswith('data:'): + b64_str = b64_str.split(',', 1)[1] + img_bytes = base64.b64decode(b64_str) + return Image.open(io.BytesIO(img_bytes)) diff --git a/video_processing.py b/video_processing.py new file mode 100644 index 0000000000000000000000000000000000000000..0150cb771ea3c0e96c64176f27fe66de60b83d64 --- /dev/null +++ b/video_processing.py @@ -0,0 +1,166 @@ +# coding=utf-8 +# Copyright 2025 The Qwen team, Alibaba Group and the HuggingFace Inc. team. All rights reserved. +# +# This code is based on EleutherAI's GPT-NeoX library and the GPT-NeoX +# and OPT implementations in this library. It has been modified from its +# original forms to accommodate minor architectural differences compared +# to GPT-NeoX and OPT used by the Meta AI team that trained the model. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""video processor class for Qwen2-VL.""" + +import math +from typing import Optional, Union + +from transformers.image_processing_utils import ( + BatchFeature, +) +from transformers.image_utils import ( + OPENAI_CLIP_MEAN, + OPENAI_CLIP_STD, + ChannelDimension, + SizeDict, + get_image_size, +) +from transformers.processing_utils import Unpack, VideosKwargs +from transformers.utils import ( + TensorType, + add_start_docstrings, + is_torch_available, + is_torchvision_available, + is_torchvision_v2_available, + is_vision_available, +) +from transformers.utils.import_utils import requires +from transformers.video_processing_utils import ( + BASE_VIDEO_PROCESSOR_DOCSTRING, + BaseVideoProcessor, +) +from transformers.video_utils import VideoMetadata, group_videos_by_shape, reorder_videos +import torchvision.transforms as T + +from .processing_utils import get_internvl_target_ratios, calculate_targets + + +if is_torchvision_available(): + if is_torchvision_v2_available(): + from torchvision.transforms.v2 import functional as F + else: + from torchvision.transforms import functional as F + + +if is_torch_available(): + import torch + + +@requires(backends=("torchvision",)) +class NemotronH_Nano_Omni_Reasoning_V3VideoProcessor(BaseVideoProcessor): + model_input_names = ["pixel_values_videos", "video_grid_thw"] + + def __init__(self, image_size=512, max_num_tiles=12, norm_mean=None, norm_std=None, **kwargs): + super().__init__(**kwargs) + self.image_size = image_size + self.max_num_tiles = max_num_tiles + self.norm_mean = norm_mean + self.norm_std = norm_std + + def _preprocess( + self, + videos: list["torch.Tensor"], + video_metadata: Union[list[VideoMetadata], list[dict]], + do_sample_frames: bool, + fps: Optional[int] = None, + num_frames: Optional[int] = None, + return_tensors: Optional[Union[str, TensorType]] = None, + device: Optional["torch.Tensor"] = None, + **kwargs, + ): + if do_sample_frames: + # Sample video frames + videos = [ + self.sample_frames( + video, + metadata=metadata, + num_frames=num_frames, + fps=fps, + ) + for video, metadata in zip(videos, video_metadata) + ] + + # We need to sample frames first before moving to device, if `do_sample_frames=True`. Otherwise + # moving the whole video incurs high GPU mem usage for long videos + if device is not None: + videos = [video.to(device) for video in videos] + + # Group videos by size for batched resizing + grouped_videos, grouped_videos_index = group_videos_by_shape(videos) + resized_videos_grouped = {} + processed_grids = {} + for shape, stacked_videos in grouped_videos.items(): + height, width = get_image_size(stacked_videos[0], channel_dim=ChannelDimension.FIRST) + batch_size, grid_t, channel = stacked_videos.shape[:3] + + target_ratios = get_internvl_target_ratios(1, self.max_num_tiles) + blocks, resize_width, resize_height = calculate_targets( + width, + height, + target_ratios, + self.image_size + ) + stacked_videos = self.resize( + image=stacked_videos, + size=SizeDict(height=resize_height, width=resize_width), + interpolation=T.InterpolationMode.BICUBIC, + ) + # stacked_videos = T.Resize((resize_width, resize_height), interpolation=T.InterpolationMode.BICUBIC)(stacked_videos) + norm_mean = torch.as_tensor(self.norm_mean, dtype=stacked_videos.dtype, device=stacked_videos.device).view(1, 1, 3, 1, 1) + norm_std = torch.as_tensor(self.norm_std, dtype=stacked_videos.dtype, device=stacked_videos.device).view(1, 1, 3, 1, 1) + stacked_videos = (stacked_videos - norm_mean) / norm_std + resized_videos_grouped[shape] = stacked_videos + grid_h, grid_w = resize_height // self.image_size, resize_width // self.image_size + processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size + resized_videos = reorder_videos(resized_videos_grouped, grouped_videos_index) + processed_grids = reorder_videos(processed_grids, grouped_videos_index) + pixel_values_videos = torch.cat(resized_videos, dim=0) + video_grid_thw = torch.tensor(processed_grids) + + return BatchFeature( + data={"pixel_values_videos": pixel_values_videos, "video_grid_thw": video_grid_thw}, + tensor_type=return_tensors, + ) + + def get_num_of_video_patches(self, num_frames: int, height: int, width: int): + """ + A utility that returns number of video patches a given video size. + + Args: + num_frames (`int`): + Number of frames in the input video. + height (`int`): + Height of the input video. + width (`int`): + Width of the input video. + Returns: + `Tuple(int, int)`: Number of placeholder tokens required and number of patches per image. + """ + target_ratios = get_internvl_target_ratios(1, self.max_num_tiles) + blocks, _, _ = calculate_targets( + width, + height, + target_ratios, + self.image_size + ) + return num_frames * blocks + + +__all__ = ["NemotronH_Nano_Omni_Reasoning_V3VideoProcessor"]