522 lines
20 KiB
Python
522 lines
20 KiB
Python
# Copyright (c) ModelScope Contributors. All rights reserved.
|
|
import base64
|
|
import math
|
|
import numpy as np
|
|
import os
|
|
import re
|
|
import requests
|
|
import torch
|
|
from io import BytesIO
|
|
from PIL import Image
|
|
from requests.adapters import HTTPAdapter
|
|
from typing import Any, Callable, List, TypeVar, Union
|
|
from urllib3.util.retry import Retry
|
|
|
|
from swift.utils import get_env_args
|
|
|
|
# >>> internvl
|
|
IMAGENET_MEAN = (0.485, 0.456, 0.406)
|
|
IMAGENET_STD = (0.229, 0.224, 0.225)
|
|
|
|
|
|
def _build_transform(input_size):
|
|
import torchvision.transforms as T
|
|
from torchvision.transforms.functional import InterpolationMode
|
|
MEAN, STD = IMAGENET_MEAN, IMAGENET_STD
|
|
transform = T.Compose([
|
|
T.Lambda(lambda img: img.convert('RGB') if img.mode != 'RGB' else img),
|
|
T.Resize((input_size, input_size), interpolation=InterpolationMode.BICUBIC),
|
|
T.ToTensor(),
|
|
T.Normalize(mean=MEAN, std=STD)
|
|
])
|
|
return transform
|
|
|
|
|
|
def _find_closest_aspect_ratio(aspect_ratio, target_ratios, width, height, image_size):
|
|
best_ratio_diff = float('inf')
|
|
best_ratio = (1, 1)
|
|
area = width * height
|
|
for ratio in target_ratios:
|
|
target_aspect_ratio = ratio[0] / ratio[1]
|
|
ratio_diff = abs(aspect_ratio - target_aspect_ratio)
|
|
if ratio_diff < best_ratio_diff:
|
|
best_ratio_diff = ratio_diff
|
|
best_ratio = ratio
|
|
elif ratio_diff == best_ratio_diff:
|
|
if area > 0.5 * image_size * image_size * ratio[0] * ratio[1]:
|
|
best_ratio = ratio
|
|
return best_ratio
|
|
|
|
|
|
def _dynamic_preprocess(image, min_num=1, max_num=12, image_size=448, use_thumbnail=False):
|
|
orig_width, orig_height = image.size
|
|
aspect_ratio = orig_width / orig_height
|
|
|
|
# calculate the existing image aspect ratio
|
|
target_ratios = set((i, j) for n in range(min_num, max_num + 1) for i in range(1, n + 1) for j in range(1, n + 1)
|
|
if min_num <= i * j <= max_num)
|
|
target_ratios = sorted(target_ratios, key=lambda x: x[0] * x[1])
|
|
|
|
# find the closest aspect ratio to the target
|
|
target_aspect_ratio = _find_closest_aspect_ratio(aspect_ratio, target_ratios, orig_width, orig_height, image_size)
|
|
|
|
# calculate the target width and height
|
|
target_width = image_size * target_aspect_ratio[0]
|
|
target_height = image_size * target_aspect_ratio[1]
|
|
blocks = target_aspect_ratio[0] * target_aspect_ratio[1]
|
|
|
|
# resize the image
|
|
resized_img = image.resize((target_width, target_height))
|
|
processed_images = []
|
|
for i in range(blocks):
|
|
box = ((i % (target_width // image_size)) * image_size, (i // (target_width // image_size)) * image_size,
|
|
((i % (target_width // image_size)) + 1) * image_size, ((i //
|
|
(target_width // image_size)) + 1) * image_size)
|
|
# split the image
|
|
split_img = resized_img.crop(box)
|
|
processed_images.append(split_img)
|
|
assert len(processed_images) == blocks
|
|
if use_thumbnail and len(processed_images) != 1:
|
|
thumbnail_img = image.resize((image_size, image_size))
|
|
processed_images.append(thumbnail_img)
|
|
return processed_images
|
|
|
|
|
|
# <<< internvl
|
|
|
|
|
|
def rescale_image(img: Image.Image, max_pixels: int) -> Image.Image:
|
|
import torchvision.transforms as T
|
|
width = img.width
|
|
height = img.height
|
|
if max_pixels is None or max_pixels <= 0 or width * height <= max_pixels:
|
|
return img
|
|
|
|
ratio = width / height
|
|
height_scaled = math.sqrt(max_pixels / ratio)
|
|
width_scaled = height_scaled * ratio
|
|
return T.Resize((int(height_scaled), int(width_scaled)))(img)
|
|
|
|
|
|
_T = TypeVar('_T')
|
|
|
|
|
|
def _check_path(path: str) -> Union[str, None]:
|
|
"""If it is a path, return the string; if it is base64, return None."""
|
|
MAX_PATH_HEURISTIC = 2000
|
|
if len(path) > MAX_PATH_HEURISTIC:
|
|
return
|
|
if os.path.exists(path):
|
|
return os.path.abspath(path)
|
|
data = path
|
|
ROOT_IMAGE_DIR = get_env_args('ROOT_IMAGE_DIR', str, None)
|
|
if ROOT_IMAGE_DIR is not None:
|
|
path = os.path.join(ROOT_IMAGE_DIR, path)
|
|
path = os.path.abspath(os.path.expanduser(path))
|
|
if os.path.exists(path):
|
|
return path
|
|
if data.startswith('data:'):
|
|
return
|
|
try:
|
|
base64.b64decode(data)
|
|
return
|
|
except Exception:
|
|
pass
|
|
return data
|
|
|
|
|
|
def load_file(path: Union[str, bytes, _T]) -> Union[BytesIO, _T]:
|
|
res = path
|
|
if isinstance(path, str):
|
|
path = path.strip()
|
|
if path.startswith('http'):
|
|
retries = Retry(total=3, backoff_factor=1, allowed_methods=['GET'])
|
|
with requests.Session() as session:
|
|
session.mount('http://', HTTPAdapter(max_retries=retries))
|
|
session.mount('https://', HTTPAdapter(max_retries=retries))
|
|
|
|
timeout = float(os.getenv('SWIFT_TIMEOUT', '20'))
|
|
request_kwargs = {'timeout': timeout} if timeout > 0 else {}
|
|
|
|
response = session.get(path, **request_kwargs)
|
|
response.raise_for_status()
|
|
content = response.content
|
|
res = BytesIO(content)
|
|
else:
|
|
data = path
|
|
path = _check_path(path)
|
|
if path is None:
|
|
# base64_str
|
|
if data.startswith('data:'):
|
|
match_ = re.match(r'data:(.+?);base64,(.+)', data)
|
|
assert match_ is not None
|
|
data = match_.group(2)
|
|
data = base64.b64decode(data)
|
|
res = BytesIO(data)
|
|
else:
|
|
with open(path, 'rb') as f:
|
|
res = BytesIO(f.read())
|
|
elif isinstance(path, bytes):
|
|
res = BytesIO(path)
|
|
return res
|
|
|
|
|
|
def load_image(image: Union[str, bytes, Image.Image]) -> Image.Image:
|
|
image = load_file(image)
|
|
if isinstance(image, BytesIO):
|
|
image = Image.open(image)
|
|
if image.mode != 'RGB':
|
|
image = image.convert('RGB')
|
|
return image
|
|
|
|
|
|
def load_batch(path_list: List[Union[str, None, Any, BytesIO]],
|
|
load_func: Callable[[Any], _T] = load_image) -> List[_T]:
|
|
res = []
|
|
assert isinstance(path_list, (list, tuple)), f'path_list: {path_list}'
|
|
for path in path_list:
|
|
if path is None: # ignore None
|
|
continue
|
|
res.append(load_func(path))
|
|
return res
|
|
|
|
|
|
def load_video_hf(videos: List[str]):
|
|
from transformers.video_utils import load_video
|
|
res = []
|
|
video_metadata = []
|
|
for video in videos:
|
|
if isinstance(video, (list, tuple)) and isinstance(video[0], str):
|
|
# Case a: Video is provided as a list of image file names
|
|
video = [np.array(load_image(image_fname)) for image_fname in video]
|
|
video = np.stack(video)
|
|
metadata = None
|
|
else:
|
|
# Case b: Video is provided as a single file path or URL or decoded frames in a np.ndarray or torch.tensor
|
|
video_load_backend = get_env_args('video_load_backend', str, 'pyav')
|
|
video, metadata = load_video(
|
|
video,
|
|
backend=video_load_backend,
|
|
)
|
|
res.append(video)
|
|
video_metadata.append(metadata)
|
|
return res, video_metadata
|
|
|
|
|
|
def _get_index(bound, fps, max_frame, first_idx=0, num_segments=32):
|
|
if bound:
|
|
start, end = bound[0], bound[1]
|
|
else:
|
|
start, end = -100000, 100000
|
|
start_idx = max(first_idx, round(start * fps))
|
|
end_idx = min(round(end * fps), max_frame)
|
|
seg_size = float(end_idx - start_idx) / num_segments
|
|
frame_indices = np.array(
|
|
[int(start_idx + (seg_size / 2) + np.round(seg_size * idx)) for idx in range(num_segments)])
|
|
return frame_indices
|
|
|
|
|
|
def transform_image(image, input_size=448, max_num=12):
|
|
transform = _build_transform(input_size=input_size)
|
|
images = _dynamic_preprocess(image, image_size=input_size, use_thumbnail=True, max_num=max_num)
|
|
pixel_values = [transform(image) for image in images]
|
|
pixel_values = torch.stack(pixel_values)
|
|
return pixel_values
|
|
|
|
|
|
def load_video_internvl(video: Union[str, bytes], bound=None, num_segments=32):
|
|
from decord import VideoReader, cpu
|
|
video_io = load_file(video)
|
|
vr = VideoReader(video_io, ctx=cpu(0), num_threads=1)
|
|
max_frame = len(vr) - 1
|
|
fps = float(vr.get_avg_fps())
|
|
|
|
images = []
|
|
frame_indices = _get_index(bound, fps, max_frame, first_idx=0, num_segments=num_segments)
|
|
for frame_index in frame_indices:
|
|
images.append(Image.fromarray(vr[frame_index].asnumpy()).convert('RGB'))
|
|
return images
|
|
|
|
|
|
def load_video_cogvlm2(video: Union[str, bytes]) -> np.ndarray:
|
|
from decord import VideoReader, bridge, cpu
|
|
video_io = load_file(video)
|
|
bridge.set_bridge('torch')
|
|
clip_end_sec = 60
|
|
clip_start_sec = 0
|
|
num_frames = get_env_args('num_frames', int, 24)
|
|
decord_vr = VideoReader(video_io, ctx=cpu(0))
|
|
duration = len(decord_vr) # duration in terms of frames
|
|
start_frame = int(clip_start_sec * decord_vr.get_avg_fps())
|
|
end_frame = min(duration, int(clip_end_sec * decord_vr.get_avg_fps())) if \
|
|
clip_end_sec is not None else duration
|
|
frame_id_list = np.linspace(start_frame, end_frame - 1, num_frames, dtype=int)
|
|
video_data = decord_vr.get_batch(frame_id_list)
|
|
video_data = video_data.permute(3, 0, 1, 2)
|
|
return video_data
|
|
|
|
|
|
def load_video_llava(video: Union[str, bytes]) -> np.ndarray:
|
|
import av
|
|
video_io = load_file(video)
|
|
container = av.open(video_io)
|
|
total_frames = container.streams.video[0].frames
|
|
num_frames = get_env_args('num_frames', int, 16)
|
|
indices = np.linspace(0, total_frames - 1, num_frames, dtype=int)
|
|
frames = []
|
|
container.seek(0)
|
|
start_index = indices[0]
|
|
end_index = indices[-1]
|
|
for i, frame in enumerate(container.decode(video=0)):
|
|
if i > end_index:
|
|
break
|
|
if i >= start_index and i in indices:
|
|
frames.append(frame)
|
|
return np.stack([x.to_ndarray(format='rgb24') for x in frames])
|
|
|
|
|
|
def load_video_minicpmv_mplug_owl3(video: Union[str, bytes], max_num_frames):
|
|
|
|
from decord import VideoReader, cpu # pip install decord
|
|
|
|
def uniform_sample(_l, _n):
|
|
gap = len(_l) / _n
|
|
idxs = [int(i * gap + gap / 2) for i in range(_n)]
|
|
return [_l[i] for i in idxs]
|
|
|
|
video_io = load_file(video)
|
|
vr = VideoReader(video_io, ctx=cpu(0))
|
|
sample_fps = round(vr.get_avg_fps() / 1) # FPS
|
|
frame_idx = [i for i in range(0, len(vr), sample_fps)]
|
|
|
|
if len(frame_idx) > max_num_frames:
|
|
frame_idx = uniform_sample(frame_idx, max_num_frames)
|
|
frames = vr.get_batch(frame_idx).asnumpy()
|
|
frames = [Image.fromarray(v.astype('uint8')) for v in frames]
|
|
return frames
|
|
|
|
|
|
def _load_audio_librosa(audio: Union[str, bytes], sampling_rate: int, mono: bool = True):
|
|
import librosa
|
|
try:
|
|
audio_io = load_file(audio)
|
|
return librosa.load(audio_io, sr=sampling_rate, mono=mono)
|
|
except Exception:
|
|
if isinstance(audio, str) and audio.startswith(('http://', 'https://')):
|
|
import audioread
|
|
audio_io = audioread.ffdec.FFmpegAudioFile(audio)
|
|
else:
|
|
audio_io = _check_path(audio) if isinstance(audio, str) else audio
|
|
return librosa.load(audio_io, sr=sampling_rate, mono=mono)
|
|
|
|
|
|
# ref: https://github.com/vllm-project/vllm/blob/v0.23.0/vllm/multimodal/audio.py#L169-L224
|
|
def _resample_audio_pyav(audio: np.ndarray, *, orig_sr: float, target_sr: float) -> np.ndarray:
|
|
import av
|
|
orig_sr_int = int(round(orig_sr))
|
|
target_sr_int = int(round(target_sr))
|
|
if orig_sr_int == target_sr_int:
|
|
return audio
|
|
if audio.ndim == 2:
|
|
return np.stack([_resample_audio_pyav(ch, orig_sr=orig_sr, target_sr=target_sr) for ch in audio], axis=0)
|
|
expected_len = int(math.ceil(audio.shape[-1] * target_sr_int / orig_sr_int))
|
|
min_samples = 1024
|
|
audio_f32 = np.asarray(audio, dtype=np.float32)
|
|
if len(audio_f32) < min_samples:
|
|
audio_f32 = np.pad(audio_f32, (0, min_samples - len(audio_f32)))
|
|
audio_f32 = audio_f32.reshape(1, -1)
|
|
resampler = av.AudioResampler(format='fltp', layout='mono', rate=target_sr_int)
|
|
frame = av.AudioFrame.from_ndarray(audio_f32, format='fltp', layout='mono')
|
|
frame.sample_rate = orig_sr_int
|
|
out_frames = resampler.resample(frame)
|
|
out_frames.extend(resampler.resample(None))
|
|
result = np.concatenate([f.to_ndarray() for f in out_frames], axis=1).squeeze(0)
|
|
return result[:expected_len]
|
|
|
|
|
|
# ref: https://github.com/vllm-project/vllm/blob/v0.23.0/vllm/multimodal/media/audio.py#L45-L160
|
|
def _load_audio_soundfile_pyav(path: Union[str, bytes, BytesIO], *, sr: float, mono: bool = True):
|
|
"""soundfile first, pyav fallback — same strategy as vLLM multimodal audio loader."""
|
|
bad_sf_codes = {0, 1, 3, 4}
|
|
if not isinstance(path, BytesIO):
|
|
path = load_file(path)
|
|
|
|
def _load_soundfile():
|
|
import soundfile
|
|
with soundfile.SoundFile(path) as f:
|
|
native_sr = f.samplerate
|
|
y = f.read(dtype='float32', always_2d=False).T
|
|
|
|
if mono and y.ndim > 1:
|
|
y = np.mean(y, axis=tuple(range(y.ndim - 1)))
|
|
|
|
if sr is not None and sr != native_sr:
|
|
y = _resample_audio_pyav(y, orig_sr=native_sr, target_sr=sr)
|
|
return y, int(sr)
|
|
return y, native_sr
|
|
|
|
def _load_pyav():
|
|
import av
|
|
path.seek(0)
|
|
with av.open(path) as container:
|
|
if not container.streams.audio:
|
|
raise ValueError('No audio stream found.')
|
|
stream = container.streams.audio[0]
|
|
stream.thread_type = 'AUTO'
|
|
native_sr = stream.rate
|
|
target_sr = sr or native_sr
|
|
chunks = []
|
|
needs_resampling = not math.isclose(float(target_sr), float(native_sr), rel_tol=0.0, abs_tol=1e-6)
|
|
resampler = av.AudioResampler(format='fltp', layout='mono', rate=target_sr) if needs_resampling else None
|
|
for frame in container.decode(stream):
|
|
if needs_resampling:
|
|
for out_frame in resampler.resample(frame):
|
|
chunks.append(out_frame.to_ndarray())
|
|
else:
|
|
chunks.append(frame.to_ndarray())
|
|
if not chunks:
|
|
raise ValueError('No audio found.')
|
|
y = np.concatenate(chunks, axis=-1).astype(np.float32)
|
|
if mono and y.ndim > 1:
|
|
y = np.mean(y, axis=0)
|
|
return y, target_sr
|
|
|
|
try:
|
|
return _load_soundfile()
|
|
except ImportError:
|
|
path.seek(0)
|
|
return _load_pyav()
|
|
except Exception as exc:
|
|
import soundfile
|
|
if not isinstance(exc, soundfile.LibsndfileError) or exc.code not in bad_sf_codes:
|
|
raise
|
|
path.seek(0)
|
|
return _load_pyav()
|
|
|
|
|
|
def load_audio(
|
|
audio: Union[str, bytes, BytesIO],
|
|
sampling_rate: int,
|
|
return_sr: bool = False,
|
|
mono: bool = True,
|
|
):
|
|
backend = get_env_args('swift_audio_load_backend', str, 'librosa')
|
|
if backend == 'librosa':
|
|
res = _load_audio_librosa(audio, sampling_rate, mono=mono)
|
|
elif backend == 'soundfile_pyav':
|
|
res = _load_audio_soundfile_pyav(audio, sr=sampling_rate, mono=mono)
|
|
else:
|
|
raise ValueError(f'Unknown audio load backend {backend!r}. Supported: librosa, soundfile_pyav')
|
|
return res if return_sr else res[0]
|
|
|
|
|
|
def _resolve_video_local_path(path: Union[str, bytes]) -> tuple:
|
|
"""Return (local_path, is_temp_file). HTTP URLs and raw bytes are written to a temp file."""
|
|
if isinstance(path, bytes) or (isinstance(path, str) and path.startswith('http')):
|
|
import tempfile
|
|
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as f:
|
|
f.write(load_file(path).read())
|
|
return f.name, True
|
|
checked = _check_path(path) if isinstance(path, str) else None
|
|
return checked or path, False
|
|
|
|
|
|
def _video_to_ndarrays_local(local_path: str, num_frames: int = -1) -> np.ndarray:
|
|
import cv2
|
|
cap = cv2.VideoCapture(local_path)
|
|
if not cap.isOpened():
|
|
raise ValueError(f'Could not open video file {local_path}')
|
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
if total_frames <= 0:
|
|
cap.release()
|
|
raise ValueError(f'Video file {local_path} has invalid or zero frame count: {total_frames}')
|
|
if num_frames <= 0 or num_frames > total_frames:
|
|
num_frames = total_frames
|
|
frame_indices = set(np.linspace(0, total_frames - 1, num_frames, dtype=int))
|
|
frames = []
|
|
for idx in range(total_frames):
|
|
if not cap.grab():
|
|
break
|
|
if idx in frame_indices:
|
|
ret, frame = cap.retrieve()
|
|
if ret:
|
|
frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
|
|
cap.release()
|
|
if len(frames) < num_frames:
|
|
raise ValueError(f'Could not read enough frames from video file {local_path} '
|
|
f'(expected {num_frames} frames, got {len(frames)})')
|
|
return np.stack(frames)
|
|
|
|
|
|
def _video_get_metadata_local(local_path: str, num_frames: int = -1) -> dict:
|
|
import cv2
|
|
cap = cv2.VideoCapture(local_path)
|
|
if not cap.isOpened():
|
|
raise ValueError(f'Could not open video file {local_path}')
|
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
if total_frames <= 0:
|
|
cap.release()
|
|
raise ValueError(f'Video file {local_path} has invalid or zero frame count: {total_frames}')
|
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
|
duration = total_frames / fps if fps > 0 else 0
|
|
cap.release()
|
|
if num_frames <= 0 or num_frames > total_frames:
|
|
num_frames = total_frames
|
|
return {
|
|
'total_num_frames': num_frames,
|
|
'fps': duration / num_frames if num_frames else fps,
|
|
'duration': duration,
|
|
'video_backend': 'opencv',
|
|
'frames_indices': list(range(num_frames)),
|
|
'do_sample_frames': num_frames == total_frames,
|
|
}
|
|
|
|
|
|
def load_vllm_video(path: Union[str, bytes], num_frames: int = -1) -> tuple:
|
|
"""Decode video frames + metadata for vLLM rollout; one download, temp file cleaned up."""
|
|
local_path, is_temp = _resolve_video_local_path(path)
|
|
try:
|
|
return _video_to_ndarrays_local(local_path, num_frames), _video_get_metadata_local(local_path, num_frames)
|
|
finally:
|
|
if is_temp:
|
|
try:
|
|
os.remove(local_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def load_video_valley(video: Union[str, bytes]):
|
|
import decord
|
|
from torchvision import transforms
|
|
video_io = load_file(video)
|
|
video_reader = decord.VideoReader(video_io)
|
|
decord.bridge.set_bridge('torch')
|
|
video = video_reader.get_batch(np.linspace(0, len(video_reader) - 1, 8).astype(np.int_)).byte()
|
|
images = [transforms.ToPILImage()(image.permute(2, 0, 1)).convert('RGB') for image in video]
|
|
return images
|
|
|
|
|
|
def load_video_ovis2(video_path, num_frames):
|
|
from moviepy.editor import VideoFileClip
|
|
with VideoFileClip(video_path) as clip:
|
|
total_frames = int(clip.fps * clip.duration)
|
|
if total_frames <= num_frames:
|
|
sampled_indices = range(total_frames)
|
|
else:
|
|
stride = total_frames / num_frames
|
|
sampled_indices = [
|
|
min(total_frames - 1, int((stride * i + stride * (i + 1)) / 2)) for i in range(num_frames)
|
|
]
|
|
frames = [clip.get_frame(index / clip.fps) for index in sampled_indices]
|
|
frames = [Image.fromarray(frame, mode='RGB') for frame in frames]
|
|
return frames
|
|
|
|
|
|
def load_video_ovis2_5(video_path, num_frames):
|
|
from moviepy.editor import VideoFileClip
|
|
with VideoFileClip(video_path) as clip:
|
|
total_frames = int(clip.fps * clip.duration)
|
|
indices = [int(i * total_frames / num_frames) for i in range(num_frames)]
|
|
frames = [Image.fromarray(clip.get_frame(t)) for t in (idx / clip.fps for idx in indices)]
|
|
return frames
|