Rate this Page

Source code for torchrl.data.video

# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
"""Lazy, picklable references to frames inside encoded videos.

This module provides :class:`VideoClipRef`, a small :class:`~tensordict.TensorClass`
that stores *where* frames live (a video path/URI plus per-frame indices) without
materializing or decoding them. Indexing a reference is cheap and stays lazy;
decoding to a dense ``uint8`` tensor happens explicitly via
:meth:`VideoClipRef.decode` (or implicitly, see ``auto_decode``) using torchcodec.

It is dataset-agnostic on purpose: anything whose rows carry ``(video file, frame
position)`` can populate a :class:`VideoClipRef`, and decoding never needs to know
which dataset it came from.
"""
from __future__ import annotations

import importlib.util
import os
import threading
from collections import defaultdict, OrderedDict
from collections.abc import Sequence
from typing import Any

import torch
from tensordict import TensorClass

from torchrl._utils import logger as torchrl_logger

__all__ = [
    "VideoClipRef",
    "clear_video_decoder_cache",
    "set_video_decoder_cache_size",
]

_has_torchcodec = importlib.util.find_spec("torchcodec") is not None

_TORCHCODEC_ERROR = (
    "This feature requires torchcodec >= 0.10.0. When running TorchRL from this "
    "repository with uv, use `uv run --extra video <command>` so torchcodec is "
    "installed in the command environment. Otherwise install it with "
    "`pip install 'torchcodec>=0.10.0'`."
)

# --- Per-process decoder cache ------------------------------------------------
# A torchcodec ``VideoDecoder`` holds C++ state + an open file descriptor: it is
# neither picklable nor safe to share across processes. We therefore NEVER store a
# decoder on a ``VideoClipRef`` (the reference carries only the address). Decoders
# are opened lazily and cached at module level, so the cache is naturally
# per-process: it is rebuilt independently in every collector / replay-buffer
# prefetch / DataLoader worker, and is never part of any pickled state.
_DECODER_CACHE: OrderedDict[tuple, Any] = OrderedDict()
_DECODER_CACHE_LOCK = threading.Lock()
_DECODER_CACHE_MAXSIZE = 8


[docs] def set_video_decoder_cache_size(maxsize: int) -> None: """Sets the maximum number of open torchcodec decoders cached per process. The cache is keyed by ``(source, stream, device)``; least-recently-used decoders are evicted (and closed) once the limit is exceeded. Args: maxsize (int): the maximum number of decoders to keep open per process. """ global _DECODER_CACHE_MAXSIZE _DECODER_CACHE_MAXSIZE = int(maxsize) with _DECODER_CACHE_LOCK: while len(_DECODER_CACHE) > _DECODER_CACHE_MAXSIZE: _DECODER_CACHE.popitem(last=False)
[docs] def clear_video_decoder_cache() -> None: """Closes and clears all cached torchcodec decoders in the current process.""" with _DECODER_CACHE_LOCK: _DECODER_CACHE.clear()
def _get_decoder(source: Any, stream: int | None, device: Any): key = (source, stream, str(device) if device is not None else None) with _DECODER_CACHE_LOCK: decoder = _DECODER_CACHE.get(key) if decoder is not None: _DECODER_CACHE.move_to_end(key) return decoder if not _has_torchcodec: raise ModuleNotFoundError(_TORCHCODEC_ERROR) try: from torchcodec.decoders import VideoDecoder except Exception as err: # pragma: no cover - import-time environment issue raise ImportError(_TORCHCODEC_ERROR) from err kwargs: dict[str, Any] = {} if stream is not None: kwargs["stream_index"] = int(stream) if device is not None: kwargs["device"] = str(device) decoder = VideoDecoder(source, **kwargs) with _DECODER_CACHE_LOCK: _DECODER_CACHE[key] = decoder _DECODER_CACHE.move_to_end(key) while len(_DECODER_CACHE) > _DECODER_CACHE_MAXSIZE: _DECODER_CACHE.popitem(last=False) return decoder def _num_frames(decoder) -> int: num = getattr(decoder.metadata, "num_frames", None) if num is None: num = len(decoder) return int(num) def _flatten(value: Any) -> list: """Flattens nested non-tensor leaves (lists / NonTensorStack / LinkedList).""" if isinstance(value, (str, bytes)): return [value] if isinstance(value, os.PathLike): return [os.fspath(value)] if hasattr(value, "tolist") and not torch.is_tensor(value): # NonTensorStack and similar expose a python view via tolist() return _flatten(value.tolist()) if torch.is_tensor(value): return value.reshape(-1).tolist() try: iterator = iter(value) except TypeError: return [value] out: list = [] for element in iterator: out.extend(_flatten(element)) return out def _first(value: Any, default: Any = None) -> Any: flat = _flatten(value) for element in flat: return element return default def _as_path(value: Any) -> str: return os.fspath(value) if isinstance(value, os.PathLike) else value def _is_path(value: Any) -> bool: return isinstance(value, (str, bytes, os.PathLike)) def _file_lists(value: Any) -> list[list]: """Collects the file-list(s) carried by a ``sources`` field, row-major. A ``VideoClipRef`` stores the unique file paths once as a flat tuple (``sources``). Batched tensorclass operations (stacking, replay-buffer storage) can wrap that tuple into a nested ``LinkedList`` that carries one copy of the tuple per batch element. This walks whatever structure ``sources`` currently has and returns every innermost "file list" (a sequence of path-like leaves) in row-major order, so the caller can resolve ``sources[file_id]`` per element. """ if value is None: return [[]] if _is_path(value): return [[_as_path(value)]] if hasattr(value, "tolist") and not torch.is_tensor(value): return _file_lists(value.tolist()) try: children = list(value) except TypeError: return [[value]] if children and all(_is_path(child) for child in children): return [[_as_path(child) for child in children]] out: list[list] = [] for child in children: out.extend(_file_lists(child)) return out def _resolve_paths(sources: Any, file_id: list[int], n: int) -> list: """Resolves the per-element source path from ``sources`` and ``file_id``. ``file_id[i]`` indexes into the file list that applies to element ``i``. The file list is either shared by all elements (a single flat ``sources`` tuple) or carried per element (after stacking / replay-buffer storage); both collapse to the same per-element path here. """ groups = _file_lists(sources) if len(groups) == 1: files = groups[0] return [files[file_id[i]] for i in range(n)] if len(groups) != n: raise RuntimeError( f"VideoClipRef.decode found {len(groups)} source file lists for {n} " "frame indices; sources and frame_index must describe the same elements." ) return [groups[i][file_id[i]] for i in range(n)] def _is_contiguous_run(indices: list[int]) -> bool: return len(indices) > 0 and indices == list( range(indices[0], indices[0] + len(indices)) ) # Whether GPU decoding has been found unsupported by the installed torchcodec # build in this process (set the first time a CUDA decode is attempted and fails). _CUDA_DECODE_DISABLED = False def _is_unsupported_device_error(err: Exception) -> bool: message = str(err).lower() return "unsupported device" in message or "deviceinterface" in message def _frames_for_indices(decoder, indices: list[int]) -> torch.Tensor: if _is_contiguous_run(indices): return decoder.get_frames_in_range(start=indices[0], stop=indices[-1] + 1).data return decoder.get_frames_at(indices=indices).data def _decode_group( source: Any, stream: int | None, indices: list[int], decode_device: Any ) -> torch.Tensor: """Decodes frames for a single source, with a CPU fallback. Falls back to CPU decoding when the torchcodec build cannot decode on the requested CUDA device (NVDEC); the caller moves the result to the requested output device afterwards. """ global _CUDA_DECODE_DISABLED use_device = decode_device if not _CUDA_DECODE_DISABLED else None try: return _frames_for_indices(_get_decoder(source, stream, use_device), indices) except RuntimeError as err: if use_device is not None and _is_unsupported_device_error(err): _CUDA_DECODE_DISABLED = True torchrl_logger.warning( "torchcodec cannot decode on the requested CUDA device; falling " "back to CPU decoding and moving frames to the device. Install a " "CUDA-enabled torchcodec build to use NVDEC." ) return _frames_for_indices(_get_decoder(source, stream, None), indices) raise def _bin_frame_index( num_frames: int, num_bins: int, frames_per_bin: int | None = None ) -> torch.Tensor: """Builds frame positions for ``num_bins`` non-overlapping temporal bins. Bins partition ``[0, num_frames)`` via ``round(linspace(0, num_frames, num_bins + 1))`` edges. With ``frames_per_bin=None`` each bin contributes its center frame (shape ``[num_bins]`` -- a subsample). With ``frames_per_bin=k`` each bin contributes ``k`` frames spanning the bin (shape ``[num_bins, k]`` -- a dense stack; frames are dropped or repeated as needed to stay rectangular). """ if num_bins < 1: raise ValueError(f"num_bins must be >= 1, got {num_bins}.") if num_frames < 1: raise ValueError(f"num_frames must be >= 1, got {num_frames}.") edges = torch.linspace(0, num_frames, num_bins + 1).round().long() last = num_frames - 1 lo = edges[:-1].clamp(0, last) hi = torch.maximum((edges[1:] - 1).clamp(0, last), lo) if frames_per_bin is None: return ((lo + hi).to(torch.float) / 2).round().long() k = int(frames_per_bin) if k < 1: raise ValueError(f"frames_per_bin must be >= 1, got {k}.") fractions = torch.linspace(0, 1, k) span = (hi - lo).to(torch.float) return ( (lo[:, None].to(torch.float) + fractions[None, :] * span[:, None]) .round() .long() )
[docs] class VideoClipRef(TensorClass["nocast"]): """A lazy, picklable reference to frames inside an encoded video. A ``VideoClipRef`` stores only the *address* of frames -- the video file(s) it spans (``sources``) plus a per-frame ``frame_index`` and ``file_id`` -- never an open decoder and never the decoded pixels. Its batch dimensions are the frames, so indexing behaves like a tensor of frames but stays lazy:: video[50:100] # -> VideoClipRef with 50 frames, no decoding happened The address is stored compactly so that multi-file references stay cheap: the unique file paths live once in the ``sources`` metadata tuple, and each frame carries a small ``file_id`` integer (an index into ``sources``) alongside its ``frame_index`` (the *local* position within that file). A single-file reference has ``sources=(path,)`` and an all-zero ``file_id``; spanning several files (see :meth:`from_files`) only grows ``file_id`` by one ``int64`` per frame rather than storing a path string per frame. For backward compatibility, the resolved video path is exposed via the :attr:`source` property. Decoding to a dense ``uint8`` tensor is explicit, batched and seek-friendly via :meth:`decode` (grouping by source, using ranged reads for contiguous indices), which makes it compose naturally with :class:`~torchrl.data.SliceSampler` (a contiguous window of steps becomes a single ranged decode). Use the companion :class:`~torchrl.envs.transforms.DecodeVideoTransform` to decode on the replay-buffer sample path. To align a video onto a lower-rate signal (e.g. proprioceptive steps), use :meth:`rebin` (subsample to one frame per bin, or a dense non-overlapping per-bin stack) or :meth:`from_timestamps` (time-based alignment). Args: sources (str, PathLike or tuple of str): the video path / URI / file id, or a tuple of unique paths for a multi-file reference. A single path is normalized to a one-element tuple. Required. (Accessible afterwards as either ``sources`` -- the tuple -- or :attr:`source` -- the resolved path.) frame_index (torch.Tensor, optional): a ``long`` tensor of *local* frame positions (within each frame's file) whose shape becomes the batch size. If omitted, it defaults to *every* frame of the (single) source (``arange(num_frames)``) and the frame count is read from the file metadata once at construction (this opens the file and requires torchcodec). Pass it explicitly to reference a subset -- e.g. one episode of a multi-episode / "bucketed" file -- with no metadata read. file_id (torch.Tensor, optional): a ``long`` tensor, the same shape as ``frame_index``, giving each frame's index into ``sources``. Defaults to all zeros (every frame comes from ``sources[0]``). Built automatically by :meth:`from_files` for multi-file references. Keyword Args: stream (int, optional): the video stream index to decode from. ``None`` (default) lets torchcodec pick the best video stream. auto_decode (bool, optional): if ``True``, indexing the reference (``ref[...]``) returns decoded frames directly instead of a narrowed reference. Intended for standalone / interactive use; keep it ``False`` for references stored in a replay buffer and rely on :class:`~torchrl.envs.transforms.DecodeVideoTransform` instead. Defaults to ``False``. out_device (torch.device or str, optional): default output device for decoded frames. A CUDA device uses GPU (NVDEC) decoding when the torchcodec build supports it, otherwise frames are decoded on CPU and moved to the device. Defaults to ``None`` (CPU). out_dtype (torch.dtype, optional): default dtype for decoded frames. Defaults to ``None`` (``uint8``). .. note:: A torchcodec ``VideoDecoder`` is not picklable nor process-safe. ``VideoClipRef`` is fully picklable because it stores no decoder; decoders are opened lazily and cached per process (see :func:`set_video_decoder_cache_size`). Examples: >>> import tempfile, os, torch >>> from torchcodec.encoders import VideoEncoder >>> from torchrl.data import VideoClipRef >>> frames = torch.arange(16, dtype=torch.uint8).reshape(16, 1, 1, 1) >>> frames = frames.expand(16, 3, 8, 8).contiguous() >>> path = os.path.join(tempfile.mkdtemp(), "clip.mp4") >>> VideoEncoder(frames=frames, frame_rate=10).to_file(path) >>> ref = VideoClipRef(path) # every frame; batch size read from file >>> ref.batch_size torch.Size([16]) >>> ref.source == path # back-compat accessor True >>> clip = ref[4:8] # lazy, no decoding >>> clip.batch_size torch.Size([4]) >>> decoded = clip.decode() # uint8 [4, 3, 8, 8] >>> decoded.shape torch.Size([4, 3, 8, 8]) .. seealso:: :class:`~torchrl.envs.transforms.DecodeVideoTransform`. """ sources: Any frame_index: torch.Tensor | None = None file_id: torch.Tensor | None = None stream: int | None = None auto_decode: bool = False out_device: Any = None out_dtype: Any = None def __post_init__(self): # Runs only on direct construction (``VideoClipRef(...)``), not on the # internal ``_from_tensordict`` path used by indexing/stacking -- and even # if it did run there, every branch below is a no-op once ``frame_index`` / # ``file_id`` are long tensors with a matching ``batch_size``. sources = self.sources # Normalize a single path (str / PathLike) into a one-element tuple so the # ``VideoClipRef(path)`` ergonomic and the multi-file form share one schema. if _is_path(sources): sources = (_as_path(sources),) self.sources = sources frame_index = self.frame_index if frame_index is None: # No frame_index given: address every frame of the (single) source, # reading the count from its metadata once. ``VideoClipRef(path)``. frame_index = torch.arange( _num_frames(_get_decoder(_first(sources), self.stream, None)) ) elif not torch.is_tensor(frame_index): frame_index = torch.as_tensor(frame_index) if frame_index.dtype != torch.long: frame_index = frame_index.to(torch.long) if frame_index is not self.frame_index: self.frame_index = frame_index # ``file_id`` defaults to all-zeros (single-file): every frame -> sources[0]. file_id = self.file_id if file_id is None: file_id = torch.zeros_like(frame_index) elif not torch.is_tensor(file_id): file_id = torch.as_tensor(file_id) if file_id.dtype != torch.long: file_id = file_id.to(torch.long) if file_id is not self.file_id: self.file_id = file_id # tensorclass does not infer batch_size from a tensor field, so set it here # (the frames are the batch dimensions). A scalar (0-d) index stays scalar. if self.batch_size == torch.Size([]) and frame_index.ndim >= 1: self.batch_size = frame_index.shape @property def source(self): """The video path(s) addressed by this reference (back-compat accessor). For a single-file reference this is the path string ``sources[0]`` (so ``ref.source == path``). For a multi-file reference it is the list of per-element resolved paths (one per frame), in batch order. """ groups = _file_lists(self.sources) if len(groups) == 1 and len(groups[0]) == 1: # Single-file reference: return the bare path for backward compatibility. return groups[0][0] file_id = self.file_id if file_id is None: return groups[0] if len(groups) == 1 else groups flat = file_id.reshape(-1).cpu().tolist() return _resolve_paths(self.sources, flat, len(flat))
[docs] @classmethod def from_file( cls, path: str | os.PathLike, *, stream: int | None = None, num_frames: int | None = None, frame_index: torch.Tensor | None = None, num_bins: int | None = None, frames_per_bin: int | None = None, auto_decode: bool = False, device: Any = None, dtype: Any = None, ) -> VideoClipRef: """Builds a reference to (by default) every frame of a video file. Args: path (str or PathLike): the video path / URI / file id. Keyword Args: stream (int, optional): stream index to decode from. Defaults to ``None`` (best video stream). num_frames (int, optional): number of frames. If ``None`` and ``frame_index`` is also ``None``, the count is read from the file metadata (this opens the file once). frame_index (torch.Tensor, optional): explicit frame positions. If given, ``num_frames`` is ignored and no metadata read is performed. Mutually exclusive with ``num_bins``. num_bins (int, optional): resample the video onto this many non-overlapping temporal bins (see :meth:`rebin`). Defaults to ``None`` (every frame). frames_per_bin (int, optional): with ``num_bins``, the number of frames per bin (a dense ``[num_bins, frames_per_bin]`` stack). ``None`` (default) takes a single center frame per bin (a ``[num_bins]`` subsample). Requires ``num_bins``. auto_decode (bool, optional): see :class:`VideoClipRef`. Defaults to ``False``. device (torch.device or str, optional): default output device. A CUDA device uses GPU (NVDEC) decoding when supported, else decodes on CPU and moves the frames. Defaults to ``None``. dtype (torch.dtype, optional): default decode dtype. Defaults to ``None`` (``uint8``). Returns: VideoClipRef: a reference whose batch size is the number of frames (or ``[num_bins]`` / ``[num_bins, frames_per_bin]`` when binning). """ if isinstance(path, os.PathLike): path = os.fspath(path) if num_bins is not None: if frame_index is not None: raise ValueError("`num_bins` and `frame_index` are mutually exclusive.") if num_frames is None: num_frames = _num_frames(_get_decoder(path, stream, None)) frame_index = _bin_frame_index( int(num_frames), int(num_bins), frames_per_bin ) elif frames_per_bin is not None: raise ValueError("`frames_per_bin` requires `num_bins`.") elif frame_index is None and num_frames is not None: frame_index = torch.arange(int(num_frames)) # ``frame_index=None`` lets ``__post_init__`` read the frame count from the # file metadata; an explicit ``frame_index`` (or ``num_frames``) skips that. # ``file_id`` defaults to all-zeros (single source) in ``__post_init__``. return cls( sources=(path,), frame_index=frame_index, stream=stream, auto_decode=auto_decode, out_device=device, out_dtype=dtype, )
[docs] @classmethod def from_files( cls, paths: Sequence[str | os.PathLike], *, num_frames_per_file: int | Sequence[int] | None = None, num_bins: int | None = None, frames_per_bin: int | None = None, stream: int | None = None, auto_decode: bool = False, device: Any = None, dtype: Any = None, ) -> VideoClipRef: """Builds a single reference spanning several video files (a lazy "cat"). The files are addressed as one logical sequence: element ``j`` of the returned reference points to a ``(file, local frame)`` pair, so slicing, :meth:`rebin` and decoding work across file boundaries (a slice that straddles two files decodes per file and concatenates). This avoids concatenating the videos into one large file and avoids any ``LazyStacked``/``LazyCat`` container -- it is just a longer ``frame_index`` plus a per-element ``file_id``. The address is stored compactly: the unique file paths are kept once in the ``sources`` metadata tuple (in first-seen order), and each frame carries a single ``int64`` ``file_id`` into that tuple plus its ``frame_index`` (the local position within its file). No path string is stored per frame, so references spanning thousands of files stay light on the replay-buffer path. Files may have different lengths; the batch size is the total frame count. Args: paths (sequence of str or PathLike): the video files, in order. Keyword Args: num_frames_per_file (int or sequence of int, optional): frame count(s). An ``int`` applies to every file; a sequence gives one count per file. ``None`` (default) reads each file's metadata once. num_bins (int, optional): resample the concatenated frames onto this many non-overlapping temporal bins (see :meth:`rebin`). Defaults to ``None`` (every frame). frames_per_bin (int, optional): with ``num_bins``, the number of frames per bin. ``None`` (default) takes a single center frame per bin. Requires ``num_bins``. stream (int, optional): stream index, applied to every file. auto_decode (bool, optional): see :class:`VideoClipRef`. device (torch.device or str, optional): default output device. dtype (torch.dtype, optional): default decode dtype. Returns: VideoClipRef: a reference over the concatenated frames of all files. Examples: >>> ref = VideoClipRef.from_files(["ep0.mp4", "ep1.mp4"]) # doctest: +SKIP >>> ref.rebin(30, frames_per_bin=4).decode().shape # doctest: +SKIP torch.Size([30, 4, 3, H, W]) """ paths = [os.fspath(p) if isinstance(p, os.PathLike) else p for p in paths] if not paths: raise ValueError("`from_files` requires at least one path.") if num_frames_per_file is None: counts = [_num_frames(_get_decoder(p, stream, None)) for p in paths] elif isinstance(num_frames_per_file, int): counts = [int(num_frames_per_file)] * len(paths) else: counts = [int(c) for c in num_frames_per_file] if len(counts) != len(paths): raise ValueError( "`num_frames_per_file` must have one entry per path " f"({len(paths)}), got {len(counts)}." ) # Unique paths in first-seen order; each frame stores a small int file_id # into this tuple rather than a per-frame path string. unique: list[str] = [] index_of: dict[str, int] = {} for path in paths: if path not in index_of: index_of[path] = len(unique) unique.append(path) local: list[int] = [] file_ids: list[int] = [] for path, count in zip(paths, counts): local.extend(range(count)) file_ids.extend([index_of[path]] * count) frame_index = torch.tensor(local, dtype=torch.long) file_id = torch.tensor(file_ids, dtype=torch.long) ref = cls( sources=tuple(unique), frame_index=frame_index, file_id=file_id, stream=stream, auto_decode=auto_decode, out_device=device, out_dtype=dtype, batch_size=frame_index.shape, ) if num_bins is not None: return ref.rebin(int(num_bins), frames_per_bin) if frames_per_bin is not None: raise ValueError("`frames_per_bin` requires `num_bins`.") return ref
[docs] @classmethod def from_timestamps( cls, path: str | os.PathLike, timestamps: torch.Tensor, *, stream: int | None = None, fps: float | None = None, auto_decode: bool = False, device: Any = None, dtype: Any = None, ) -> VideoClipRef: """Builds a reference from timestamps (seconds), for fps-mismatched streams. Timestamps are converted to the nearest frame index using the stream's average fps (read from the file unless ``fps`` is given), so cameras that run at a different rate than the control loop can still be addressed. Args: path (str or PathLike): the video path / URI / file id. timestamps (torch.Tensor): timestamps in seconds. Keyword Args: stream (int, optional): stream index. Defaults to ``None``. fps (float, optional): frames-per-second to use for the conversion. Defaults to ``None`` (read from metadata). auto_decode (bool, optional): see :class:`VideoClipRef`. device (torch.device or str, optional): default decode device. dtype (torch.dtype, optional): default decode dtype. Returns: VideoClipRef: a reference addressing the nearest frames to ``timestamps``. """ if isinstance(path, os.PathLike): path = os.fspath(path) decoder = _get_decoder(path, stream, None) if fps is None: fps = float(decoder.metadata.average_fps) num_frames = _num_frames(decoder) timestamps = torch.as_tensor(timestamps, dtype=torch.float) frame_index = (timestamps * fps).round().long().clamp_(0, num_frames - 1) return cls.from_file( path, stream=stream, frame_index=frame_index, auto_decode=auto_decode, device=device, dtype=dtype, )
[docs] def rebin(self, num_bins: int, frames_per_bin: int | None = None) -> VideoClipRef: """Resamples the referenced frames onto ``num_bins`` non-overlapping bins. Bins partition the frames this reference currently addresses into ``num_bins`` contiguous, non-overlapping temporal bins, useful to align a video onto a lower-rate signal (e.g. proprioceptive steps). - ``frames_per_bin=None`` (default) keeps one **center** frame per bin: the returned reference has batch size ``[num_bins]`` and decodes to ``[num_bins, C, H, W]`` (a subsample / decimation). - ``frames_per_bin=k`` keeps ``k`` frames spanning each bin: batch size ``[num_bins, k]``, decoding to ``[num_bins, k, C, H, W]`` (a dense, non-overlapping stack; frames are dropped or repeated to stay rectangular). For *overlapping* (sliding-window) stacking, subsample first (``rebin(num_bins)``) and apply :class:`~torchrl.envs.transforms.CatFrames` to the decoded frames on the sample path. Args: num_bins (int): the number of temporal bins. frames_per_bin (int, optional): frames kept per bin. ``None`` (default) takes the single center frame. Returns: VideoClipRef: a new (lazy) reference over the binned frames. Examples: >>> ref = VideoClipRef(path) # 100 frames # doctest: +SKIP >>> ref.rebin(30).batch_size # one frame per proprio step torch.Size([30]) >>> ref.rebin(30, frames_per_bin=3).batch_size torch.Size([30, 3]) """ flat = self.reshape(-1) positions = _bin_frame_index( int(flat.frame_index.numel()), int(num_bins), frames_per_bin ) # Gather ``file_id`` and ``frame_index`` together at the selected positions so # a multi-file reference keeps each bin pointing at the correct file; the # ``sources`` tuple rides along unchanged as metadata. We index the underlying # tensordict (rather than ``flat[positions]``) to bypass the ``auto_decode`` # eager path and always return a (lazy) reference. return type(self)._from_tensordict(flat._tensordict[positions])
def _source_list(self, n: int) -> list: """Resolves the per-element source path (one per frame) for ``decode``.""" file_id = self.file_id if file_id is None: file_id_flat = [0] * n else: file_id_flat = file_id.reshape(-1).cpu().tolist() if len(file_id_flat) != n: raise RuntimeError( f"VideoClipRef.decode found {len(file_id_flat)} file ids for {n} " "frame indices; file_id and frame_index must describe the same " "elements." ) return _resolve_paths(self.sources, file_id_flat, n)
[docs] def decode(self, *, device: Any = None, dtype: Any = None) -> torch.Tensor: """Decodes the referenced frames to a dense tensor. Each frame's source file is resolved from its ``file_id`` (an index into the ``sources`` tuple); frames are then grouped by source file and decoded with a per-process decoder cache, with contiguous index runs read in a single ranged read. The output keeps the reference's batch shape: a scalar reference yields ``[C, H, W]`` and a reference with batch size ``[*B]`` yields ``[*B, C, H, W]``. Keyword Args: device (torch.device or str, optional): output device for the decoded frames, overriding ``out_device``. Defaults to ``out_device`` if set, else the reference's own device (so ``td.to("cuda")`` on a tensordict holding the reference decodes onto CUDA), else CPU. A CUDA device uses GPU (NVDEC) decoding when the torchcodec build supports it, and otherwise decodes on CPU and moves the frames to the device. dtype (torch.dtype, optional): dtype for the decoded frames, overriding ``out_dtype``. Defaults to ``uint8``. Returns: torch.Tensor: the decoded frames (``uint8`` by default). """ if not _has_torchcodec: raise ModuleNotFoundError(_TORCHCODEC_ERROR) if device is None: device = _first(self.out_device) if device is None: # Fall back to the reference's own (container) device, so moving the # tensordict that holds it -- e.g. ``td.to("cuda")`` -- makes # ``decode()`` materialize frames on that device. device = self.device if dtype is None: dtype = _first(self.out_dtype) stream = _first(self.stream) out_device = torch.device(device) if device is not None else None # Only attempt on-device (NVDEC) decoding for CUDA outputs; CPU outputs and # the fallback path decode on CPU and move afterwards. decode_device = ( out_device if out_device is not None and out_device.type == "cuda" else None ) frame_index = self.frame_index.reshape(-1).cpu().tolist() n = len(frame_index) # Resolve each frame's source path from its file_id (raises on mismatch). sources = self._source_list(n) groups: dict[Any, list[tuple[int, int]]] = defaultdict(list) for position, (src, idx) in enumerate(zip(sources, frame_index)): groups[src].append((position, int(idx))) out: list[torch.Tensor | None] = [None] * n for src, items in groups.items(): # Decode each distinct frame once, then scatter to every position that # referenced it (binning / sliding windows can repeat indices). unique = sorted({idx for _, idx in items}) data = _decode_group(src, stream, unique, decode_device) offset_of = {idx: offset for offset, idx in enumerate(unique)} for position, idx in items: out[position] = data[offset_of[idx]] stacked = torch.stack(out) # type: ignore[arg-type] if dtype is not None: stacked = stacked.to(dtype) if out_device is not None: stacked = stacked.to(out_device) frame_shape = stacked.shape[1:] return stacked.reshape(*self.batch_size, *frame_shape)
@property def frames(self) -> torch.Tensor: """Decoded frames for this reference (shorthand for :meth:`decode`).""" return self.decode() def __getitem__(self, index): out = super().__getitem__(index) if isinstance(out, VideoClipRef) and bool(_first(out.auto_decode, False)): return out.decode() return out