| import json |
| import subprocess |
|
|
| import av |
| import cv2 |
| import numpy as np |
| import torchvision |
|
|
| |
| try: |
| import decord |
|
|
| DECORD_AVAILABLE = True |
| except ImportError: |
| DECORD_AVAILABLE = False |
|
|
| try: |
| import torchcodec |
|
|
| TORCHCODEC_AVAILABLE = True |
| except (ImportError, RuntimeError): |
| TORCHCODEC_AVAILABLE = False |
|
|
|
|
| def _get_video_info_ffmpeg(video_path: str) -> dict: |
| """Get video metadata using ffprobe.""" |
| cmd = [ |
| "ffprobe", |
| "-v", |
| "error", |
| "-select_streams", |
| "v:0", |
| "-show_entries", |
| "stream=nb_frames,duration,r_frame_rate", |
| "-of", |
| "json", |
| video_path, |
| ] |
|
|
| try: |
| output = subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8") |
| probe_data = json.loads(output) |
| stream = probe_data["streams"][0] |
|
|
| |
| if "/" in stream["r_frame_rate"]: |
| num, den = map(int, stream["r_frame_rate"].split("/")) |
| fps = num / den |
| else: |
| fps = float(stream["r_frame_rate"]) |
|
|
| |
| nb_frames = int(stream.get("nb_frames", 0)) |
| duration = float(stream.get("duration", 0)) |
|
|
| |
| if nb_frames == 0 and duration > 0: |
| nb_frames = int(duration * fps) |
|
|
| return { |
| "nb_frames": nb_frames, |
| "fps": fps, |
| "duration": duration, |
| } |
| except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError) as e: |
| raise ValueError(f"Failed to get video info for {video_path}: {e}") |
|
|
|
|
| def _extract_frames_ffmpeg(video_path: str, frame_indices: list[int]) -> np.ndarray: |
| """Extract specific frames using ffmpeg.""" |
| frames = [] |
|
|
| for idx in frame_indices: |
| |
| cmd = [ |
| "ffmpeg", |
| "-i", |
| video_path, |
| "-vf", |
| f"select=eq(n\\,{idx})", |
| "-vframes", |
| "1", |
| "-f", |
| "image2pipe", |
| "-pix_fmt", |
| "rgb24", |
| "-vcodec", |
| "rawvideo", |
| "-", |
| ] |
|
|
| try: |
| output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) |
|
|
| |
| if len(output) == 0: |
| raise subprocess.CalledProcessError(1, cmd) |
|
|
| |
| if len(frames) == 0: |
| info_cmd = [ |
| "ffprobe", |
| "-v", |
| "error", |
| "-select_streams", |
| "v:0", |
| "-show_entries", |
| "stream=width,height", |
| "-of", |
| "json", |
| video_path, |
| ] |
| info_output = subprocess.check_output(info_cmd).decode("utf-8") |
| info_data = json.loads(info_output) |
| width = info_data["streams"][0]["width"] |
| height = info_data["streams"][0]["height"] |
|
|
| |
| frame_data = np.frombuffer(output, dtype=np.uint8) |
| frame = frame_data.reshape((height, width, 3)) |
| frames.append(frame) |
|
|
| except subprocess.CalledProcessError: |
| |
| if len(frames) > 0: |
| frames.append(np.zeros_like(frames[0])) |
| else: |
| |
| frames.append(np.zeros((480, 640, 3), dtype=np.uint8)) |
|
|
| return np.array(frames) |
|
|
|
|
| def _extract_frames_at_timestamps_ffmpeg(video_path: str, timestamps: list[float]) -> np.ndarray: |
| """Extract frames at specific timestamps using ffmpeg.""" |
| frames = [] |
|
|
| for timestamp in timestamps: |
| cmd = [ |
| "ffmpeg", |
| "-ss", |
| str(timestamp), |
| "-i", |
| video_path, |
| "-vframes", |
| "1", |
| "-f", |
| "image2pipe", |
| "-pix_fmt", |
| "rgb24", |
| "-vcodec", |
| "rawvideo", |
| "-", |
| ] |
|
|
| try: |
| output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) |
|
|
| |
| if len(output) == 0: |
| raise subprocess.CalledProcessError(1, cmd) |
|
|
| |
| if len(frames) == 0: |
| info_cmd = [ |
| "ffprobe", |
| "-v", |
| "error", |
| "-select_streams", |
| "v:0", |
| "-show_entries", |
| "stream=width,height", |
| "-of", |
| "json", |
| video_path, |
| ] |
| info_output = subprocess.check_output(info_cmd).decode("utf-8") |
| info_data = json.loads(info_output) |
| width = info_data["streams"][0]["width"] |
| height = info_data["streams"][0]["height"] |
|
|
| |
| frame_data = np.frombuffer(output, dtype=np.uint8) |
| frame = frame_data.reshape((height, width, 3)) |
| frames.append(frame) |
|
|
| except subprocess.CalledProcessError: |
| |
| if len(frames) > 0: |
| frames.append(frames[-1]) |
| else: |
| frames.append(np.zeros((480, 640, 3), dtype=np.uint8)) |
|
|
| return np.array(frames) |
|
|
|
|
| def _extract_all_frames_ffmpeg(video_path: str) -> tuple[np.ndarray, np.ndarray]: |
| """Extract all frames and their timestamps using ffmpeg.""" |
| |
| info = _get_video_info_ffmpeg(video_path) |
| fps = info["fps"] |
|
|
| |
| cmd = [ |
| "ffmpeg", |
| "-i", |
| video_path, |
| "-f", |
| "image2pipe", |
| "-pix_fmt", |
| "rgb24", |
| "-vcodec", |
| "rawvideo", |
| "-", |
| ] |
|
|
| try: |
| output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) |
|
|
| |
| info_cmd = [ |
| "ffprobe", |
| "-v", |
| "error", |
| "-select_streams", |
| "v:0", |
| "-show_entries", |
| "stream=width,height", |
| "-of", |
| "json", |
| video_path, |
| ] |
| info_output = subprocess.check_output(info_cmd).decode("utf-8") |
| info_data = json.loads(info_output) |
| width = info_data["streams"][0]["width"] |
| height = info_data["streams"][0]["height"] |
|
|
| |
| frame_data = np.frombuffer(output, dtype=np.uint8) |
| total_pixels = len(frame_data) // 3 |
| actual_frames = total_pixels // (width * height) |
|
|
| frames = frame_data[: actual_frames * width * height * 3].reshape( |
| (actual_frames, height, width, 3) |
| ) |
|
|
| |
| timestamps = np.arange(actual_frames) / fps |
|
|
| return frames, timestamps |
|
|
| except subprocess.CalledProcessError as e: |
| raise ValueError(f"Failed to extract frames from {video_path}: {e}") |
|
|
|
|
| def get_frames_by_indices( |
| video_path: str, |
| indices: list[int] | np.ndarray, |
| video_backend: str = "ffmpeg", |
| video_backend_kwargs: dict = {}, |
| ) -> np.ndarray: |
| if video_backend == "decord": |
| if not DECORD_AVAILABLE: |
| raise ImportError("decord is not available. Install it with: pip install decord") |
| vr = decord.VideoReader(video_path, **video_backend_kwargs) |
| frames = vr.get_batch(indices) |
| return frames.asnumpy() |
| elif video_backend == "torchcodec": |
| if not TORCHCODEC_AVAILABLE: |
| raise ImportError("torchcodec is not available.") |
| decoder = torchcodec.decoders.VideoDecoder( |
| video_path, device="cpu", dimension_order="NHWC", num_ffmpeg_threads=0 |
| ) |
| return decoder.get_frames_at(indices=indices).data.numpy() |
| elif video_backend == "ffmpeg": |
| return _extract_frames_ffmpeg(video_path, list(indices)) |
| elif video_backend == "opencv": |
| frames = [] |
| cap = cv2.VideoCapture(video_path, **video_backend_kwargs) |
| for idx in indices: |
| cap.set(cv2.CAP_PROP_POS_FRAMES, idx) |
| ret, frame = cap.read() |
| if not ret: |
| raise ValueError(f"Unable to read frame at index {idx}") |
| frames.append(frame) |
| cap.release() |
| frames = np.array(frames) |
| return frames |
| else: |
| raise NotImplementedError |
|
|
|
|
| def get_frames_by_timestamps( |
| video_path: str, |
| timestamps: list[float] | np.ndarray, |
| video_backend: str = "ffmpeg", |
| video_backend_kwargs: dict = {}, |
| fps: None | float = None, |
| ) -> np.ndarray: |
| """Get frames from a video at specified timestamps. |
| |
| Args: |
| video_path (str): Path to the video file. |
| timestamps (list[int] | np.ndarray): Timestamps to retrieve frames for, in seconds. |
| video_backend (str, optional): Video backend to use. Defaults to "ffmpeg". |
| fps (float, optional): FPS of the video. Defaults to 30. |
| Returns: |
| np.ndarray: Frames at the specified timestamps. |
| """ |
| if video_backend == "decord": |
| if not DECORD_AVAILABLE: |
| raise ImportError("decord is not available. Install it with: pip install decord") |
| vr = decord.VideoReader(video_path, **video_backend_kwargs) |
| num_frames = len(vr) |
| |
| frame_ts: np.ndarray = vr.get_frame_timestamp(range(num_frames)) |
| |
| |
| indices = np.abs(frame_ts[:, :1] - timestamps).argmin(axis=0) |
| frames = vr.get_batch(indices) |
| return frames.asnumpy() |
| elif video_backend == "torchcodec": |
| if not TORCHCODEC_AVAILABLE: |
| raise ImportError("torchcodec is not available.") |
| decoder = torchcodec.decoders.VideoDecoder( |
| video_path, device="cpu", dimension_order="NHWC", num_ffmpeg_threads=0 |
| ) |
|
|
| |
| |
| |
| if fps is None: |
| fps = decoder.metadata.average_fps |
| interval = 1 / fps |
| timestamps = np.array(timestamps).astype(np.float64) |
|
|
| if np.all(timestamps == 0): |
| timestamps = np.arange(len(timestamps)) / fps |
|
|
| |
| |
| first_frame = decoder.get_frames_at(indices=[0]) |
| last_frame = decoder.get_frames_at(indices=[len(decoder) - 1]) |
| min_pts = float(first_frame.pts_seconds[0]) |
| max_pts = float(last_frame.pts_seconds[0]) |
| |
| |
| timestamps = np.clip(timestamps, min_pts, max_pts) |
|
|
| |
| |
| |
| |
| |
| if fps is None: |
| closest_timestamps = np.round(timestamps / interval) * interval |
| |
| closest_timestamps = np.clip(closest_timestamps, min_pts, max_pts) |
| timestamp_errors = np.abs(closest_timestamps - timestamps) / interval |
| invalid_mask = timestamp_errors >= 0.01 |
| if np.any(invalid_mask): |
| invalid_indices = np.where(invalid_mask)[0] |
| invalid_timestamps = timestamps[invalid_indices] |
| raise ValueError( |
| f"Try to read invalid timestamps {invalid_timestamps} from video {video_path} (FPS: {fps})" |
| ) |
|
|
| timestamps = closest_timestamps |
|
|
| return decoder.get_frames_played_at(seconds=timestamps).data.numpy() |
| elif video_backend == "ffmpeg": |
| return _extract_frames_at_timestamps_ffmpeg(video_path, list(timestamps)) |
| elif video_backend == "opencv": |
| |
| cap = cv2.VideoCapture(video_path, **video_backend_kwargs) |
| if not cap.isOpened(): |
| raise ValueError(f"Unable to open video file: {video_path}") |
| |
| num_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) |
| |
| fps = cap.get(cv2.CAP_PROP_FPS) |
| frame_ts = np.arange(num_frames) / fps |
| frame_ts = frame_ts[:, np.newaxis] |
| |
| indices = np.abs(frame_ts - timestamps).argmin(axis=0) |
| frames = [] |
| for idx in indices: |
| cap.set(cv2.CAP_PROP_POS_FRAMES, idx) |
| ret, frame = cap.read() |
| if not ret: |
| raise ValueError(f"Unable to read frame at index {idx}") |
| frames.append(frame) |
| cap.release() |
| frames = np.array(frames) |
| return frames |
|
|
| elif video_backend == "torchvision_av": |
| |
| torchvision.set_video_backend("pyav") |
|
|
| |
| reader = torchvision.io.VideoReader(video_path, "video") |
|
|
| |
| |
| first_ts = timestamps[0] |
| last_ts = timestamps[-1] |
|
|
| |
| |
| |
| reader.seek(first_ts, keyframes_only=True) |
|
|
| |
| |
| found_frames_map = {} |
| tolerance = 0.001 |
| |
| for frame in reader: |
| current_ts = frame["pts"] |
|
|
| |
| for ts in timestamps: |
| if ts not in found_frames_map and abs(current_ts - ts) < tolerance: |
| found_frames_map[ts] = frame["data"] |
| break |
|
|
| if current_ts >= last_ts + tolerance or len(found_frames_map) == len(timestamps): |
| break |
|
|
| reader.container.close() |
| reader = None |
| |
| |
| print(f"[video_utils] Requested {len(timestamps)} timestamps: {timestamps[:4]}{'...' if len(timestamps) > 4 else ''}") |
| print(f"[video_utils] Found {len(found_frames_map)} frames with tolerance={tolerance}s") |
| if len(found_frames_map) < len(timestamps): |
| missing = [ts for ts in timestamps if ts not in found_frames_map] |
| print(f"[video_utils] WARNING: Missing timestamps: {missing[:4]}{'...' if len(missing) > 4 else ''}") |
| |
| frames = np.array(list(found_frames_map.values())) |
| return frames.transpose(0, 2, 3, 1) |
|
|
| else: |
| raise NotImplementedError |
|
|
|
|
| def get_all_frames( |
| video_path: str, |
| video_backend: str = "ffmpeg", |
| video_backend_kwargs: dict = {}, |
| ) -> tuple[np.ndarray, np.ndarray]: |
| """Get all frames from a video. |
| |
| Returns: |
| tuple[np.ndarray, np.ndarray]: Frames and timestamps. |
| """ |
| if video_backend == "decord": |
| if not DECORD_AVAILABLE: |
| raise ImportError("decord is not available. Install it with: pip install decord") |
| vr = decord.VideoReader(video_path, **video_backend_kwargs) |
| frames = vr.get_batch(range(len(vr))).asnumpy() |
| return frames, vr.get_frame_timestamp(range(len(vr)))[:, 0] |
| elif video_backend == "torchcodec": |
| if not TORCHCODEC_AVAILABLE: |
| raise ImportError("torchcodec is not available.") |
| decoder = torchcodec.decoders.VideoDecoder( |
| video_path, device="cpu", dimension_order="NHWC", num_ffmpeg_threads=0 |
| ) |
| frames = decoder.get_frames_at(indices=range(len(decoder))) |
| return frames.data.numpy(), frames.pts_seconds.numpy() |
| elif video_backend == "ffmpeg": |
| return _extract_all_frames_ffmpeg(video_path) |
| elif video_backend == "pyav": |
| container = av.open(video_path) |
| stream = container.streams.video[0] |
| assert stream.time_base is not None |
| frames = [] |
| timestamps = [] |
| for frame in container.decode(video=0): |
| frames.append(frame.to_ndarray(format="rgb24")) |
| timestamps.append(frame.pts * stream.time_base) |
| container.close() |
| return np.stack(frames), np.array(timestamps) |
|
|
| else: |
| raise NotImplementedError |
|
|