diff --git a/src/transformers/models/minicpm_o_2_6/feature_extractor_minicpm_o_2_6.py b/src/transformers/models/minicpm_o_2_6/feature_extractor_minicpm_o_2_6.py index c77702731096..897945af4535 100644 --- a/src/transformers/models/minicpm_o_2_6/feature_extractor_minicpm_o_2_6.py +++ b/src/transformers/models/minicpm_o_2_6/feature_extractor_minicpm_o_2_6.py @@ -36,6 +36,7 @@ def __call__( chunk_length: Optional[int] = 1, **kwargs, ): + # in batch inference, it may be [[]] if isinstance(audios, np.ndarray): audios_list = [[audios]] elif isinstance(audios[0], np.ndarray): diff --git a/src/transformers/models/minicpm_o_2_6/image_processing_minicpm.py b/src/transformers/models/minicpm_o_2_6/image_processing_minicpm.py index 9a2e94cf84aa..05880e5e4443 100755 --- a/src/transformers/models/minicpm_o_2_6/image_processing_minicpm.py +++ b/src/transformers/models/minicpm_o_2_6/image_processing_minicpm.py @@ -259,7 +259,13 @@ def preprocess( return_tensors: Optional[Union[str, TensorType]] = None, **kwargs, ) -> MiniCPMOBatchFeature: - images_list = make_nested_list_of_images(images) + # in batch inference, it may be [[]], so we can't use `make_nested_list_of_images` + if isinstance(images, Image.Image): + images_list = [[images]] + elif isinstance(images[0], Image.Image): + images_list = [images] + else: + images_list = images to_tensor = transforms.ToTensor() normalize_transform = transforms.Normalize( @@ -308,7 +314,9 @@ def preprocess( (slice_image.shape[1] // self.patch_size, slice_image.shape[2] // self.patch_size)) ) - tgt_sizes = np.vstack(tgt_sizes) + # in batch inference, it may be [] + if tgt_sizes: + tgt_sizes = np.vstack(tgt_sizes) new_images_list.append(new_images) image_sizes_list.append(image_sizes) diff --git a/src/transformers/models/minicpm_o_2_6/modeling_minicpm_o_2_6.py b/src/transformers/models/minicpm_o_2_6/modeling_minicpm_o_2_6.py index 0d136c880812..bcb47d7adbac 100644 --- a/src/transformers/models/minicpm_o_2_6/modeling_minicpm_o_2_6.py +++ b/src/transformers/models/minicpm_o_2_6/modeling_minicpm_o_2_6.py @@ -45,6 +45,7 @@ from transformers.utils import ModelOutput, add_start_docstrings, add_start_docstrings_to_model_forward, is_flash_attn_2_available, logging, replace_return_docstrings, can_return_tuple, auto_docstring, LossKwargs from transformers.cache_utils import Cache, DynamicCache, EncoderDecoderCache, StaticCache from transformers.generation import GenerationMixin +from transformers.generation.utils import GenerateOutput from transformers.generation.logits_process import LogitsProcessor, TopKLogitsWarper, TopPLogitsWarper from transformers.masking_utils import create_causal_mask, create_sliding_window_causal_mask from transformers.models.siglip.configuration_siglip import SiglipVisionConfig @@ -78,6 +79,7 @@ @dataclass class OmniOutput(ModelOutput): text: Optional[Union[str, List[str], Iterator]] = None + outputs: (GenerateOutput | torch.LongTensor) = None spk_embeds: Optional[torch.FloatTensor] = None audio_wav: Optional[np.ndarray] = None sampling_rate: Optional[int] = None @@ -549,7 +551,7 @@ def __init__(self, config): # feature_extractor = MiniCPM_o_2_6FeatureExtractor.from_pretrained(config._name_or_path) # self.processor = MiniCPM_o_2_6Processor(image_processor=image_processor, feature_extractor=feature_extractor, tokenizer=tokenizer) - self.terminators = ["<|im_end|>", "<|endoftext|>"] + # self.terminators = ["<|im_end|>", "<|endoftext|>"] self.force_no_stop = False @@ -1019,54 +1021,6 @@ def get_omni_embedding(self, data, input_embeddings, chunk_length=-1, stream_inp return input_embeddings - # def forward(self, data = None, **kwargs): - # r""" - # Perform a forward pass with multimodal inputs including vision and optional audio features. - - # Args: - # data (`Dict`): - # A dictionary containing multimodal inputs required to compute embeddings: - - # - `pixel_values` (`torch.FloatTensor` of shape `(B, C, H, W)`): Image tensors used for visual embedding. - # - `tgt_sizes` (`torch.LongTensor` of shape `(num_images, 2)`): Target spatial size (H, W) after patch embedding. - # - `image_bound` (`torch.LongTensor` of shape `(num_images, 2)`): Token index boundaries in the input sequence for each image. - # - `input_ids` (`torch.LongTensor`): Tokenized input ids including placeholders for multimodal tokens. - # - `position_ids` (`torch.LongTensor`): Positional indices, will be cast to `torch.int64` if needed. - # - `audio_values` (`torch.FloatTensor`, *optional*): Optional raw audio input, used if `config.init_audio` is True. - - # **kwargs: - # Additional standard generation/model arguments passed to the underlying language model, e.g.: - # - `attention_mask` - # - `past_key_values` - # - `use_cache` - # - `output_attentions` - # - `output_hidden_states` - # - `return_dict` - - # Returns: - # Model outputs from the underlying language model, depending on `return_dict`. - # """ - # output_attentions = kwargs.pop('output_attentions') if kwargs.get('output_attentions', None) is not None else self.config.output_attentions - # output_hidden_states = ( - # kwargs.pop('output_hidden_states') if kwargs.get('output_hidden_states', None) is not None else self.config.output_hidden_states - # ) - # outputs = self.language_model( - # output_attentions=output_attentions, - # output_hidden_states=output_hidden_states, - # **kwargs - # ) - # hidden_states = outputs.last_hidden_state - # # Only compute necessary logits, and do not upcast them to float if we are not computing the loss - # slice_indices = slice(0, None) - # logits = self.lm_head(hidden_states[:, slice_indices, :]) - - # return CausalLMOutputWithPast( - # loss=None, - # logits=logits, - # past_key_values=outputs.past_key_values, - # hidden_states=outputs.hidden_states, - # attentions=outputs.attentions, - # ) def forward( self, input_ids: torch.LongTensor = None, @@ -1140,26 +1094,12 @@ def forward( attentions=outputs.attentions, ) - def _decode(self, inputs_embeds, tokenizer, attention_mask, **kwargs): - terminators = [tokenizer.convert_tokens_to_ids(i) for i in self.terminators] - outputs = super().generate( - inputs_embeds=inputs_embeds, - pad_token_id=0, - eos_token_id=terminators, - attention_mask=attention_mask, - output_hidden_states=True, - return_dict_in_generate=True, - **kwargs, - ) - return outputs - def _decode_stream(self, inputs_embeds, tokenizer, **kwargs): - terminators = [tokenizer.convert_tokens_to_ids(i) for i in self.terminators] streamer = TextIteratorStreamer(tokenizer=tokenizer) generation_kwargs = { "inputs_embeds": inputs_embeds, "pad_token_id": 0, - "eos_token_id": terminators, + "eos_token_id": tokenizer.terminator_ids, "streamer": streamer, } generation_kwargs.update(kwargs) @@ -1180,13 +1120,44 @@ def generate( audio_bounds=None, spk_bounds=None, attention_mask=None, - tokenizer=None, + processor=None, vision_hidden_states=None, stream=False, + generate_audio=False, + return_spk_embed=False, + return_dict=False, + batched=False, + use_tts_template=False, + sampling=False, + max_new_tokens=2048, + min_new_tokens=0, + force_no_stop=False, **kwargs, ): assert input_ids is not None assert len(input_ids) == len(pixel_values) + assert sampling or not stream, "if use stream mode, make sure sampling=True" + + kwargs.pop("image_sizes") + if sampling: + generation_config = { + "top_p": 0.8, + "top_k": 100, + "temperature": 0.7, + "do_sample": True, + "repetition_penalty": 1.05, + } + else: + generation_config = { + "num_beams": 3, + "repetition_penalty": 1.2, + } + generation_config["max_new_tokens"] = max_new_tokens + generation_config["min_new_tokens"] = min_new_tokens + generation_config.update((k, kwargs[k]) for k in generation_config.keys() & kwargs.keys()) + + if generate_audio or return_spk_embed: + return_dict = True model_inputs = { "input_ids": input_ids, @@ -1212,213 +1183,22 @@ def generate( ) if stream: - result = self._decode_stream(model_inputs["inputs_embeds"], tokenizer, **kwargs) - # if stream return TextIteratorStreamer and output is empty - outputs = {} + result = self._decode_stream(model_inputs["inputs_embeds"], processor.tokenizer, **generation_config) else: - terminators = [tokenizer.convert_tokens_to_ids(i) for i in self.terminators] outputs = super().generate( inputs_embeds=model_inputs["inputs_embeds"], pad_token_id=0, - eos_token_id=terminators, + eos_token_id=processor.tokenizer.terminator_ids, attention_mask=attention_mask, output_hidden_states=True, return_dict_in_generate=True, - **kwargs, - ) - result = self.processor.decode_text(outputs.sequences, tokenizer, self.terminators) - - return result, outputs - - def chat( - self, - image=None, - msgs=None, - tokenizer=None, - processor=None, - vision_hidden_states=None, - max_new_tokens=2048, - min_new_tokens=0, - sampling=True, - max_inp_length=32768, - stream=False, - chunk_input=True, - omni_input=False, - max_slice_nums=None, - use_image_id=None, - use_tts_template=False, - generate_audio=False, - return_spk_embed=False, - return_dict=False, - output_audio_path=None, - **kwargs, - ): - """ - Unified chat function - - Args: - image: use for batch_size=1 vqa, It is not recommended to continue to use this parameter - msgs: the input chat msgs, support text: (string) / image: (PIL.Image) / audio (numpy.ndarray) - tokenizer: tokenizer for llm - processor: if None, use the default processor - max_new_tokens: the maximum length of the generation - min_new_tokens: the minimum length of the generation - sampling: whether to use sampling decoding or beam search decoding - max_inp_length: the maximum length of input - stream: whether to return generator, only used when tts is not required - chunk_input: whether to split audio into 1s chunks - omni_input: determine whether it is omni mode - max_slice_nums: control the maximum number of image slices - use_image_id: for video understanding or omni understanding, use_image_id should be False - use_tts_template: if the msgs contain audio, use_tts_template should be True - generate_audio: whether to generate audio output, only used when return_dict=True - return_spk_embed: whether to return spk embedding, only used when return_dict=True - return_dict: whether to return dict - output_audio_path: audio save path when generate_audio - **kwargs: - """ - if isinstance(msgs[0], list): - batched = True - else: - batched = False - - if generate_audio or return_spk_embed: - return_dict = True - - msgs_list = msgs - images_list = image - - if batched is False: - images_list, msgs_list = [images_list], [msgs_list] - else: - assert images_list is None, "Please integrate image to msgs when using batch inference." - images_list = [None] * len(msgs_list) - assert len(images_list) == len(msgs_list), "The batch dim of images_list and msgs_list should be the same." - - if processor is None: - processor = self.processor - - assert ( - self.config.query_num == processor.image_processor.image_feature_size - ), "These two values should be the same. Check `config.json` and `preprocessor_config.json`." - assert ( - self.config.patch_size == processor.image_processor.patch_size - ), "These two values should be the same. Check `config.json` and `preprocessor_config.json`." - assert ( - self.config.use_image_id == processor.image_processor.use_image_id - ), "These two values should be the same. Check `config.json` and `preprocessor_config.json`." - assert ( - self.config.slice_config.max_slice_nums == processor.image_processor.max_slice_nums - ), "These two values should be the same. Check `config.json` and `preprocessor_config.json`." - assert ( - self.config.slice_mode == processor.image_processor.slice_mode - ), "These two values should be the same. Check `config.json` and `preprocessor_config.json`." - - prompts_lists = [] - input_images_list = [] - input_audios_list = [] - audio_parts_list = [] - - for image, msgs in zip(images_list, msgs_list): - if isinstance(msgs, str): - msgs = json.loads(msgs) - copy_msgs = deepcopy(msgs) - - assert len(msgs) > 0, "msgs is empty" - assert sampling or not stream, "if use stream mode, make sure sampling=True" - - if image is not None and isinstance(copy_msgs[0]["content"], str): - copy_msgs[0]["content"] = [image, copy_msgs[0]["content"]] - - images = [] - audios = [] - audio_parts = [] - for i, msg in enumerate(copy_msgs): - role = msg["role"] - content = msg["content"] - assert role in ["system", "user", "assistant"] - if i == 0: - assert role in ["user", "system"], "The role of first msg should be user" - if isinstance(content, str): - content = [content] - cur_msgs = [] - for c in content: - if isinstance(c, Image.Image): - images.append(c) - cur_msgs.append("(./)") - elif isinstance(c, np.ndarray): # audio - audios.append(c) - audio_parts.append(i) - cur_msgs.append("()") - use_tts_template = True - elif isinstance(c, str): - cur_msgs.append(c) - if omni_input: - msg["content"] = "".join(cur_msgs) - else: - msg["content"] = "\n".join(cur_msgs) - - prompts_lists.append( - processor.tokenizer.apply_chat_template( - copy_msgs, - tokenize=False, - add_generation_prompt=True, - chat_template=self.processor.default_tts_chat_template if use_tts_template else None, + **generation_config, ) - ) - if images: - input_images_list.append(images) - if audios: - input_audios_list.append(audios) - audio_parts_list.append(audio_parts) - - inputs = processor( - prompts_lists, - input_images_list, - input_audios_list, - audio_parts=audio_parts_list, - max_slice_nums=max_slice_nums, - use_image_id=use_image_id, - chunk_input=chunk_input, - return_tensors="pt", - max_length=max_inp_length, - ).to(self.device) - - if sampling: - generation_config = { - "top_p": 0.8, - "top_k": 100, - "temperature": 0.7, - "do_sample": True, - "repetition_penalty": 1.05, - } - else: - generation_config = { - "num_beams": 3, - "repetition_penalty": 1.2, - } - - if min_new_tokens > 0: - generation_config["min_new_tokens"] = min_new_tokens - - generation_config.update((k, kwargs[k]) for k in generation_config.keys() & kwargs.keys()) - - inputs.pop("image_sizes") - with torch.inference_mode(): - res, outputs = self.generate( - **inputs, - tokenizer=tokenizer, - max_new_tokens=max_new_tokens, - vision_hidden_states=vision_hidden_states, - stream=stream, - **generation_config, - ) if stream: - def stream_gen(): - for text in res: - for term in self.terminators: + for text in result: + for term in processor.tokenizer.terminators: text = text.replace(term, "") yield text @@ -1429,35 +1209,27 @@ def stream_gen(): else: spk_embeds = wav_numpy = sr = None - - if batched: - answer = res - else: - answer = res[0] - - if use_tts_template and generate_audio: - mel_spec = self._generate_mel_spec(inputs, outputs, answer, tts_config={'top_p': 0.7, 'top_k': 20, 'repetition_penalty': 1.0}) - wav_numpy, sr = self.decode_mel_to_audio(mel_spec, output_audio_path) + + if not batched and use_tts_template and generate_audio: + result = processor.decode_text(outputs.sequences, processor.tokenizer) + mel_spec = self._generate_mel_spec(model_inputs, outputs, result[0], tts_config={'top_p': 0.7, 'top_k': 20, 'repetition_penalty': 1.0}, force_no_stop=force_no_stop) + wav_numpy, sr = self.decode_mel_to_audio(mel_spec, kwargs.get('output_audio_path', None)) if return_spk_embed: - spk_embeds = self._get_last_spk_embeds(inputs, outputs) - - if isinstance(answer, list): - answer = [i.replace(tokenizer.tts_end, "") for i in answer] - else: - answer = answer.replace(tokenizer.tts_end, "") + spk_embeds = self._get_last_spk_embeds(model_inputs, outputs) if return_dict: - return OmniOutput(text=answer, spk_embeds=spk_embeds, audio_wav=wav_numpy, sampling_rate=sr) + return OmniOutput(outputs=outputs, spk_embeds=spk_embeds, audio_wav=wav_numpy, sampling_rate=sr) else: - return answer + return outputs + @torch.inference_mode() def streaming_prefill( self, session_id, msgs, - tokenizer, + processor, omni_input=True, max_slice_nums=None, ls_temperature=1.0, @@ -1520,15 +1292,15 @@ def streaming_prefill( self.reset_session() self.session_id = session_id - prompt = tokenizer.apply_chat_template( - copy_msgs, tokenize=False, add_generation_prompt=False, chat_template=self.processor.default_tts_chat_template + prompt = processor.tokenizer.apply_chat_template( + copy_msgs, tokenize=False, add_generation_prompt=False, chat_template=processor.default_tts_chat_template ) add_special_tokens = True # add bos else: prompt = copy_msgs[0]["content"] add_special_tokens = False - model_inputs = self.processor( + model_inputs = processor( [prompt], [images] if images else None, [audios] if audios else None, @@ -1661,18 +1433,20 @@ def prepare_inputs_for_generation( def streaming_generate( self, session_id, - tokenizer, + processor, max_new_tokens=512, min_new_tokens=0, sampling=True, generate_audio=True, enable_regenerate=False, + force_no_stop=False, **kwargs, ): """ Streaming video/audio input and output audio stream Args: """ + tokenizer = processor.tokenizer if sampling: generation_config = { "top_p": 0.8, @@ -1696,7 +1470,6 @@ def streaming_generate( self.llm_generate_completed = False self.audio_past_key_values = None # apm kv cache - terminators = [tokenizer.convert_tokens_to_ids(i) for i in self.terminators] generate_prompt = "<|im_end|>\n<|im_start|>assistant\n<|spk_bos|><|spk|><|spk_eos|><|tts_bos|>" input_ids = tokenizer(generate_prompt, return_tensors="pt", add_special_tokens=False)["input_ids"].cuda() @@ -1710,11 +1483,12 @@ def streaming_generate( attention_mask = torch.ones((1, cache_length + input_ids.shape[1]), dtype=torch.bool, device=self.device) generation_config["max_new_tokens"] = max_new_tokens - streamer = self.llm_generate_chunk(input_ids, attention_mask, tokenizer, terminators, generation_config) + streamer = self.llm_generate_chunk(input_ids, attention_mask, tokenizer, tokenizer.terminator_ids, generation_config) if generate_audio: result = self._generate_mel_spec_audio_streaming( - spk_bounds, streamer, output_chunk_size=25, enable_regenerate=enable_regenerate, tts_config={"top_p": 0.7, "top_k": 20, "repetition_penalty": 1.0} + spk_bounds, streamer, output_chunk_size=25, enable_regenerate=enable_regenerate, + tts_config={"top_p": 0.7, "top_k": 20, "repetition_penalty": 1.0}, force_no_stop=force_no_stop ) return result else: @@ -1761,7 +1535,7 @@ def check_uncompleted_token(ids): end = check_uncompleted_token(cur_ids[0]) left_ids = cur_ids[:, end:] cur_ids = cur_ids[:, :end] - text = self.processor.decode_text(cur_ids, tokenizer, self.terminators)[0] if end > 0 else "" + text = self.processor.decode_text(cur_ids, tokenizer)[0] if end > 0 else "" self.llm_past_key_values = outputs.past_key_values input_ids = outputs.sequences[:, -1:] @@ -1826,7 +1600,7 @@ def _get_last_spk_embeds(self, inputs, outputs): spk_embeds = last_hidden_states[spk_bound[0] : spk_bound[1]] return spk_embeds - def _generate_mel_spec(self, inputs, outputs, text, output_chunk_size=25, tts_max_new_tokens=2048, tts_config: dict = {"top_p": 0.7, "top_k": 20, "repetition_penalty": 1.0}): + def _generate_mel_spec(self, inputs, outputs, text, force_no_stop=False, output_chunk_size=25, tts_max_new_tokens=2048, tts_config: dict = {"top_p": 0.7, "top_k": 20, "repetition_penalty": 1.0}): spk_embeds = self._get_last_spk_embeds(inputs, outputs) text = text.split("<|tts_bos|>")[-1] @@ -1913,7 +1687,7 @@ def _generate_mel_spec(self, inputs, outputs, text, output_chunk_size=25, tts_ma past_key_values=past_key_values, streaming_tts_text_mask=streaming_tts_text_mask, max_new_token=output_chunk_size, - force_no_stop=self.force_no_stop, + force_no_stop=force_no_stop, temperature=torch.tensor([0.1, 0.3, 0.1, 0.3], dtype=torch.float, device=self.tts.device), eos_token=torch.tensor([625], dtype=torch.long, device=self.tts.device), logits_warpers=logits_warpers, @@ -1935,7 +1709,7 @@ def _generate_mel_spec(self, inputs, outputs, text, output_chunk_size=25, tts_ma past_key_values=past_key_values, streaming_tts_text_mask=streaming_tts_text_mask, max_new_token=output_chunk_size, - force_no_stop=self.force_no_stop, + force_no_stop=force_no_stop, temperature=torch.tensor([0.1, 0.3, 0.1, 0.3], dtype=torch.float, device=self.tts.device), eos_token=torch.tensor([625], dtype=torch.long, device=self.tts.device), logits_warpers=logits_warpers, @@ -1996,6 +1770,7 @@ def _generate_mel_spec_audio_streaming( prev_seg_text_left="", prev_seg_audio_ids=None, enable_regenerate=False, + force_no_stop=False, tts_config: dict = {"top_p": 0.7, "top_k": 20, "repetition_penalty": 1.0}, ): # get spk_embedding @@ -2147,7 +1922,7 @@ def _generate_mel_spec_audio_streaming( past_key_values=past_key_values, streaming_tts_text_mask=streaming_tts_text_mask, max_new_token=output_chunk_size, - force_no_stop=self.force_no_stop, + force_no_stop=force_no_stop, temperature=torch.tensor([0.1, 0.3, 0.1, 0.3], dtype=torch.float, device=self.tts.device), eos_token=torch.tensor([625], dtype=torch.long, device=self.tts.device), logits_warpers=logits_warpers, @@ -2188,7 +1963,7 @@ def _generate_mel_spec_audio_streaming( past_key_values=past_kv, streaming_tts_text_mask=streaming_tts_text_mask, max_new_token=N, - force_no_stop=self.force_no_stop, + force_no_stop=force_no_stop, temperature=torch.tensor([0.1, 0.3, 0.1, 0.3], dtype=torch.float, device=self.tts.device), eos_token=torch.tensor([625], dtype=torch.long, device=self.tts.device), logits_warpers=logits_warpers, @@ -2259,7 +2034,7 @@ def _generate_mel_spec_audio_streaming( past_key_values=past_key_values, streaming_tts_text_mask=streaming_tts_text_mask, max_new_token=output_chunk_size, - force_no_stop=self.force_no_stop, + force_no_stop=force_no_stop, # temperature=torch.tensor([0.1] * self.tts.num_vq, dtype=torch.float, device=self.tts.device), temperature=torch.tensor([0.1, 0.3, 0.1, 0.3], dtype=torch.float, device=self.tts.device), eos_token=torch.tensor([625], dtype=torch.long, device=self.tts.device), @@ -2299,7 +2074,7 @@ def _generate_mel_spec_audio_streaming( past_key_values=past_kv, streaming_tts_text_mask=streaming_tts_text_mask, max_new_token=N, - force_no_stop=self.force_no_stop, + force_no_stop=force_no_stop, temperature=torch.tensor([0.1, 0.3, 0.1, 0.3], dtype=torch.float, device=self.tts.device), eos_token=torch.tensor([625], dtype=torch.long, device=self.tts.device), logits_warpers=logits_warpers, @@ -2363,7 +2138,8 @@ def _generate_mel_spec_audio_streaming( text_left, prev_seg_audio_ids, enable_regenerate=enable_regenerate, - tts_config={"top_p": 0.7, "top_k": 20, "repetition_penalty": 1.0} + tts_config={"top_p": 0.7, "top_k": 20, "repetition_penalty": 1.0}, + force_no_stop=force_no_stop, ) for res in result: yield res @@ -2853,6 +2629,7 @@ def __init__( nn.GELU(), ) + # These hardcodings match our model and cannot be modified. self.encoder = DVAEDecoder( idim=512, odim=1024, @@ -2879,20 +2656,21 @@ def __init__( ) @torch.inference_mode() - def forward(self, inp: torch.Tensor, mode: Literal["encode", "decode"] = "decode") -> torch.Tensor: - if mode == "encode" and hasattr(self, "encoder") and self.vq_layer is not None: - mel = inp.clone() - x: torch.Tensor = self.downsample_conv( - torch.div(mel, self.coef.view(100, 1).expand(mel.shape), out=mel), - ).unsqueeze_(0) - del mel - x = self.encoder(x) - ind = self.vq_layer(x) - del x - return ind + def encode(self, mel: torch.Tensor) -> torch.Tensor: + """Encode mel spectrogram to VQ indices.""" + mel = mel.clone() + x = self.downsample_conv(torch.div(mel, self.coef.view(100, 1).expand(mel.shape), out=mel)) + x = x.unsqueeze(0) + x = self.encoder(x) + ind = self.vq_layer(x) + del x + return ind + def decode(self, inp: torch.Tensor) -> torch.Tensor: + """Decode VQ inputs (either indices or embeddings) back to spectrogram.""" vq_feats = self.vq_layer._embed(inp) + # reshape embeddings vq_feats = ( vq_feats.view( (vq_feats.size(0), 2, vq_feats.size(1) // 2, vq_feats.size(2)), @@ -2901,16 +2679,19 @@ def forward(self, inp: torch.Tensor, mode: Literal["encode", "decode"] = "decode .flatten(2) ) - dec_out = self.out_conv( - self.decoder( - x=vq_feats, - ), - ) - + dec_out = self.decoder(x=vq_feats) + dec_out = self.out_conv(dec_out) del vq_feats - + # re-apply coef return torch.mul(dec_out, self.coef, out=dec_out) + def forward(self, inp: torch.Tensor) -> torch.Tensor: + """Standard forward = encode + decode → full reconstruction.""" + indices = self.encode(inp) + recon = self.decode(indices) + return recon + + def apply_spk_emb( input_ids: torch.Tensor = None, spk_emb: torch.Tensor = None, @@ -3129,7 +2910,7 @@ def _prepare_4d_causal_attention_mask_with_cache_position( class PatchLlamaModel(LlamaModel): """ This is a patch for LlamaModel to support our audio - Mainly modifies by changing create_causal_mask to _update_causal_mask + Mainly modifies by changing `create_causal_mask` to `_update_causal_mask` """ @can_return_tuple @@ -3180,8 +2961,8 @@ def forward( if position_ids is None: position_ids = cache_position.unsqueeze(0) - # ! in transformers>=4.53.1, this is `create_causal_mask`, but it will be wrong in our case - # so copy `_update_causal_mask` from LlamaModel which transformers=4.44.2 + # in transformers>=4.53.1, this is `create_causal_mask`, but it will be wrong in our case + # so copy `_update_causal_mask` from `LlamaModel` which transformers=4.44.2 causal_mask = self._update_causal_mask( attention_mask, inputs_embeds, cache_position, past_key_values, output_attentions ) @@ -3888,7 +3669,7 @@ def decode_to_mel_specs( batch_result[i].narrow(1, 0, src.size(0)).copy_(src.permute(1, 0)) del src - mel_specs = decoder(batch_result) + mel_specs = decoder.decode(batch_result) del batch_result return mel_specs diff --git a/src/transformers/models/minicpm_o_2_6/processing_minicpm_o_2_6.py b/src/transformers/models/minicpm_o_2_6/processing_minicpm_o_2_6.py index 96c12ef2fb95..7ed6bc4322b3 100644 --- a/src/transformers/models/minicpm_o_2_6/processing_minicpm_o_2_6.py +++ b/src/transformers/models/minicpm_o_2_6/processing_minicpm_o_2_6.py @@ -25,7 +25,10 @@ import numpy as np import torch import torchaudio +import json +from copy import deepcopy +from PIL import Image from transformers.image_utils import ImageInput from transformers.processing_utils import ProcessorMixin, ProcessingKwargs, Unpack, ImagesKwargs, AudioKwargs from transformers.tokenization_utils_base import PreTokenizedInput, TextInput @@ -155,15 +158,11 @@ class MiniCPM_o_2_6Processor(ProcessorMixin): image_processor_class = "AutoImageProcessor" feature_extractor_class = "MiniCPM_o_2_6FeatureExtractor" - def __init__(self, tokenizer=None, image_processor=None, feature_extractor=None): - super().__init__(tokenizer, image_processor, feature_extractor) + def __init__(self, tokenizer=None, image_processor=None, feature_extractor=None, chat_template=None): + super().__init__(tokenizer, image_processor, + feature_extractor, chat_template=chat_template) self.version = image_processor.version self.default_tts_chat_template = "{% for message in messages %}{{'<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n'}}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\n<|spk_bos|><|spk|><|spk_eos|><|tts_bos|>' }}{% endif %}" - self.image_tag = "(./)" - self.image_pattern = "\(./\)" - self.audio_tag = "()" - self.audio_pattern = "\(\)" - self.split_pattern = f"({self.image_pattern}|{self.audio_pattern})" def __call__( self, @@ -212,40 +211,130 @@ def __call__( return MiniCPMOBatchFeature(data={**model_inputs}) - # Copied from transformers.models.clip.processing_clip.CLIPProcessor.batch_decode with CLIP->Llama - def batch_decode(self, *args, **kwargs): - """ - This method forwards all its arguments to LlamaTokenizerFast's [`~PreTrainedTokenizer.batch_decode`]. Please - refer to the docstring of this method for more information. + def apply_chat_template( + self, + msgs, + chunk_input=True, + max_slice_nums=None, + max_length=32768, + omni_input=False, + use_image_id=None, + use_tts_template=False, + **kwargs, + ): """ - output_ids = args[0] - result_text = [] - for result in output_ids: - result = result[result != 0] - result_text.append(self.tokenizer.decode( - result, *args[1:], **kwargs).strip()) - return result_text - # return self.tokenizer.batch_decode(*args, **kwargs) + Unified chat function - # Copied from transformers.models.clip.processing_clip.CLIPProcessor.decode with CLIP->Llama - def decode(self, *args, **kwargs): - """ - This method forwards all its arguments to LlamaTokenizerFast's [`~PreTrainedTokenizer.decode`]. Please refer to - the docstring of this method for more information. + Args: + msgs: the input chat msgs, support text: (string) / image: (PIL.Image) / audio (numpy.ndarray) + chunk_input: whether to split audio into 1s chunks + max_length: the maximum length of input + max_slice_nums: control the maximum number of image slices + omni_input: determine whether it is omni mode + use_image_id: for video understanding or omni understanding, use_image_id should be False + use_tts_template: if the msgs contain audio, use_tts_template should be True """ - result = args[0] - result = result[result != 0] - return self.tokenizer.decode(result, *args[1:], **kwargs).strip() + if isinstance(msgs[0], list): + batched = True + else: + batched = False + + msgs_list = msgs + if not batched: + msgs_list = [msgs_list] + + prompts_lists = [] + input_images_list = [] + input_audios_list = [] + audio_parts_list = [] + + for msgs in msgs_list: + if isinstance(msgs, str): + msgs = json.loads(msgs) + copy_msgs = deepcopy(msgs) + + assert len(msgs) > 0, "msgs is empty" + + images = [] + audios = [] + audio_parts = [] + for i, msg in enumerate(copy_msgs): + role = msg["role"] + content = msg["content"] + assert role in ["system", "user", "assistant"] + if i == 0: + assert role in ["user", "system"], "The role of first msg should be user" + if isinstance(content, str): + content = [content] + cur_msgs = [] + for c in content: + if isinstance(c, Image.Image): + images.append(c) + cur_msgs.append("(./)") + elif isinstance(c, np.ndarray): # audio + audios.append(c) + audio_parts.append(i) + cur_msgs.append("()") + use_tts_template = True + elif isinstance(c, str): + cur_msgs.append(c) + if omni_input: + msg["content"] = "".join(cur_msgs) + else: + msg["content"] = "\n".join(cur_msgs) + + prompts_lists.append( + self.tokenizer.apply_chat_template( + copy_msgs, + tokenize=False, + add_generation_prompt=True, + chat_template=self.default_tts_chat_template if use_tts_template else None, + ) + ) + input_images_list.append(images) + input_audios_list.append(audios) + audio_parts_list.append(audio_parts) + + inputs = self.__call__( + prompts_lists, + input_images_list, + input_audios_list, + audio_parts=audio_parts_list, + max_slice_nums=max_slice_nums, + use_image_id=use_image_id, + chunk_input=chunk_input, + return_tensors="pt", + max_length=max_length, + ) + return inputs + + def decode(self, outputs, batched=False): + result = self.decode_text( + outputs.sequences, self.tokenizer) + if not batched: + result = result[0] + if isinstance(result, list): + result = [i.replace(self.tokenizer.tts_end, "") for i in result] + else: + result = result.replace(self.tokenizer.tts_end, "") + return result - def decode_text(self, result_ids, tokenizer, terminators): - terminators = [tokenizer.convert_tokens_to_ids(i) for i in terminators] + def decode_text(self, result_ids, tokenizer): result_text = [] for result in result_ids: result = result[result != 0] - if result[0] == tokenizer.bos_id: - result = result[1:] - if result[-1] in terminators: - result = result[:-1] + start, end = 0, len(result) + for i, tok in enumerate(result): + if tok == tokenizer.bos_id: + start = i+1 + else: + break + for i in range(len(result)-1, -1, -1): + if result[i] in tokenizer.terminator_ids: + end = i + else: + break + result = result[start:end] result_text.append(tokenizer.decode(result)) return result_text @@ -421,10 +510,10 @@ def _convert_omni_to_inputs( spk_bounds_list = [] for index, text in enumerate(texts): - text_chunks = re.split(self.split_pattern, text) + text_chunks = re.split(self.tokenizer.split_pattern, text) - image_tags = re.findall(self.image_pattern, text) - audio_tags = re.findall(self.audio_pattern, text) + image_tags = re.findall(self.tokenizer.image_pattern, text) + audio_tags = re.findall(self.tokenizer.audio_pattern, text) if image_tags: assert images is not None @@ -436,13 +525,13 @@ def _convert_omni_to_inputs( image_id = 0 audio_id = 0 for i, chunk in enumerate(text_chunks): - if chunk == self.image_tag: + if chunk == self.tokenizer.image_tag: image_placeholder = self.image_processor.get_slice_image_placeholder( self.tokenizer, image_sizes[index][image_id], image_id, max_slice_nums, use_image_id ) image_id += 1 text_chunks[i] = image_placeholder - elif chunk == self.audio_tag: + elif chunk == self.tokenizer.audio_tag: audio_placeholder = audio_phs[index][audio_id] audio_id += 1 text_chunks[i] = audio_placeholder diff --git a/src/transformers/models/minicpm_o_2_6/tokenization_minicpm_o_2_6_fast.py b/src/transformers/models/minicpm_o_2_6/tokenization_minicpm_o_2_6_fast.py index 2ec227488fb0..8d943508c40e 100644 --- a/src/transformers/models/minicpm_o_2_6/tokenization_minicpm_o_2_6_fast.py +++ b/src/transformers/models/minicpm_o_2_6/tokenization_minicpm_o_2_6_fast.py @@ -15,6 +15,7 @@ from transformers import Qwen2TokenizerFast + class MiniCPM_o_2_6TokenizerFast(Qwen2TokenizerFast): def __init__(self, **kwargs): super().__init__(**kwargs) @@ -31,6 +32,8 @@ def __init__(self, **kwargs): self.slice_end = "" self.im_id_start = "" self.im_id_end = "" + self.image_tag = f"({self.im_start}./{self.im_end})" + self.image_pattern = "\(./\)" # audio self.audio_start = "<|audio_start|>" @@ -40,6 +43,12 @@ def __init__(self, **kwargs): self.tts_start = "<|tts_bos|>" self.tts_end = "<|tts_eos|>" self.unk_token = "" + self.audio_tag = "()" + self.audio_pattern = "\(\)" + + self.split_pattern = f"({self.image_pattern}|{self.audio_pattern})" + + self.terminator_tokens = ["<|im_end|>", "<|endoftext|>", self.tts_end] @property def eos_id(self): @@ -53,6 +62,10 @@ def bos_id(self): def unk_id(self): return self.unk_token_id + @property + def terminators(self): + return self.terminator_tokens + @property def im_start_id(self): return self.convert_tokens_to_ids(self.im_start) @@ -101,6 +114,10 @@ def tts_start_id(self): def tts_end_id(self): return self.convert_tokens_to_ids(self.tts_end) + @property + def terminator_ids(self): + return [self.convert_tokens_to_ids(t) for t in self.terminator_tokens] + @staticmethod def escape(text: str) -> str: return text diff --git a/tests/models/minicpm_o_2_6/test.py b/tests/models/minicpm_o_2_6/test.py deleted file mode 100644 index f4fb3f622156..000000000000 --- a/tests/models/minicpm_o_2_6/test.py +++ /dev/null @@ -1,47 +0,0 @@ -from transformers import AutoModelForCausalLM, AutoModel, AutoTokenizer -import torch - -model_path = "xxx" - -model = AutoModel.from_pretrained( - model_path, - trust_remote_code=True, - attn_implementation='sdpa', - torch_dtype=torch.bfloat16, - init_vision=True, - init_audio=True, - init_tts=True -) - - -tokenizer = AutoTokenizer.from_pretrained( - model_path, - trust_remote_code=True, - local_files_only=True, - ) - -# device = torch.device("mps") -# model.to(device) - -from PIL import Image - -image = Image.open('xxx.jpeg').convert('RGB') -question = 'What is in the image?' -msgs = [{'role': 'user', 'content': [image, question]}] -res = model.chat( - image=None, - msgs=msgs, - tokenizer=tokenizer -) -print(res) - -res = model.chat( - msgs=msgs, - tokenizer=tokenizer, - sampling=True, - stream=True -) -generated_text = "" -for new_text in res: - generated_text += new_text - print(new_text, flush=True, end='') \ No newline at end of file diff --git a/tests/models/minicpm_o_2_6/test_modeling_minicpm_o_2_6.py b/tests/models/minicpm_o_2_6/test_modeling_minicpm_o_2_6.py index a3a4e36da4d5..2fed53bd8d07 100644 --- a/tests/models/minicpm_o_2_6/test_modeling_minicpm_o_2_6.py +++ b/tests/models/minicpm_o_2_6/test_modeling_minicpm_o_2_6.py @@ -21,14 +21,14 @@ import numpy as np import librosa import soundfile as sf -from moviepy.editor import VideoFileClip +from moviepy import VideoFileClip from PIL import Image import requests from io import BytesIO from transformers import ( AutoModel, - AutoTokenizer, + AutoProcessor, is_torch_available, ) from transformers.testing_utils import ( @@ -38,12 +38,12 @@ require_vision, require_soundfile, require_sentencepiece, - require_tokenizers, ) if is_torch_available(): import torch + @require_torch class MiniCPM_o_2_6ModelIngestionTest(unittest.TestCase): """Test for MiniCPM_o_2_6Model.""" @@ -55,7 +55,7 @@ def setUp(self): self.video_path = os.path.join(self.assets_dir, "Skiing.mp4") self.ref_audio_path = os.path.join(self.assets_dir, "demo.wav") - + if not os.path.exists(self.video_path): video_url = "https://huggingface.co/openbmb/MiniCPM-o-2_6/resolve/main/assets/Skiing.mp4" response = requests.get(video_url, stream=True) @@ -72,18 +72,15 @@ def setUp(self): for chunk in response.iter_content(chunk_size=8192): f.write(chunk) + self.model_path = "openbmb/MiniCPM-o-2_6" self.model = AutoModel.from_pretrained( - "openbmb/MiniCPM-o-2_6", - trust_remote_code=True, + self.model_path, attn_implementation='sdpa', - torch_dtype=torch.bfloat16, - init_vision=True, - init_audio=True, - init_tts=True + torch_dtype=torch.bfloat16 ) self.model = self.model.eval().to(torch_device) - self.tokenizer = AutoTokenizer.from_pretrained('openbmb/MiniCPM-o-2_6', trust_remote_code=True) self.model.init_tts() + self.processor = AutoProcessor.from_pretrained(self.model_path) def tearDown(self): """clean up test environment""" @@ -94,16 +91,10 @@ def tearDown(self): if os.path.exists(self.assets_dir): os.rmdir(self.assets_dir) - @slow - def test_minicpm_o_2_6_model_base(self): - """test base model loading""" - base_model = AutoModel.from_pretrained("openbmb/MiniCPM-o-2_6") - self.assertIsNotNone(base_model) - def _get_video_chunk_content(self, video_path, flatten=True): """process video content, extract frames and audio""" video = VideoFileClip(video_path) - + with tempfile.NamedTemporaryFile(suffix=".wav", delete=True) as temp_audio_file: video.audio.write_audiofile( temp_audio_file.name, @@ -111,21 +102,22 @@ def _get_video_chunk_content(self, video_path, flatten=True): fps=16000, logger=None ) - audio_np, sr = librosa.load(temp_audio_file.name, sr=16000, mono=True) - + audio_np, sr = librosa.load( + temp_audio_file.name, sr=16000, mono=True) + num_units = math.ceil(video.duration) contents = [] - + for i in range(num_units): frame = video.get_frame(i+1) image = Image.fromarray(frame.astype(np.uint8)) audio_segment = audio_np[sr*i:sr*(i+1)] - + if flatten: contents.extend(["", image, audio_segment]) else: contents.append(["", image, audio_segment]) - + video.close() return contents @@ -133,21 +125,25 @@ def _get_video_chunk_content(self, video_path, flatten=True): @require_vision @require_soundfile @require_sentencepiece - @require_tokenizers - def test_omni_chat(self): + def test_omni_generate(self): try: - ref_audio, _ = librosa.load(self.ref_audio_path, sr=16000, mono=True) - sys_msg = self.model.get_sys_prompt(ref_audio=ref_audio, mode='omni', language='en') - + ref_audio, _ = librosa.load( + self.ref_audio_path, sr=16000, mono=True) + sys_msg = self.processor.get_sys_prompt( + ref_audio=ref_audio, mode='omni', language='en') + contents = self._get_video_chunk_content(self.video_path) msg = {"role": "user", "content": contents} msgs = [sys_msg, msg] + inputs = self.processor.apply_chat_template( + msgs=msgs).to(self.model.device) - output_audio_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name + output_audio_path = tempfile.NamedTemporaryFile( + suffix=".wav", delete=False).name - res = self.model.chat( - msgs=msgs, - tokenizer=self.tokenizer, + res = self.model.generate( + **inputs, + processor=self.processor, sampling=True, temperature=0.5, max_new_tokens=128, @@ -159,16 +155,17 @@ def test_omni_chat(self): use_image_id=False, return_dict=True ) + res = self.processor.decode(res.outputs) self.assertIsNotNone(res, "Chat response should not be empty") - self.assertIn("text", res, "Chat response should contain text") - self.assertTrue(len(res["text"]) > 0, "Chat response text should not be empty") - - if res.get("audio_path"): - self.assertTrue(sf.info(output_audio_path).frames > 0, "Generated audio file should not be empty") - + self.assertTrue(len(res) > 0, + "Chat response text should not be empty") + self.assertTrue(sf.info(output_audio_path).frames > + 0, "Generated audio file should not be empty") + except FileNotFoundError: - self.skipTest(f"资源文件未找到: {self.video_path} 或 {self.ref_audio_path}") + self.skipTest( + f"资源文件未找到: {self.video_path} 或 {self.ref_audio_path}") finally: if 'output_audio_path' in locals() and os.path.exists(output_audio_path): os.remove(output_audio_path) @@ -177,44 +174,45 @@ def test_omni_chat(self): @require_vision @require_soundfile @require_sentencepiece - @require_tokenizers def test_streaming_inference(self): try: self.model.reset_session() - - ref_audio, _ = librosa.load(self.ref_audio_path, sr=16000, mono=True) - sys_msg = self.model.get_sys_prompt(ref_audio=ref_audio, mode='omni', language='en') - - contents = self._get_video_chunk_content(self.video_path, flatten=False) + + ref_audio, _ = librosa.load( + self.ref_audio_path, sr=16000, mono=True) + sys_msg = self.processor.get_sys_prompt( + ref_audio=ref_audio, mode='omni', language='en') + + contents = self._get_video_chunk_content( + self.video_path, flatten=False) session_id = 'test_session' generate_audio = True - res = self.model.streaming_prefill( + self.model.streaming_prefill( session_id=session_id, msgs=[sys_msg], - tokenizer=self.tokenizer + processor=self.processor, ) - self.assertIsNotNone(res, "System prompt prefilling should not return empty") for content in contents: msgs = [{"role": "user", "content": content}] - res = self.model.streaming_prefill( + self.model.streaming_prefill( session_id=session_id, msgs=msgs, - tokenizer=self.tokenizer + processor=self.processor, ) - self.assertIsNotNone(res, "Content prefilling should not return empty") res = self.model.streaming_generate( session_id=session_id, - tokenizer=self.tokenizer, + processor=self.processor, temperature=0.5, generate_audio=generate_audio ) audios = [] text = "" - output_audio_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name + output_audio_path = tempfile.NamedTemporaryFile( + suffix=".wav", delete=False).name if generate_audio: for r in res: @@ -226,17 +224,22 @@ def test_streaming_inference(self): text += txt res_audio = np.concatenate(audios) - sf.write(output_audio_path, res_audio, samplerate=sampling_rate) - - self.assertTrue(len(text) > 0, "Generated text should not be empty") - self.assertTrue(sf.info(output_audio_path).frames > 0, "Generated audio file should not be empty") + sf.write(output_audio_path, res_audio, + samplerate=sampling_rate) + + self.assertTrue( + len(text) > 0, "Generated text should not be empty") + self.assertTrue(sf.info(output_audio_path).frames > + 0, "Generated audio file should not be empty") else: for r in res: text += r['text'] - self.assertTrue(len(text) > 0, "Generated text should not be empty") + self.assertTrue( + len(text) > 0, "Generated text should not be empty") except FileNotFoundError: - self.skipTest(f"Resource file not found: {self.video_path} or {self.ref_audio_path}") + self.skipTest( + f"Resource file not found: {self.video_path} or {self.ref_audio_path}") finally: if 'output_audio_path' in locals() and os.path.exists(output_audio_path): os.remove(output_audio_path) @@ -244,14 +247,12 @@ def test_streaming_inference(self): @slow @require_soundfile @require_sentencepiece - @require_tokenizers def test_audio_mimick(self): try: self.model.init_tts() - self.model.tts.float() mimick_prompt = "Please repeat each user's speech, including voice style and speech content." - + audio_urls = [ 'https://huggingface.co/openbmb/MiniCPM-o-2_6/resolve/main/assets/input_examples/Trump_WEF_2018_10s.mp3', 'https://huggingface.co/openbmb/MiniCPM-o-2_6/resolve/main/assets/input_examples/cxk_original.wav', @@ -262,24 +263,29 @@ def test_audio_mimick(self): for audio_url in audio_urls: try: - + response = requests.get(audio_url, stream=True) response.raise_for_status() - + with tempfile.NamedTemporaryFile(suffix=os.path.splitext(audio_url)[1], delete=False) as temp_file: for chunk in response.iter_content(chunk_size=8192): temp_file.write(chunk) temp_file_path = temp_file.name - audio_input, _ = librosa.load(temp_file_path, sr=16000, mono=True) - - msgs = [{'role': 'user', 'content': [mimick_prompt, audio_input]}] - - output_audio_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name - - res = self.model.chat( - msgs=msgs, - tokenizer=self.tokenizer, + audio_input, _ = librosa.load( + temp_file_path, sr=16000, mono=True) + + msgs = [{'role': 'user', 'content': [ + mimick_prompt, audio_input]}] + inputs = self.processor.apply_chat_template( + msgs=msgs).to(self.model.device) + + output_audio_path = tempfile.NamedTemporaryFile( + suffix=".wav", delete=False).name + + res = self.model.generate( + **inputs, + processor=self.processor, sampling=True, max_new_tokens=128, use_tts_template=True, @@ -287,10 +293,14 @@ def test_audio_mimick(self): generate_audio=True, output_audio_path=output_audio_path ) + res = self.processor.decode(res.outputs) - self.assertIsNotNone(res, "Mimic response should not be empty") - self.assertTrue(os.path.exists(output_audio_path), "Output audio file should exist") - self.assertTrue(sf.info(output_audio_path).frames > 0, "Generated audio file should not be empty") + self.assertIsNotNone( + res, "Mimic response should not be empty") + self.assertTrue(os.path.exists( + output_audio_path), "Output audio file should exist") + self.assertTrue(sf.info(output_audio_path).frames > + 0, "Generated audio file should not be empty") except requests.exceptions.RequestException as e: self.skipTest(f"Failed to download audio file: {str(e)}") @@ -308,44 +318,47 @@ def test_audio_mimick(self): @slow @require_vision @require_sentencepiece - @require_tokenizers def test_single_image_inference(self): try: - image_url = "https://www.ilankelman.org/stopsigns/australia.jpg" + image_url = "https://bkimg.cdn.bcebos.com/pic/d043ad4bd11373f082022267f9585cfbfbedaa64aeb6" response = requests.get(image_url, stream=True) response.raise_for_status() - + image = Image.open(BytesIO(response.content)).convert('RGB') question = 'What is in the image?' - + msgs = [{'role': 'user', 'content': [image, question]}] - - res = self.model.chat( - image=None, - msgs=msgs, - tokenizer=self.tokenizer + inputs = self.processor.apply_chat_template( + msgs=msgs).to(self.model.device) + + res = self.model.generate( + **inputs, + processor=self.processor, ) - - self.assertIsNotNone(res, "Normal inference response should not be empty") - self.assertTrue(len(res) > 0, "Normal inference response text should not be empty") - - res = self.model.chat( - msgs=msgs, - tokenizer=self.tokenizer, + res = self.processor.decode(res) + + self.assertIsNotNone( + res, "Normal inference response should not be empty") + self.assertTrue( + len(res) > 0, "Normal inference response text should not be empty") + + res = self.model.generate( + **inputs, + processor=self.processor, sampling=True, stream=True ) - + generated_text = "" for new_text in res: generated_text += new_text - self.assertIsNotNone(new_text, "Each part of streaming reasoning should not be empty") - - self.assertTrue(len(generated_text) > 0, "Text should not be empty") - + self.assertIsNotNone( + new_text, "Each part of streaming reasoning should not be empty") + + self.assertTrue(len(generated_text) > 0, + "Text should not be empty") + except requests.exceptions.RequestException as e: self.skipTest(f"Failed to download image: {str(e)}") except Exception as e: self.fail(f"Single image inference test failed: {str(e)}") - - \ No newline at end of file diff --git a/tests/models/minicpm_o_2_6/test_processing_minicpm_o_2_6.py b/tests/models/minicpm_o_2_6/test_processing_minicpm_o_2_6.py index 36cd6ca33aa9..624e660f30ab 100644 --- a/tests/models/minicpm_o_2_6/test_processing_minicpm_o_2_6.py +++ b/tests/models/minicpm_o_2_6/test_processing_minicpm_o_2_6.py @@ -13,23 +13,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -import inspect import shutil import tempfile import unittest import numpy as np -import pytest +from PIL import Image import librosa import requests +import pytest +import soundfile as sf +import io from transformers import ( AutoProcessor, MiniCPM_o_2_6Processor, - AutoTokenizer, - WhisperFeatureExtractor, ) -from transformers.testing_utils import require_av, require_librosa, require_torch, require_torchaudio, require_vision +from transformers.testing_utils import require_librosa, require_torch, require_torchaudio, require_vision from transformers.utils import is_torch_available, is_vision_available from ...test_processing_common import ProcessorTesterMixin @@ -45,14 +45,29 @@ @require_vision @require_torch @require_torchaudio -class MiniCPM_o_2_6ProcessorTest(ProcessorTesterMixin, unittest.TestCase): +class MiniCPM_o_2_6ProcessorTest(unittest.TestCase): processor_class = MiniCPM_o_2_6Processor - def setUp(self): - self.tmpdirname = tempfile.mkdtemp() - processor_kwargs = self.prepare_processor_dict() - processor = MiniCPM_o_2_6Processor.from_pretrained("openbmb/MiniCPM-O-2.6", **processor_kwargs) - processor.save_pretrained(self.tmpdirname) + # 添加这个属性,告知测试框架处理器有自己的chat template实现 + has_chat_template = True + + # 输入名称定义 + images_input_name = "pixel_values" + audio_input_name = "audio_features" + + @classmethod + def setUpClass(cls): + cls.tmpdirname = tempfile.mkdtemp() + processor_kwargs = cls.prepare_processor_dict() + processor = AutoProcessor.from_pretrained( + "openbmb/MiniCPM-o-2_6", **processor_kwargs) + processor.save_pretrained(cls.tmpdirname) + + @staticmethod + def prepare_processor_dict(): + return { + "chat_template": "{% for message in messages %}{% if message['role'] == 'user' %}<|im_start|>user\n{{ message['content'] }}<|im_end|>\n{% elif message['role'] == 'assistant' %}<|im_start|>assistant\n{{ message['content'] }}<|im_end|>\n{% endif %}{% endfor %}" + } def get_tokenizer(self, **kwargs): return AutoProcessor.from_pretrained(self.tmpdirname, **kwargs).tokenizer @@ -63,342 +78,225 @@ def get_image_processor(self, **kwargs): def get_feature_extractor(self, **kwargs): return AutoProcessor.from_pretrained(self.tmpdirname, **kwargs).feature_extractor - @staticmethod - def prepare_processor_dict(): - return { - "chat_template": "{% for message in messages %}{% if message['role'] == 'user' %}<|im_start|>user\n{{ message['content'] }}<|im_end|>\n{% elif message['role'] == 'assistant' %}<|im_start|>assistant\n{{ message['content'] }}<|im_end|>\n{% endif %}{% endfor %}" - } + def get_processor(self, **kwargs): + return MiniCPM_o_2_6Processor.from_pretrained(self.tmpdirname, **kwargs) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmpdirname, ignore_errors=True) def tearDown(self): - shutil.rmtree(self.tmpdirname, ignore_errors=True) + # 防止复写父类的tearDown方法 + pass - def prepare_image_inputs(self): + def prepare_image_inputs(self, batch_size=1): """Preparing the image input for testing""" - image_url = "https://www.ilankelman.org/stopsigns/australia.jpg" + image_url = "https://bkimg.cdn.bcebos.com/pic/d043ad4bd11373f082022267f9585cfbfbedaa64aeb6" image = Image.open(requests.get(image_url, stream=True).raw) - return image + if batch_size == 1: + return image + return [[image]] * batch_size - def prepare_audio_inputs(self): + def prepare_audio_inputs(self, batch_size=1): """Preparing the audio input for testing""" audio_url = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen2-Audio/audio/f2641_0_throatclearing.wav" - try: - audio, _ = librosa.load(audio_url, sr=16000) + response = requests.get(audio_url) + audio, _ = librosa.load(io.BytesIO( + response.content), sr=16000, mono=True) + if batch_size == 1: return audio - except: - # 如果下载失败,使用随机生成的音频 - return np.random.rand(160000) * 2 - 1 # 模拟1秒的音频 - - def test_save_load_pretrained_default(self): - """Test saving and loading pretrained models""" - image_processor = self.get_image_processor() - tokenizer = self.get_tokenizer() - feature_extractor = self.get_feature_extractor() - processor = self.processor_class( - tokenizer=tokenizer, - feature_extractor=feature_extractor, - image_processor=image_processor, - ) - - processor.save_pretrained(self.tmpdirname) - processor = MiniCPM_o_2_6Processor.from_pretrained(self.tmpdirname) - - self.assertEqual(processor.tokenizer.get_vocab(), tokenizer.get_vocab()) - self.assertEqual(processor.image_processor.to_json_string(), image_processor.to_json_string()) - self.assertEqual(processor.feature_extractor.to_json_string(), feature_extractor.to_json_string()) - - def test_image_processor(self): - """Testing image processing functions""" - image_processor = self.get_image_processor() - tokenizer = self.get_tokenizer() - feature_extractor = self.get_feature_extractor() - processor = self.processor_class( - tokenizer=tokenizer, - feature_extractor=feature_extractor, - image_processor=image_processor, - ) + return [[audio]] * batch_size + + def prepare_text_inputs(self, batch_size=1, modality="text"): + """准备文本输入""" + if modality == "text": + text = "Hello, how are you?" + elif modality == "image": + text = "What is in this image: (./)?" + elif modality == "audio": + text = "What was said in this audio: ()?" + elif modality == "mixed": + text = "Describe this image (./) and this audio ()." + + if batch_size == 1: + return text + return [text] * batch_size + + def test_processor_with_multi_modal_inputs(self): + """Test the processor with multiple modality inputs""" + processor = self.get_processor() - image_input = self.prepare_image_inputs() - input_image_proc = image_processor(image_input, return_tensors="np") - input_processor = processor(images=image_input, text="dummy", return_tensors="np") - - for key in input_image_proc.keys(): - self.assertAlmostEqual(input_image_proc[key].sum(), input_processor[key].sum(), delta=1e-2) - - def test_processor(self): - """Testing overall processing functions""" - image_processor = self.get_image_processor() - tokenizer = self.get_tokenizer() - feature_extractor = self.get_feature_extractor() - processor = self.processor_class( - tokenizer=tokenizer, - feature_extractor=feature_extractor, - image_processor=image_processor, - ) + input_text = self.prepare_text_inputs(modality="mixed") + image = self.prepare_image_inputs() + audio = self.prepare_audio_inputs() - input_str = "Test text" - image_input = self.prepare_image_inputs() - audio_input = self.prepare_audio_inputs() - inputs = processor(text=input_str, images=image_input, audios=audio_input) - - keys = list(inputs.keys()) - self.assertListEqual( - keys, - [ - "input_ids", - "attention_mask", - "pixel_values", - "image_sizes", - "image_bound", - "tgt_sizes", - "audio_features", - "audio_feature_lens", - "audio_bounds", - "spk_bounds", - ], - ) + # Test with all modalities + inputs = processor(text=[input_text], images=[image], + audios=[audio], return_tensors="pt") - # no input - with pytest.raises(ValueError): - processor() - - # no text input - with pytest.raises(ValueError): - processor(images=image_input) - - def test_model_input_names(self): - """Testing model input names""" - image_processor = self.get_image_processor() - tokenizer = self.get_tokenizer() - feature_extractor = self.get_feature_extractor() - processor = self.processor_class( - tokenizer=tokenizer, - feature_extractor=feature_extractor, - image_processor=image_processor, - ) + # 检查输入中包含所有模态相关的键 + self.assertIn("input_ids", inputs) + self.assertIn("attention_mask", inputs) + self.assertIn("pixel_values", inputs) + self.assertIn("image_bound", inputs) + self.assertIn("audio_features", inputs) + self.assertIn("audio_bounds", inputs) - input_str = "Test text" - image_input = self.prepare_image_inputs() - audio_input = self.prepare_audio_inputs() + # 检查处理器能处理批量输入 + batch_size = 2 + input_texts = self.prepare_text_inputs( + batch_size=batch_size, modality="mixed") + images = self.prepare_image_inputs(batch_size=batch_size) + audios = self.prepare_audio_inputs(batch_size=batch_size) + + batch_inputs = processor( + text=input_texts, images=images, audios=audios, return_tensors="pt") + self.assertEqual(batch_inputs["input_ids"].shape[0], batch_size) + self.assertEqual(batch_inputs["attention_mask"].shape[0], batch_size) + + @require_torch + def test_apply_chat_template_text(self): + """Test applying chat template with text only""" + processor = self.get_processor() - inputs = processor(text=input_str, images=image_input, audios=audio_input) - self.assertListEqual(sorted(inputs.keys()), sorted(processor.model_input_names)) + # 准备聊天消息 + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello, how are you?"}, + {"role": "assistant", "content": "I'm doing well, thank you!"} + ] - def test_apply_chat_template(self): - """Testing chat template application""" + # 测试不带标记化的应用 + formatted_prompt = processor.tokenizer.apply_chat_template( + messages, tokenize=False) + self.assertIsInstance(formatted_prompt, str) + self.assertTrue("<|im_start|>" in formatted_prompt) + self.assertTrue("<|im_end|>" in formatted_prompt) + + # 测试带标记化的应用 + tokenized_prompt = processor.tokenizer.apply_chat_template( + messages, tokenize=True, return_tensors="pt") + self.assertIsInstance(tokenized_prompt, torch.Tensor) + + @require_torch + @require_vision + def test_apply_chat_template_with_image(self): + """Test applying chat template with image""" processor = self.get_processor() - if processor.chat_template is None: - self.skipTest("Processor does not have a chat template") + image = self.prepare_image_inputs() + # 准备包含图像的消息 messages = [ - [ - { - "role": "user", - "content": "Please describe this image.", - }, - { - "role": "assistant", - "content": "This is an image.", - }, - ] + {"role": "user", "content": [image, "What is in this image?"]} ] - formatted_prompt = processor.apply_chat_template(messages, add_generation_prompt=True, tokenize=False) - self.assertEqual(len(formatted_prompt), 1) + # 测试聊天模板应用 + inputs = processor.apply_chat_template( + msgs=messages, omni_input=True, return_tensors="pt") - formatted_prompt_tokenized = processor.apply_chat_template( - messages, add_generation_prompt=True, tokenize=True - ) - expected_output = processor.tokenizer(formatted_prompt, return_tensors=None).input_ids - self.assertListEqual(expected_output, formatted_prompt_tokenized) + # 检查输出中有图像相关的数据 + self.assertIn("input_ids", inputs) + self.assertIn("attention_mask", inputs) + self.assertIn("pixel_values", inputs) + self.assertIn("image_bound", inputs) - def test_audio_processing(self): - """Testing audio processing functions""" + @require_torch + @require_librosa + def test_apply_chat_template_with_audio(self): + """Test applying chat template with audio""" processor = self.get_processor() - audio_input = self.prepare_audio_inputs() - - # test audio feature extraction - audio_features = processor.feature_extractor( - audio_input, - sampling_rate=16000, - return_attention_mask=True, - padding="max_length", - return_tensors="pt", - ) - - self.assertIn("input_features", audio_features) - self.assertIn("attention_mask", audio_features) - - # test audio and text combination processing - inputs = processor( - text="Describe this audio", - audios=audio_input, - return_tensors="pt", + audio = self.prepare_audio_inputs() + + # 准备包含音频的消息 + messages = [ + {"role": "user", "content": [ + audio, "What was said in this audio?"]} + ] + + # 测试聊天模板应用 + inputs = processor.apply_chat_template( + msgs=messages, omni_input=True, use_tts_template=True, return_tensors="pt" ) - + + # 检查输出中有音频相关的数据 + self.assertIn("input_ids", inputs) + self.assertIn("attention_mask", inputs) self.assertIn("audio_features", inputs) - self.assertIn("audio_feature_lens", inputs) self.assertIn("audio_bounds", inputs) - def test_number_to_text_converter(self): - """Testing number to text conversion functions""" - processor = self.get_processor() - converter = processor.NumberToTextConverter() - - # test chinese number conversion - chinese_text = converter.replace_numbers_with_text("我有2个苹果", language="chinese") - self.assertEqual(chinese_text, "我有两个苹果") - - # test english number conversion - english_text = converter.replace_numbers_with_text("I have 23 books", language="english") - self.assertEqual(english_text, "I have two three books") - - # test automatic language detection - mixed_text = converter.replace_numbers_with_text("我有3个苹果和4个梨") - self.assertIn("三", mixed_text) - self.assertIn("四", mixed_text) - - def test_voice_checker(self): - """Testing voice checking functions""" + @require_torch + @require_vision + @require_librosa + def test_apply_chat_template_with_mixed_media(self): + """Test applying chat template with both image and audio""" processor = self.get_processor() - checker = processor.VoiceChecker() - - # test silent detection - silent_audio = np.zeros(16000) # 1 second of silence - mel_spec = np.random.rand(100, 100) # simulate mel spectrogram - is_bad = checker.is_bad(silent_audio, mel_spec) - self.assertTrue(is_bad) - - # test normal audio - normal_audio = np.random.rand(16000) * 2 - 1 # 1 second of normal audio - is_bad = checker.is_bad(normal_audio, mel_spec) - self.assertFalse(is_bad) - - # test reset function - checker.reset() - self.assertIsNone(checker.previous_mel) - self.assertEqual(checker.consecutive_zeros, 0) - self.assertEqual(checker.consecutive_low_distance, 0) - - def test_image_processor_basic(self): - """Testing basic image processor functions""" - image_processor = self.get_image_processor() - - # test image preprocessing image = self.prepare_image_inputs() - processed = image_processor.preprocess(image) - - self.assertIn("pixel_values", processed) - self.assertIn("image_sizes", processed) - self.assertIn("tgt_sizes", processed) - - # test image slicing function - sliced_images = image_processor.get_sliced_images(image) - self.assertIsInstance(sliced_images, list) - self.assertTrue(len(sliced_images) > 0) - - # test image placeholder generation - placeholder = image_processor.get_slice_image_placeholder(image.size) - self.assertIsInstance(placeholder, str) - self.assertTrue(len(placeholder) > 0) - - def test_image_processor_edge_cases(self): - """Testing image processor edge cases""" - image_processor = self.get_image_processor() - - # test empty image list - with self.assertRaises(ValueError): - image_processor.preprocess([]) - - # test invalid image - invalid_image = np.zeros((100, 100)) - with self.assertRaises(ValueError): - image_processor.preprocess(invalid_image) - - # test large image - large_image = Image.new('RGB', (2000, 2000)) - processed = image_processor.preprocess(large_image) - self.assertIn("pixel_values", processed) - - def test_chat_tts_processor(self): - """Testing ChatTTS processor functions""" - processor = self.get_processor() - tts_processor = processor.ChatTTSProcessor(processor.tokenizer) - - # prepare test data - text_list = ["Test text 1", "Test text 2"] - audio_list = [np.random.rand(16000) for _ in range(2)] - - # test processing function - outputs = tts_processor(text_list, audio_list) - - self.assertIn("tts_input_ids_varlen", outputs) - self.assertIn("tts_input_features_varlen", outputs) - self.assertEqual(len(outputs["tts_input_ids_varlen"]), len(text_list)) - self.assertEqual(len(outputs["tts_input_features_varlen"]), len(audio_list)) - - def test_mel_spectrogram_features(self): - """Testing mel spectrogram feature extraction functions""" - processor = self.get_processor() - mel_processor = processor.MelSpectrogramFeatures() - - # prepare test audio - audio = torch.randn(1, 16000) # 1 second of audio - - # test feature extraction - features = mel_processor(audio) - - self.assertIsInstance(features, torch.Tensor) - self.assertEqual(features.dim(), 2) # should be 2D tensor - self.assertEqual(features.shape[0], 100) # mel filter number - - def test_batch_processing(self): - """Testing batch processing functions""" - processor = self.get_processor() - - # prepare batch processing data - texts = ["Text 1", "Text 2", "Text 3"] - images = [self.prepare_image_inputs() for _ in range(3)] - audios = [np.random.rand(16000) for _ in range(3)] - - # test batch processing - batch_outputs = processor( - text=texts, - images=images, - audios=audios, - return_tensors="pt" + audio = self.prepare_audio_inputs() + + # 准备包含多种媒体的消息 + messages = [ + {"role": "user", "content": [ + image, audio, "Describe this image and audio."]} + ] + + # 测试聊天模板应用 + inputs = processor.apply_chat_template( + msgs=messages, omni_input=True, use_tts_template=True, return_tensors="pt" ) - - # verify outputs - self.assertIn("input_ids", batch_outputs) - self.assertIn("attention_mask", batch_outputs) - self.assertIn("pixel_values", batch_outputs) - self.assertIn("audio_features", batch_outputs) - - # verify batch size - self.assertEqual(batch_outputs["input_ids"].shape[0], len(texts)) - self.assertEqual(len(batch_outputs["pixel_values"]), len(images)) - self.assertEqual(len(batch_outputs["audio_features"]), len(audios)) - - def test_error_handling(self): - """Testing error handling functions""" + + # 检查输出中有所有模态相关的数据 + self.assertIn("input_ids", inputs) + self.assertIn("attention_mask", inputs) + self.assertIn("pixel_values", inputs) + self.assertIn("image_bound", inputs) + self.assertIn("audio_features", inputs) + self.assertIn("audio_bounds", inputs) + + def test_get_sys_prompt(self): + """Test the system prompt generation function""" + processor = self.get_processor() + audio = self.prepare_audio_inputs() + + # 测试默认系统提示 + sys_prompt = processor.get_sys_prompt() + self.assertIn("role", sys_prompt) + self.assertIn("content", sys_prompt) + + # 测试 omni 模式 + sys_prompt = processor.get_sys_prompt(mode="omni", language="zh") + self.assertIn("你是一个AI助手", sys_prompt["content"][0]) + + # 测试英文提示 + sys_prompt = processor.get_sys_prompt(mode="omni", language="en") + self.assertIn("You are a helpful assistant", sys_prompt["content"][0]) + + # 测试语音克隆提示 + sys_prompt = processor.get_sys_prompt( + ref_audio=audio, mode="voice_cloning", language="en") + self.assertIn("Clone the voice", sys_prompt["content"][0]) + + # 测试语音助手模式 + sys_prompt = processor.get_sys_prompt( + ref_audio=audio, mode="audio_assistant", language="zh") + self.assertIn("模仿输入音频中的声音特征", sys_prompt["content"][0]) + + def test_decode(self): + """Test the decoding functionality""" processor = self.get_processor() - - # test invalid input - with self.assertRaises(ValueError): - processor(text=None, images=None, audios=None) - - # test mismatch input length - with self.assertRaises(AssertionError): - processor( - text=["Text 1", "Text 2"], - images=[self.prepare_image_inputs()], - audios=[np.random.rand(16000)] - ) - - # test invalid audio sampling rate - with self.assertRaises(ValueError): - processor( - text="Test text", - audios=np.random.rand(16000), - sampling_rate=0 - ) + + # 创建模拟输出 + class MockOutput: + def __init__(self, sequences): + self.sequences = sequences + + # 测试单个输出 + mock_output = MockOutput(torch.tensor([[1, 2, 3, 4, 0, 0]])) + result = processor.decode(mock_output) + self.assertIsInstance(result, str) + + # 测试批量输出 + mock_output = MockOutput(torch.tensor( + [[1, 2, 3, 4, 0, 0], [5, 6, 7, 8, 0, 0]])) + result = processor.decode(mock_output, batched=True) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 2)