1+ import math
12import os
23import base64
34import io
78import PIL
89from enum import StrEnum
910from .base_llm import LLM , Message , validate_only_first_message_has_files
11+ import cv2
12+ from PIL import Image
13+ from logging import getLogger
14+
15+
16+ logger = getLogger (__name__ )
1017
1118
1219def get_image_as_base64 (image_bytes : bytes ):
1320 return base64 .b64encode (image_bytes ).decode ("utf-8" )
1421
1522
16- def convert_message_to_hf_format (message : Message ) -> dict :
23+ def convert_message_to_hf_format (message : Message , max_n_frames_per_video : int ) -> dict :
1724 """Convert a Message to HuggingFace chat format."""
1825 content = []
1926
@@ -25,20 +32,77 @@ def convert_message_to_hf_format(message: Message) -> dict:
2532 if message .img is not None :
2633 content .append (extract_content_piece (message .img ))
2734
28- # Add multiple images from files if present
35+ if message .video is not None :
36+ imgs : list = video_to_imgs (message .video , max_n_frames_per_video )
37+ for frame in imgs :
38+ content .append (extract_content_piece (frame ))
39+
40+ # Add multiple images from files (img or video) if present
2941 if message .files is not None :
3042 for file_path in message .files :
3143 if is_img (file_path ):
3244 content .append (extract_content_piece (file_path ))
45+ elif is_video (file_path ):
46+ imgs : list = video_to_imgs (file_path , max_n_frames_per_video )
47+ for frame in imgs :
48+ content .append (extract_content_piece (frame ))
49+ else :
50+ raise ValueError (f"Unsupported file type: { file_path } " )
3351
3452 return {"role" : message .role , "content" : content }
3553
3654
55+ def video_to_imgs (video_path : Path , max_n_frames : int ) -> list [PIL .Image .Image ]:
56+ assert isinstance (video_path , Path ), video_path
57+ """From https://github.com/agustoslu/simple-inference-benchmark/blob/5cec55787d34af65f0d11efc429c3d4de92f051a/utils.py#L79"""
58+ cap = cv2 .VideoCapture (str (video_path ))
59+ total_frames = int (cap .get (cv2 .CAP_PROP_FRAME_COUNT ))
60+ fps = int (cap .get (cv2 .CAP_PROP_FPS ))
61+
62+ frame_indices = compute_frame_indices (
63+ vid_n_frames = total_frames , vid_fps = fps , max_n_frames = max_n_frames
64+ )
65+
66+ frames = []
67+ for frame_idx in frame_indices :
68+ cap .set (cv2 .CAP_PROP_POS_FRAMES , frame_idx )
69+ success , frame = cap .read ()
70+ if success :
71+ # Convert BGR (the default format for OpenCV) to RGB
72+ frame_rgb = cv2 .cvtColor (frame , cv2 .COLOR_BGR2RGB )
73+ frames .append (Image .fromarray (frame_rgb ))
74+
75+ cap .release ()
76+ logger .info (f"Extracted { len (frames )} frames from video { video_path } " )
77+ return frames
78+
79+
80+ def compute_frame_indices (vid_n_frames : int , vid_fps : float , max_n_frames : int ):
81+ """
82+ From https://github.com/agustoslu/simple-inference-benchmark/blob/5cec55787d34af65f0d11efc429c3d4de92f051a/utils.py#L164
83+ This function will return the frames starting at 0 every second.
84+ Unless that number exceeds max_n_frames, in which case it will return max_n_frames frames evenly spaced out, starting at 0.
85+ """
86+ assert isinstance (vid_n_frames , int ), vid_n_frames
87+ assert isinstance (max_n_frames , int ), max_n_frames
88+ vid_fps = int (vid_fps )
89+ fps_n_frames = math .ceil (vid_n_frames / vid_fps )
90+ if fps_n_frames <= max_n_frames :
91+ return list (range (0 , vid_n_frames - 1 , vid_fps ))
92+ else :
93+ return list (range (0 , vid_n_frames - 1 , vid_n_frames // max_n_frames ))
94+
95+
3796def is_img (file_path : str | Path ) -> bool :
3897 permitted = (".png" , ".jpg" , ".jpeg" )
3998 return str (file_path ).lower ().endswith (permitted )
4099
41100
101+ def is_video (file_path : str | Path ) -> bool :
102+ permitted = (".mp4" ,)
103+ return str (file_path ).lower ().endswith (permitted )
104+
105+
42106def extract_content_piece (img : PIL .Image .Image | str | Path ) -> dict :
43107 image_bytes = extract_bytes (img )
44108 content_piece = {
@@ -58,7 +122,7 @@ def extract_bytes(img: PIL.Image.Image | str | Path) -> bytes:
58122 elif isinstance (img , PIL .Image .Image ):
59123 # Handle PIL Image
60124 img_byte_arr = io .BytesIO ()
61- img .save (img_byte_arr , format = img . format or "PNG " )
125+ img .save (img_byte_arr , format = "jpeg " )
62126 return img_byte_arr .getvalue ()
63127 else :
64128 raise ValueError (f"Unsupported image type: { type (img )} " )
@@ -75,6 +139,7 @@ class HuggingFaceVLM(LLM):
75139 model_id : HuggingFaceVLMs
76140 max_new_tokens : int = 1000
77141 requires_gpu_exclusively : bool = False
142+ max_n_frames_per_video : int = 200
78143
79144 # Available model IDs
80145 model_ids = list (HuggingFaceVLMs )
@@ -92,13 +157,20 @@ def __post_init__(self):
92157 def complete_msgs (self , msgs : list [Message ]) -> str :
93158 """Complete a conversation with the model."""
94159 validate_only_first_message_has_files (msgs )
95- hf_messages = [convert_message_to_hf_format (msg ) for msg in msgs ]
160+ hf_messages = [
161+ convert_message_to_hf_format (
162+ msg , max_n_frames_per_video = self .max_n_frames_per_video
163+ )
164+ for msg in msgs
165+ ]
96166
167+ logger .info ("Calling HuggingFace API..." )
97168 completion = self .client .chat .completions .create (
98169 model = self .model_id ,
99170 messages = hf_messages ,
100171 max_tokens = self .max_new_tokens ,
101172 )
173+ logger .info ("Token usage: %s" , dict (completion .usage ))
102174
103175 return completion .choices [0 ].message .content
104176
0 commit comments