Skip to content

Commit 7a5a856

Browse files
committed
Add Gemma3 support for videos
1 parent a5ba392 commit 7a5a856

4 files changed

Lines changed: 135 additions & 7 deletions

File tree

llmlib/llmlib/huggingface_inference.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import math
12
import os
23
import base64
34
import io
@@ -7,13 +8,19 @@
78
import PIL
89
from enum import StrEnum
910
from .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

1219
def 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+
3796
def 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+
42106
def 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

test-files/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
*.mp3
44
*.mp4
55
*.flac
6+
generated_*

tests/helpers.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import base64
12
import os
23
from pathlib import Path
34
import PIL
5+
import cv2
46
from llmlib.base_llm import LLM, Message
7+
import numpy as np
58
import pytest
69

710

@@ -75,8 +78,10 @@ def mona_lisa_message() -> Message:
7578
return msg
7679

7780

78-
def pyramid_message() -> Message:
81+
def pyramid_message(load_img: bool = False) -> Message:
7982
img = file_for_test("pyramid.jpg")
83+
if load_img:
84+
img = PIL.Image.open(img)
8085
msg = Message(role="user", msg="What is in the image?", img=img, img_name="")
8186
return msg
8287

@@ -131,8 +136,7 @@ def assert_model_supports_multiturn(model: LLM):
131136

132137

133138
def assert_model_supports_multiturn_with_6min_video(model: LLM):
134-
video = file_for_test("tasting travel - rome italy.mp4")
135-
convo = [Message(role="user", msg="What country are they visiting?", video=video)]
139+
convo = [video_message()]
136140
answer1 = model.complete_msgs(convo)
137141
assert "italy" in answer1.lower(), answer1
138142

@@ -149,6 +153,11 @@ def assert_model_supports_multiturn_with_6min_video(model: LLM):
149153
assert "jesus" in answer3.lower(), answer3
150154

151155

156+
def video_message() -> Message:
157+
video = file_for_test("tasting travel - rome italy.mp4")
158+
return Message(role="user", msg="What country are they visiting?", video=video)
159+
160+
152161
def assert_model_supports_multiturn_with_multiple_imgs(model: LLM):
153162
files = [file_for_test("forest.jpg"), file_for_test("fish.jpg")]
154163
msg = Message(
@@ -164,3 +173,14 @@ def assert_model_supports_multiturn_with_multiple_imgs(model: LLM):
164173
answer2 = model.complete_msgs(convo).lower()
165174
possible_answers = ["biodiversity", "ecosystem", "habitat"]
166175
assert any(answer in answer2 for answer in possible_answers), answer2
176+
177+
178+
def decode_base64_to_array(base64_str: str) -> np.ndarray:
179+
"""Decode base64 string to OpenCV image (numpy array)"""
180+
# Remove data URL prefix if present
181+
if "base64," in base64_str:
182+
base64_str = base64_str.split("base64,")[1]
183+
image_data = base64.b64decode(base64_str)
184+
np_array = np.frombuffer(image_data, np.uint8)
185+
image = cv2.imdecode(np_array, cv2.IMREAD_COLOR)
186+
return image

tests/test_huggingface_vlm.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1+
import cv2
2+
from llmlib.huggingface_inference import convert_message_to_hf_format
13
import pytest
24
from llmlib.huggingface_inference import HuggingFaceVLM, HuggingFaceVLMs
35
from .helpers import (
46
assert_model_recognizes_pyramid_in_image,
7+
assert_model_supports_multiturn_with_6min_video,
8+
decode_base64_to_array,
9+
file_for_test,
510
is_ci,
611
assert_model_knows_capital_of_france,
712
assert_model_supports_multiturn,
813
assert_model_supports_multiturn_with_multiple_imgs,
14+
pyramid_message,
15+
video_message,
916
)
1017

1118

@@ -45,3 +52,31 @@ def test_huggingface_vlm_multi_turn_text_conversation(gemma3):
4552
@pytest.mark.skipif(condition=is_ci(), reason="Avoid costs")
4653
def test_huggingface_vlm_multi_turn_with_images(gemma3):
4754
assert_model_supports_multiturn_with_multiple_imgs(gemma3)
55+
56+
57+
@pytest.mark.skipif(condition=is_ci(), reason="Files are not available on CI")
58+
def test_huggingface_vlm_multi_turn_with_6min_video(gemma3):
59+
assert_model_supports_multiturn_with_6min_video(gemma3)
60+
61+
62+
@pytest.mark.skipif(condition=is_ci(), reason="Files are not available on CI")
63+
def test_convert_to_huggingface_format():
64+
img_msg1 = pyramid_message(load_img=True)
65+
img_msg2 = pyramid_message(load_img=False)
66+
max_n_frames_per_video = 200
67+
b64_enc1 = convert_message_to_hf_format(img_msg1, max_n_frames_per_video)[
68+
"content"
69+
][1]["image_url"]["url"]
70+
b64_enc2 = convert_message_to_hf_format(img_msg2, max_n_frames_per_video)[
71+
"content"
72+
][1]["image_url"]["url"]
73+
# assert b64_enc1 == b64_enc2
74+
array1 = decode_base64_to_array(base64_str=b64_enc1)
75+
array2 = decode_base64_to_array(base64_str=b64_enc2)
76+
# asserting imgs are the same fails, but you can visually inspect them
77+
cv2.imwrite(file_for_test("generated_pyramid_1.jpeg"), array1)
78+
cv2.imwrite(file_for_test("generated_pyramid_2.jpeg"), array2)
79+
80+
msg = video_message()
81+
hf_msg = convert_message_to_hf_format(msg, max_n_frames_per_video)
82+
assert len(hf_msg["content"]) > 10

0 commit comments

Comments
 (0)