This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user