Skip to content

Commit e6c5c09

Browse files
authored
Add support of video MiniCPM-V_4_5 (#2044)
* add video support * Finally fixed accuracy in WWB * some optimisations * apply review comments * skip minicpm_v4_5 for genai at the moment * style fix * fix CI * Fix video temporal ids * Add batched temporal resampling
1 parent 1aac780 commit e6c5c09

4 files changed

Lines changed: 122 additions & 18 deletions

File tree

‎optimum/intel/openvino/modeling_visual_language.py‎

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1296,6 +1296,7 @@ def forward(
12961296
audio_embed_sizes=audio_embed_sizes,
12971297
audio_attention_mask=audio_attention_mask,
12981298
input_mode=input_mode,
1299+
temporal_ids=temporal_ids,
12991300
**kwargs,
13001301
)
13011302

@@ -1648,6 +1649,7 @@ def preprocess_inputs(
16481649
tokenizer: Optional[PreTrainedTokenizer] = None,
16491650
config: Optional[PretrainedConfig] = None,
16501651
video: Optional["VideoInput"] = None,
1652+
video_temporal_ids: Optional[List[List[int]]] = None,
16511653
audio: Optional[np.ndarray] = None,
16521654
):
16531655
if processor is None:
@@ -2572,6 +2574,7 @@ def get_vision_embeddings(self, pixel_values, input_ids=None, **kwargs):
25722574
if input_ids is not None and input_ids.shape[1] == 1:
25732575
return None
25742576
tgt_sizes = kwargs["tgt_sizes"]
2577+
temporal_ids = kwargs.get("temporal_ids")
25752578
pixel_values_list = pixel_values
25762579
vision_hidden_states = []
25772580
all_pixel_values = []
@@ -2607,14 +2610,17 @@ def get_vision_embeddings(self, pixel_values, input_ids=None, **kwargs):
26072610
pixel_values=all_pixel_values, patch_attention_mask=patch_attn_mask, position_ids=position_ids
26082611
)[0]
26092612
)
2610-
vision_embedding = self.resampling(vision_embedding, tgt_sizes)
2613+
temporal_groups = None
2614+
if temporal_ids is not None:
2615+
temporal_groups = [group for sample_groups in temporal_ids for group in sample_groups]
2616+
vision_embedding = self.resampling(vision_embedding, tgt_sizes, temporal_groups)
26112617

26122618
start = 0
2613-
for pixel_value in pixel_values_list:
2614-
img_cnt = len(pixel_value)
2615-
if img_cnt > 0:
2616-
vision_hidden_states.append(vision_embedding[start : start + img_cnt])
2617-
start += img_cnt
2619+
for sample_idx, pixel_value in enumerate(pixel_values_list):
2620+
output_count = len(temporal_ids[sample_idx]) if temporal_ids is not None else len(pixel_value)
2621+
if output_count > 0:
2622+
vision_hidden_states.append(vision_embedding[start : start + output_count])
2623+
start += output_count
26182624
else:
26192625
vision_hidden_states.append([])
26202626
else: # no image
@@ -2623,7 +2629,7 @@ def get_vision_embeddings(self, pixel_values, input_ids=None, **kwargs):
26232629
vision_hidden_states.append(dummy_feature)
26242630
return vision_hidden_states
26252631

2626-
def resampling(self, x, tgt_sizes):
2632+
def resampling(self, x, tgt_sizes, temporal_groups=None):
26272633
bs = x.shape[0]
26282634

26292635
patch_len = tgt_sizes[:, 0] * tgt_sizes[:, 1]
@@ -2642,6 +2648,50 @@ def resampling(self, x, tgt_sizes):
26422648
pos_embed = torch.nn.utils.rnn.pad_sequence(pos_embed, batch_first=True, padding_value=0.0).permute(
26432649
1, 0, 2
26442650
) # BLD => L * B * D
2651+
# A temporal group is represented by one fixed set of resampler query tokens, so concatenate
2652+
# its frame features and positions before inference instead of producing tokens for every frame.
2653+
if temporal_groups is not None:
2654+
group_features = []
2655+
group_positions = []
2656+
group_masks = []
2657+
frame_start = 0
2658+
for group in temporal_groups:
2659+
group_ids = [int(temporal_id) for temporal_id in group]
2660+
if not group_ids:
2661+
raise ValueError("Temporal groups must not be empty")
2662+
frame_end = frame_start + len(group_ids)
2663+
if frame_end > bs:
2664+
raise ValueError("Temporal groups contain more frames than were encoded")
2665+
2666+
group_pos_embed = pos_embed[:, frame_start:frame_end].permute(1, 0, 2)
2667+
temporal_positions = np.asarray([max(temporal_id, 0) for temporal_id in group_ids], dtype=np.float32)
2668+
temporal_embed = self._get_1d_sincos_pos_embed_from_grid_new(
2669+
self.embed_dim, temporal_positions[:, None]
2670+
).squeeze(1)
2671+
temporal_embed[np.asarray(group_ids) < 0] = 0
2672+
group_pos_embed += torch.from_numpy(temporal_embed).to(group_pos_embed.dtype).unsqueeze(1)
2673+
2674+
group_features.append(x[frame_start:frame_end].reshape(-1, x.shape[-1]))
2675+
group_positions.append(group_pos_embed.reshape(-1, self.embed_dim))
2676+
group_masks.append(key_padding_mask[frame_start:frame_end].reshape(-1))
2677+
frame_start = frame_end
2678+
2679+
if frame_start != bs:
2680+
raise ValueError(f"Temporal groups cover {frame_start} frames, but {bs} were encoded")
2681+
2682+
image_feature = torch.nn.utils.rnn.pad_sequence(group_features, batch_first=True, padding_value=0.0)
2683+
pos_embed = torch.nn.utils.rnn.pad_sequence(group_positions, batch_first=True, padding_value=0.0).permute(
2684+
1, 0, 2
2685+
)
2686+
key_padding_mask = torch.nn.utils.rnn.pad_sequence(group_masks, batch_first=True, padding_value=True)
2687+
return torch.from_numpy(
2688+
self.resampler(
2689+
image_feature=image_feature,
2690+
pos_embed=pos_embed,
2691+
key_padding_mask=key_padding_mask,
2692+
)
2693+
)
2694+
26452695
res = torch.from_numpy(self.resampler(image_feature=x, pos_embed=pos_embed, key_padding_mask=key_padding_mask))
26462696
return res
26472697

@@ -2761,14 +2811,57 @@ def preprocess_inputs(
27612811
tokenizer: Optional[PreTrainedTokenizer] = None,
27622812
config: Optional[PretrainedConfig] = None,
27632813
video: Optional["VideoInput"] = None,
2814+
video_temporal_ids: Optional[List[List[int]]] = None,
27642815
audio: Optional[np.ndarray] = None,
27652816
):
27662817
if processor is None:
27672818
raise ValueError("Processor is required.")
2768-
if video is not None:
2769-
raise ValueError("Video input is not supported")
27702819
if audio is not None:
27712820
raise ValueError("Audio input is not supported")
2821+
if video is not None and (getattr(config, "version", 0) or 0) >= 4.5:
2822+
from PIL import Image as PILImage
2823+
2824+
video_frames = [
2825+
frame if isinstance(frame, PILImage.Image) else PILImage.fromarray(np.asarray(frame))
2826+
for frame in video
2827+
]
2828+
if len(video_frames) == 0:
2829+
raise ValueError("Video input must contain at least one frame")
2830+
2831+
image_inputs = [] if image is None else image if isinstance(image, list) else [image]
2832+
visual_inputs = image_inputs + video_frames
2833+
temporal_ids = [[-1] for _ in image_inputs]
2834+
if video_temporal_ids is None:
2835+
temporal_ids.extend(
2836+
list(range(group_start, min(group_start + 6, len(video_frames))))
2837+
for group_start in range(0, len(video_frames), 6)
2838+
)
2839+
else:
2840+
if sum(len(group) for group in video_temporal_ids) != len(video_frames):
2841+
raise ValueError("video_temporal_ids must contain one ID for every video frame")
2842+
temporal_ids.extend(video_temporal_ids)
2843+
messages = [
2844+
{
2845+
"role": "user",
2846+
"content": "(<image>./</image>)" * len(visual_inputs) + "\n" + text,
2847+
}
2848+
]
2849+
chat_template_processor = (
2850+
processor if getattr(processor, "chat_template", None) is not None else processor.tokenizer
2851+
)
2852+
prompt = chat_template_processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
2853+
inputs = processor(
2854+
prompt,
2855+
visual_inputs,
2856+
temporal_ids=temporal_ids,
2857+
max_slice_nums=1,
2858+
use_image_id=False,
2859+
return_tensors="pt",
2860+
)
2861+
inputs.pop("image_sizes", None)
2862+
return inputs
2863+
if video is not None:
2864+
raise ValueError("Video input is not supported")
27722865
if getattr(processor, "chat_template", None) is not None:
27732866
messages = [{"role": "user", "content": text if image is None else "(<image>./</image>)\n" + text}]
27742867
prompt = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)

‎tests/openvino/test_genai.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,7 @@ def test_compare_outputs(self, model_arch):
353353
"llama4",
354354
"maira2",
355355
"minicpmv", # transformers output is empty with tiny model on transformers 4.57
356+
"minicpm_v4_5", # tokenizer argument unsupported by this generic test
356357
"smolvlm",
357358
"videochat_flash_qwen", # GenAI requires video input; image-only not supported
358359
"qwen3_omni_moe",

‎tests/openvino/test_seq2seq.py‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,7 @@ class OVModelForVisualCausalLMIntegrationTest(OVSeq2SeqTestMixin):
589589
"videochat_flash_qwen",
590590
"internvl_chat",
591591
"minicpmv",
592+
"minicpm_v4_5",
592593
"minicpmo",
593594
"llava-qwen2",
594595
"phi3_v",
@@ -620,6 +621,7 @@ class OVModelForVisualCausalLMIntegrationTest(OVSeq2SeqTestMixin):
620621
"qwen3_vl",
621622
"videochat_flash_qwen",
622623
"muse_glimmer",
624+
"minicpm_v4_5",
623625
"gemma4",
624626
"gemma4_moe",
625627
"gemma4_unified",
@@ -756,7 +758,7 @@ def compare_outputs(inputs, ov_model, transformers_model, generation_config):
756758
transformers_inputs["inputs"] = transformers_inputs.pop("input_ids")
757759
ov_outputs = ov_model.generate(**inputs, generation_config=generation_config)
758760
# original minicpmv, internvl always skip input tokens in generation results, while transformers based approach provide them
759-
if model_arch in ["minicpmv", "minicpmo", "internvl_chat", "videochat_flash_qwen"]:
761+
if model_arch in ["minicpmv", "minicpm_v4_5", "minicpmo", "internvl_chat", "videochat_flash_qwen"]:
760762
ov_outputs = ov_outputs[:, inputs["input_ids"].shape[1] :]
761763
with torch.no_grad():
762764
transformers_outputs = transformers_model.generate(
@@ -846,7 +848,7 @@ def compare_outputs(inputs, ov_model, transformers_model, generation_config):
846848
self._check_device_and_request(ov_model, test_device, False)
847849

848850
# pytorch minicpmv and internvl_chat are not designed to be used via forward
849-
if model_arch not in ["minicpmv", "minicpmo", "internvl_chat", "videochat_flash_qwen"]:
851+
if model_arch not in ["minicpmv", "minicpm_v4_5", "minicpmo", "internvl_chat", "videochat_flash_qwen"]:
850852
set_seed(SEED)
851853
ov_outputs = ov_model(**inputs)
852854
set_seed(SEED)
@@ -883,8 +885,8 @@ def compare_outputs(inputs, ov_model, transformers_model, generation_config):
883885
transformers_inputs["past_key_values"] = DynamicCache()
884886

885887
with torch.no_grad():
886-
if model_arch in ["minicpmo"]:
887-
# `generate` method for minicpmo requires tokenizer
888+
if model_arch in ["minicpmo", "minicpm_v4_5"]:
889+
# MiniCPM's custom generate method requires the tokenizer to resolve terminator IDs.
888890
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=model_arch in REMOTE_CODE_MODELS)
889891
additional_inputs["tokenizer"] = tokenizer
890892
transformers_outputs = transformers_model.generate(
@@ -895,7 +897,7 @@ def compare_outputs(inputs, ov_model, transformers_model, generation_config):
895897
transformers_outputs = transformers_outputs[1].sequences
896898

897899
# original minicpmv, internvl always skip input tokens in generation results, while transformers based approach provide them
898-
if model_arch in ["minicpmv", "minicpmo", "internvl_chat", "videochat_flash_qwen"]:
900+
if model_arch in ["minicpmv", "minicpm_v4_5", "minicpmo", "internvl_chat", "videochat_flash_qwen"]:
899901
ov_outputs = ov_outputs[:, inputs["input_ids"].shape[1] :]
900902
self.assertTrue(
901903
torch.equal(ov_outputs, transformers_outputs),
@@ -915,11 +917,14 @@ def compare_outputs(inputs, ov_model, transformers_model, generation_config):
915917
num_frames = 32
916918
input_video, _ = load_video(video_path, num_frames=num_frames, backend="opencv")
917919
question = "Why is this video funny?"
918-
inputs = ov_model.preprocess_inputs(**preprocessors, text=question, video=input_video)
920+
video_kwargs = {"video_temporal_ids": [[0, 100]]} if model_arch == "minicpm_v4_5" else {}
921+
inputs = ov_model.preprocess_inputs(**preprocessors, text=question, video=input_video, **video_kwargs)
919922
compare_outputs(inputs, ov_model, transformers_model, gen_config)
920923

921924
# check video+image scenario
922-
inputs = ov_model.preprocess_inputs(**preprocessors, text=question, video=input_video, image=image)
925+
inputs = ov_model.preprocess_inputs(
926+
**preprocessors, text=question, video=input_video, image=image, **video_kwargs
927+
)
923928
compare_outputs(inputs, ov_model, transformers_model, gen_config)
924929

925930
if model_arch in self.SUPPORT_AUDIO:
@@ -983,9 +988,13 @@ def test_minicpmv_resampler_padding_mask(self, model_arch):
983988
tgt_sizes = torch.tensor([[8, 8], [6, 5], [4, 4]], dtype=torch.int32)
984989
set_seed(SEED)
985990
image_features = torch.randn(3, 64, transformers_model.config.vision_config.hidden_size)
991+
temporal_groups = [[0, 1], [100]] if model_arch == "minicpm_v4_5" else None
986992
with torch.no_grad():
987-
transformers_outputs = transformers_model.resampler(image_features, tgt_sizes)
988-
ov_outputs = ov_model.resampling(image_features, tgt_sizes)
993+
if temporal_groups is None:
994+
transformers_outputs = transformers_model.resampler(image_features, tgt_sizes)
995+
else:
996+
transformers_outputs = transformers_model.resampler(image_features, tgt_sizes, temporal_groups)
997+
ov_outputs = ov_model.resampling(image_features, tgt_sizes, temporal_groups)
989998
max_diff = torch.abs(ov_outputs - transformers_outputs).max()
990999
self.assertLess(max_diff, 1e-4, f"Max abs diff {max_diff}")
9911000

‎tests/openvino/utils_tests.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,6 +1140,7 @@ def get_num_sdpa(model):
11401140
"ltx2": "ltx2-video-transformer",
11411141
"ltx2.3": "ltx2-video-transformer",
11421142
"mistral-nemo": "mistral",
1143+
"minicpm_v4_5": "minicpmv",
11431144
"mixtral_awq": "mixtral",
11441145
"nanollava_vision_tower": "siglip",
11451146
"opt125m": "opt",

0 commit comments

Comments
 (0)