poet70's picture
Upload folder using huggingface_hub
ac29381 verified
Raw
History Blame Contribute Delete
42.6 kB
from typing import Any, Callable, ClassVar, Optional, Literal
import albumentations as A
import cv2
from einops import rearrange
import functools
import numpy as np
from pydantic import Field, PrivateAttr, field_validator
import torch
import torchvision.transforms.v2 as T
from groot.vla.data.schema import DatasetMetadata
from groot.vla.data.transform.base import ModalityTransform
class VideoTransform(ModalityTransform):
# Configurable attributes
backend: str = Field(
default="torchvision", description="The backend to use for the transformations"
)
# Model variables
_train_transform: Callable | None = PrivateAttr(default=None)
_eval_transform: Callable | None = PrivateAttr(default=None)
_original_resolutions: dict[str, tuple[int, int]] = PrivateAttr(default_factory=dict)
# Model constants
_INTERPOLATION_MAP: ClassVar[dict[str, dict[str, Any]]] = PrivateAttr(
{
"nearest": {
"albumentations": cv2.INTER_NEAREST,
"torchvision": T.InterpolationMode.NEAREST,
},
"linear": {
"albumentations": cv2.INTER_LINEAR,
"torchvision": T.InterpolationMode.BILINEAR,
},
"cubic": {
"albumentations": cv2.INTER_CUBIC,
"torchvision": T.InterpolationMode.BICUBIC,
},
"area": {
"albumentations": cv2.INTER_AREA,
"torchvision": None, # Torchvision does not support this interpolation mode
},
"lanczos4": {
"albumentations": cv2.INTER_LANCZOS4, # Lanczos with a 4x4 filter
"torchvision": T.InterpolationMode.LANCZOS, # Torchvision does not specify filter size, might be different from 4x4
},
"linear_exact": {
"albumentations": cv2.INTER_LINEAR_EXACT,
"torchvision": None, # Torchvision does not support this interpolation mode
},
"nearest_exact": {
"albumentations": cv2.INTER_NEAREST_EXACT,
"torchvision": T.InterpolationMode.NEAREST_EXACT,
},
"max": {
"albumentations": cv2.INTER_MAX,
"torchvision": None,
},
}
)
@property
def train_transform(self) -> Callable:
assert (
self._train_transform is not None
), "Transform is not set. Please call set_metadata() before calling apply()."
return self._train_transform
@train_transform.setter
def train_transform(self, value: Callable):
self._train_transform = value
@property
def eval_transform(self) -> Callable | None:
return self._eval_transform
@eval_transform.setter
def eval_transform(self, value: Callable | None):
self._eval_transform = value
@property
def original_resolutions(self) -> dict[str, tuple[int, int]]:
assert (
self._original_resolutions is not None
), "Original resolutions are not set. Please call set_metadata() before calling apply()."
return self._original_resolutions
@original_resolutions.setter
def original_resolutions(self, value: dict[str, tuple[int, int]]):
self._original_resolutions = value
def check_input(self, data: dict[str, Any]):
if self.backend == "torchvision":
for key in self.apply_to:
assert isinstance(data[key], torch.Tensor), f"Video {key} is not a torch tensor"
assert data[key].ndim in [
4,
5,
], f"Expected video {key} to have 4 or 5 dimensions (T, C, H, W or T, B, C, H, W), got {data[key].ndim}"
elif self.backend == "albumentations":
for key in self.apply_to:
assert isinstance(data[key], np.ndarray), f"Video {key} is not a numpy array"
assert data[key].ndim in [
4,
5,
], f"Expected video {key} to have 4 or 5 dimensions (T, C, H, W or T, B, C, H, W), got {data[key].ndim}"
else:
raise ValueError(f"Backend {self.backend} not supported")
def set_metadata(self, dataset_metadata: DatasetMetadata):
super().set_metadata(dataset_metadata)
self.original_resolutions = {}
for key in self.apply_to:
split_keys = key.split(".")
assert len(split_keys) == 2, f"Invalid key: {key}. Expected format: modality.key"
sub_key = split_keys[1]
if sub_key in dataset_metadata.modalities.video:
self.original_resolutions[key] = dataset_metadata.modalities.video[
sub_key
].resolution
else:
raise ValueError(
f"Video key {sub_key} not found in dataset metadata. Available keys: {dataset_metadata.modalities.video.keys()}"
)
train_transform = self.get_transform(mode="train")
eval_transform = self.get_transform(mode="eval")
if self.backend == "albumentations":
self.train_transform = A.ReplayCompose(transforms=[train_transform]) # type: ignore
if eval_transform is not None:
self.eval_transform = A.ReplayCompose(transforms=[eval_transform]) # type: ignore
else:
assert train_transform is not None, "Train transform must be set"
self.train_transform = train_transform
self.eval_transform = eval_transform
def apply(self, data: dict[str, Any]) -> dict[str, Any]:
if self.training:
transform = self.train_transform
else:
transform = self.eval_transform
if transform is None:
return data
assert (
transform is not None
), "Transform is not set. Please call set_metadata() before calling apply()."
try:
self.check_input(data)
except AssertionError as e:
raise ValueError(
f"Input data does not match the expected format for {self.__class__.__name__}: {e}"
) from e
# Concatenate views
views = [data[key] for key in self.apply_to]
num_views = len(views)
is_batched = views[0].ndim == 5
bs = views[0].shape[0] if is_batched else 1
if isinstance(views[0], torch.Tensor):
views = torch.cat(views, 0)
elif isinstance(views[0], np.ndarray):
views = np.concatenate(views, 0)
else:
raise ValueError(f"Unsupported view type: {type(views[0])}")
if is_batched:
views = rearrange(views, "(v b) t c h w -> (v b t) c h w", v=num_views, b=bs)
# Apply the transform
if self.backend == "torchvision":
views = transform(views)
elif self.backend == "albumentations":
assert isinstance(transform, A.ReplayCompose), "Transform must be a ReplayCompose"
first_frame = views[0]
transformed = transform(image=first_frame)
replay_data = transformed["replay"]
transformed_first_frame = transformed["image"]
if len(views) > 1:
# Apply the same transformations to the rest of the frames
transformed_frames = [
transform.replay(replay_data, image=frame)["image"] for frame in views[1:]
]
# Add the first frame back
transformed_frames = [transformed_first_frame] + transformed_frames
else:
# If there is only one frame, just make a list with one frame
transformed_frames = [transformed_first_frame]
# Delete the replay data to save memory
del replay_data
views = np.stack(transformed_frames, 0)
else:
raise ValueError(f"Backend {self.backend} not supported")
# Split views
if is_batched:
views = rearrange(views, "(v b t) c h w -> v b t c h w", v=num_views, b=bs)
else:
views = rearrange(views, "(v t) c h w -> v t c h w", v=num_views)
for key, view in zip(self.apply_to, views):
data[key] = view
return data
@classmethod
def _validate_interpolation(cls, interpolation: str):
if interpolation not in cls._INTERPOLATION_MAP:
raise ValueError(f"Interpolation mode {interpolation} not supported")
def _get_interpolation(self, interpolation: str, backend: str = "torchvision"):
"""
Get the interpolation mode for the given backend.
Args:
interpolation (str): The interpolation mode.
backend (str): The backend to use.
Returns:
Any: The interpolation mode for the given backend.
"""
return self._INTERPOLATION_MAP[interpolation][backend]
def get_transform(self, mode: Literal["train", "eval"] = "train") -> Callable | None:
raise NotImplementedError(
"set_transform is not implemented for VideoTransform. Please implement this function to set the transforms."
)
class VideoCrop(VideoTransform):
height: int | None = Field(default=None, description="The height of the input image")
width: int | None = Field(default=None, description="The width of the input image")
scale: float = Field(
...,
description="The scale of the crop. The crop size is (width * scale, height * scale)",
)
def get_transform(self, mode: Literal["train", "eval"] = "train") -> Callable:
"""Get the transform for the given mode.
Args:
mode (Literal["train", "eval"]): The mode to get the transform for.
Returns:
Callable: If mode is "train", return a random crop transform. If mode is "eval", return a center crop transform.
"""
# 1. Check the input resolution
assert (
len(set(self.original_resolutions.values())) == 1
), f"All video keys must have the same resolution, got: {self.original_resolutions}"
if self.height is None:
assert self.width is None, "Height and width must be either both provided or both None"
self.width, self.height = self.original_resolutions[self.apply_to[0]]
else:
assert (
self.width is not None
), "Height and width must be either both provided or both None"
# 2. Create the transform
size = (int(self.height * self.scale), int(self.width * self.scale))
if self.backend == "torchvision":
if mode == "train":
return T.RandomCrop(size)
elif mode == "eval":
return T.CenterCrop(size)
else:
raise ValueError(f"Crop mode {mode} not supported")
elif self.backend == "albumentations":
if mode == "train":
return A.RandomCrop(height=size[0], width=size[1], p=1)
elif mode == "eval":
return A.CenterCrop(height=size[0], width=size[1], p=1)
else:
raise ValueError(f"Crop mode {mode} not supported")
else:
raise ValueError(f"Backend {self.backend} not supported")
def check_input(self, data: dict[str, Any]):
super().check_input(data)
# Check the input resolution
for key in self.apply_to:
if self.backend == "torchvision":
height, width = data[key].shape[-2:]
elif self.backend == "albumentations":
height, width = data[key].shape[-3:-1]
else:
raise ValueError(f"Backend {self.backend} not supported")
assert (
height == self.height and width == self.width
), f"Video {key} has invalid shape {height, width}, expected {self.height, self.width}"
class VideoRandomErasing(VideoTransform):
"""Adds random rectangles overlaying the video.
This discourages overfitting to the background.
"""
probability: float = Field(default=0.2, description="Probability of applying the transform")
scale: tuple[float, float] = Field(default=(0.02, 0.33), description="Scale of the rectangle")
ratio: tuple[float, float] = Field(
default=(0.3, 3.3), description="Aspect ratio of the rectangle"
)
value: Literal["random"] | tuple[float, float, float] = Field(
default="random", description="Color to fill the erased region with"
)
def get_transform(self, mode: Literal["train", "eval"] = "train") -> Callable | None:
"""Get the transform for the given mode.
Args:
mode (Literal["train", "eval"]): The mode to get the transform for.
Returns:
Callable: If mode is "train", return a transform that adds random rectangles. If mode is "eval", return a no-op.
"""
if mode == "eval":
return None
if self.backend == "torchvision":
return T.RandomErasing(
p=self.probability, scale=self.scale, ratio=self.ratio, value=self.value
)
elif self.backend == "albumentations":
return A.Erasing(
p=self.probability, scale=self.scale, ratio=self.ratio, value=self.value
)
else:
raise ValueError(f"Backend {self.backend} not supported")
class VideoResize(VideoTransform):
height: int = Field(..., description="The height of the resize")
width: int = Field(..., description="The width of the resize")
interpolation: str = Field(default="linear", description="The interpolation mode")
antialias: bool = Field(default=True, description="Whether to apply antialiasing")
@field_validator("interpolation")
def validate_interpolation(cls, v):
cls._validate_interpolation(v)
return v
def get_transform(self, mode: Literal["train", "eval"] = "train") -> Callable:
"""Get the resize transform. Same transform for both train and eval.
Args:
mode (Literal["train", "eval"]): The mode to get the transform for.
Returns:
Callable: The resize transform.
"""
interpolation = self._get_interpolation(self.interpolation, self.backend)
if interpolation is None:
raise ValueError(
f"Interpolation mode {self.interpolation} not supported for torchvision"
)
if self.backend == "torchvision":
size = (self.height, self.width)
return T.Resize(size, interpolation=interpolation, antialias=self.antialias)
elif self.backend == "albumentations":
return A.Resize(
height=self.height,
width=self.width,
interpolation=interpolation,
p=1,
)
else:
raise ValueError(f"Backend {self.backend} not supported")
class VideoRandomRotation(VideoTransform):
degrees: float | tuple[float, float] = Field(
..., description="The degrees of the random rotation"
)
interpolation: str = Field("linear", description="The interpolation mode")
@field_validator("interpolation")
def validate_interpolation(cls, v):
cls._validate_interpolation(v)
return v
def get_transform(self, mode: Literal["train", "eval"] = "train") -> Callable | None:
"""Get the random rotation transform, only used in train mode.
Args:
mode (Literal["train", "eval"]): The mode to get the transform for.
Returns:
Callable | None: The random rotation transform. None for eval mode.
"""
if mode == "eval":
return None
interpolation = self._get_interpolation(self.interpolation, self.backend)
if interpolation is None:
raise ValueError(
f"Interpolation mode {self.interpolation} not supported for torchvision"
)
if self.backend == "torchvision":
return T.RandomRotation(self.degrees, interpolation=interpolation) # type: ignore
elif self.backend == "albumentations":
return A.Rotate(limit=self.degrees, interpolation=interpolation, p=1)
else:
raise ValueError(f"Backend {self.backend} not supported")
class VideoHorizontalFlip(VideoTransform):
p: float = Field(..., description="The probability of the horizontal flip")
def get_transform(self, mode: Literal["train", "eval"] = "train") -> Callable | None:
"""Get the horizontal flip transform, only used in train mode.
Args:
mode (Literal["train", "eval"]): The mode to get the transform for.
Returns:
Callable | None: If mode is "train", return a horizontal flip transform. If mode is "eval", return None.
"""
if mode == "eval":
return None
if self.backend == "torchvision":
return T.RandomHorizontalFlip(self.p)
elif self.backend == "albumentations":
return A.HorizontalFlip(p=self.p)
else:
raise ValueError(f"Backend {self.backend} not supported")
class VideoGrayscale(VideoTransform):
p: float = Field(..., description="The probability of the grayscale transformation")
def get_transform(self, mode: Literal["train", "eval"] = "train") -> Callable | None:
"""Get the grayscale transform, only used in train mode.
Args:
mode (Literal["train", "eval"]): The mode to get the transform for.
Returns:
Callable | None: If mode is "train", return a grayscale transform. If mode is "eval", return None.
"""
if mode == "eval":
return None
if self.backend == "torchvision":
return T.RandomGrayscale(self.p)
elif self.backend == "albumentations":
return A.ToGray(p=self.p)
else:
raise ValueError(f"Backend {self.backend} not supported")
class VideoColorJitter(VideoTransform):
brightness: float | tuple[float, float] = Field(
..., description="The brightness of the color jitter"
)
contrast: float | tuple[float, float] = Field(
..., description="The contrast of the color jitter"
)
saturation: float | tuple[float, float] = Field(
..., description="The saturation of the color jitter"
)
hue: float | tuple[float, float] = Field(..., description="The hue of the color jitter")
def get_transform(self, mode: Literal["train", "eval"] = "train") -> Callable | None:
"""Get the color jitter transform, only used in train mode.
Args:
mode (Literal["train", "eval"]): The mode to get the transform for.
Returns:
Callable | None: If mode is "train", return a color jitter transform. If mode is "eval", return None.
"""
if mode == "eval":
return None
if self.backend == "torchvision":
return T.ColorJitter(
brightness=self.brightness,
contrast=self.contrast,
saturation=self.saturation,
hue=self.hue,
)
elif self.backend == "albumentations":
return A.ColorJitter(
brightness=self.brightness,
contrast=self.contrast,
saturation=self.saturation,
hue=self.hue,
p=1,
)
else:
raise ValueError(f"Backend {self.backend} not supported")
class VideoRandomGrayscale(VideoTransform):
p: float = Field(..., description="The probability of the grayscale transformation")
def get_transform(self, mode: Literal["train", "eval"] = "train") -> Callable | None:
"""Get the grayscale transform, only used in train mode.
Args:
mode (Literal["train", "eval"]): The mode to get the transform for.
Returns:
Callable | None: If mode is "train", return a grayscale transform. If mode is "eval", return None.
"""
if mode == "eval":
return None
if self.backend == "torchvision":
return T.RandomGrayscale(self.p)
elif self.backend == "albumentations":
return A.ToGray(p=self.p)
else:
raise ValueError(f"Backend {self.backend} not supported")
class VideoRandomPosterize(VideoTransform):
bits: int = Field(..., description="The number of bits to posterize the image")
p: float = Field(..., description="The probability of the posterize transformation")
def get_transform(self, mode: Literal["train", "eval"] = "train") -> Callable | None:
"""Get the posterize transform, only used in train mode.
Args:
mode (Literal["train", "eval"]): The mode to get the transform for.
Returns:
Callable | None: If mode is "train", return a posterize transform. If mode is "eval", return None.
"""
if mode == "eval":
return None
if self.backend == "torchvision":
return T.RandomPosterize(bits=self.bits, p=self.p)
elif self.backend == "albumentations":
return A.Posterize(num_bits=self.bits, p=self.p)
else:
raise ValueError(f"Backend {self.backend} not supported")
class VideoToTensor(VideoTransform):
output_on_cuda: bool = Field(
default=False,
description="Output the tensor on CUDA if True.",
)
def get_transform(self, mode: Literal["train", "eval"] = "train") -> Callable:
"""Get the to tensor transform. Same transform for both train and eval.
Args:
mode (Literal["train", "eval"]): The mode to get the transform for.
Returns:
Callable: The to tensor transform.
"""
if self.backend == "torchvision":
return functools.partial(
self.__class__.to_tensor,
output_on_cuda=self.output_on_cuda,
)
else:
raise ValueError(f"Backend {self.backend} not supported")
def check_input(self, data: dict):
"""Check if the input data has the correct shape.
Expected video shape: [T, H, W, C], dtype np.uint8
"""
for key in self.apply_to:
assert key in data, f"Key {key} not found in data. Available keys: {data.keys()}"
assert data[key].ndim in [
4,
5,
], f"Video {key} must have 4 or 5 dimensions, got {data[key].ndim}"
assert (
data[key].dtype == np.uint8
), f"Video {key} must have dtype uint8, got {data[key].dtype}"
input_resolution = data[key].shape[-3:-1][::-1]
if key in self.original_resolutions:
expected_resolution = self.original_resolutions[key]
else:
expected_resolution = input_resolution
assert (
input_resolution == expected_resolution
), f"Video {key} has invalid resolution {input_resolution}, expected {expected_resolution}. Full shape: {data[key].shape}"
@staticmethod
def to_tensor(frames: np.ndarray, output_on_cuda: bool) -> torch.Tensor:
"""Convert numpy array to tensor efficiently.
Args:
frames: numpy array of shape [T, H, W, C] in uint8 format
output_on_cuda: whether to output the tensor on CUDA if True.
Returns:
tensor of shape [T, C, H, W] in range [0, 1]
"""
frames = torch.from_numpy(frames)
if output_on_cuda:
frames = frames.cuda()
frames = frames.to(torch.float32) / 255.0
return frames.permute(0, 3, 1, 2) # [T, C, H, W]
class VideoToNumpy(VideoTransform):
def get_transform(self, mode: Literal["train", "eval"] = "train") -> Callable:
"""Get the to numpy transform. Same transform for both train and eval.
Args:
mode (Literal["train", "eval"]): The mode to get the transform for.
Returns:
Callable: The to numpy transform.
"""
if self.backend == "torchvision":
return self.__class__.to_numpy
else:
raise ValueError(f"Backend {self.backend} not supported")
@staticmethod
def to_numpy(frames: torch.Tensor) -> np.ndarray:
"""Convert tensor back to numpy array efficiently.
Args:
frames: tensor of shape [T, C, H, W] in range [0, 1]
Returns:
numpy array of shape [T, H, W, C] in uint8 format
"""
frames = (frames.permute(0, 2, 3, 1) * 255).to(torch.uint8)
return frames.cpu().numpy()
class VideoMergeTimeBatch(ModalityTransform):
"""
Merge the batch and time dimensions of the video.
"""
apply_to: list[str] = Field(..., description="The keys of the modalities to merge")
def apply(self, data: dict) -> dict:
warnings.warn(
"VideoMergeTimeBatch is deprecated. Use ComposedModalityTransform instead.",
DeprecationWarning,
)
for key in self.apply_to:
data[key] = rearrange(data[key], "b t ... -> (b t) ...")
return data
class VideoSplitTimeBatch(ModalityTransform):
"""
Split the batch and time dimensions of the video.
"""
apply_to: list[str] = Field(..., description="The keys of the modalities to split")
time_dim: int = Field(..., description="The dimension of the time dimension")
def apply(self, data: dict) -> dict:
warnings.warn(
"VideoSplitTimeBatch is deprecated. Use ComposedModalityTransform instead.",
DeprecationWarning,
)
for key in self.apply_to:
data[key] = rearrange(data[key], "(b t) ... -> b t ...", t=self.time_dim)
return data
class VideoFocusRect(ModalityTransform):
"""
Given a rectangle area in the video, apply focus effects on the target
rectangle, by applying blur and noise to the surrounding region.
Mainly useful for EgoView
"""
# Region coordinates in normalized space [0,1]
xtl: float = Field(2 / 12, description="Top-left x coordinate (normalized)", ge=0.0, le=1.0)
ytl: float = Field(3 / 8, description="Top-left y coordinate (normalized)", ge=0.0, le=1.0)
xbr: float = Field(10 / 12, description="Bottom-left x coordinate (normalized)", ge=0.0, le=1.0)
ybr: float = Field(1.0, description="Bottom-left y coordinate (normalized)", ge=0.0, le=1.0)
# Content region parameters (in pixel coordinates, None means auto-detect)
content_y_min: Optional[int] = Field(
None, description="Top coordinate of content region (pixels)"
)
content_y_max: Optional[int] = Field(
None, description="Bottom coordinate of content region (pixels)"
)
content_x_min: Optional[int] = Field(
None, description="Left coordinate of content region (pixels)"
)
content_x_max: Optional[int] = Field(
None, description="Right coordinate of content region (pixels)"
)
# Jitter amount for coordinates (in normalized space)
jitter: float = Field(0.05, description="Amount of random jitter to apply to coordinates")
# Effect parameters
blur_kernel: int = Field(95, description="Gaussian blur kernel size")
noise_std: float = Field(0.3, description="Standard deviation of Gaussian noise")
blend_size: float = Field(0.1, description="Size of blending region as fraction of image size")
# Effect probabilities during training
p_blur: float = Field(0.2, description="Probability of applying blur")
p_noise: float = Field(0.2, description="Probability of applying noise")
def detect_padding(self, image: np.ndarray) -> tuple[slice, slice]:
"""
Detect padding in the image by finding non-black regions.
Returns slices for the content region (y_slice, x_slice).
"""
H, W = image.shape[:2]
# If all content region parameters are provided, use them
if all(
param is not None
for param in [
self.content_y_min,
self.content_y_max,
self.content_x_min,
self.content_x_max,
]
):
y_min = max(0, self.content_y_min)
y_max = min(H, self.content_y_max)
x_min = max(0, self.content_x_min)
x_max = min(W, self.content_x_max)
return slice(y_min, y_max), slice(x_min, x_max)
if image.ndim == 3:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
else:
gray = image
# Find non-black regions
rows = np.any(gray > 0.01, axis=1)
cols = np.any(gray > 0.01, axis=0)
# find first and last non-black pixel indices
rmin, rmax = np.where(rows)[0][[0, -1]]
cmin, cmax = np.where(cols)[0][[0, -1]]
return slice(rmin, rmax + 1), slice(cmin, cmax + 1)
def apply(self, data: dict[str, Any]) -> dict[str, Any]:
if not self.training:
# Do nothing in eval mode
return data
for key in self.apply_to:
video = data[key]
# Handle numpy array case
assert isinstance(
video, np.ndarray
), f"Expected numpy array or torch tensor for {key}, got {type(video)}"
assert video.ndim in {
3,
4,
}, f"Expected [H, W, C] or [T, H, W, C] array for {key}, got shape {video.shape}"
transformed = self._transform_video(video)
data[key] = transformed
return data
def _transform_video(self, video: np.ndarray) -> np.ndarray:
"""
Apply the focus rectangle transformation to a video.
Args:
video (np.ndarray): Video tensor of shape [T, H, W, C]
Returns:
np.ndarray: Transformed video
"""
# Handle both single frame and video inputs
is_single_frame = video.ndim == 3
if is_single_frame:
video = video[np.newaxis]
T, H, W, C = video.shape
assert (
self.p_blur + self.p_noise <= 1.0
), "Sum of blur and noise probabilities must be <= 1.0"
r = np.random.random()
apply_blur = r < self.p_blur
apply_noise = self.p_blur <= r < self.p_blur + self.p_noise
alpha = random.uniform(0.0, 1.0) # Noise blending factor
# Apply jitter once to rectangle
xtl = self.xtl + np.random.uniform(-self.jitter, self.jitter)
ytl = self.ytl + np.random.uniform(-self.jitter, self.jitter)
xbr = self.xbr + np.random.uniform(-self.jitter, self.jitter)
ybr = self.ybr + np.random.uniform(-self.jitter, self.jitter)
xtl, ytl, xbr, ybr = [np.clip(x, 0.0, 1.0) for x in [xtl, ytl, xbr, ybr]]
# Detect padding from first frame (assume consistent across frames)
y_slice, x_slice = self.detect_padding(video[0])
content_h = y_slice.stop - y_slice.start
content_w = x_slice.stop - x_slice.start
# Convert normalized coordinates relative to pixel space
x1 = int(xtl * content_w) + x_slice.start
y1 = int(ytl * content_h) + y_slice.start
x2 = int(xbr * content_w) + x_slice.start
y2 = int(ybr * content_h) + y_slice.start
# Create mask for the inner rectangle
mask = np.zeros((H, W), dtype=np.float32)
pts = np.array([[x1, y1], [x2, y1], [x2, y2], [x1, y2]], dtype=np.int32)
cv2.fillPoly(mask, [pts], color=1.0)
# Create a smooth blend mask around the target rectangle using distance transform
content_mask = np.zeros((H, W), dtype=np.uint8)
content_mask[y_slice, x_slice] = 1
dist = cv2.distanceTransform(1 - (mask > 0).astype(np.uint8) * content_mask, cv2.DIST_L2, 3)
blend_radius = int(min(content_h, content_w) * self.blend_size)
blend_mask = np.clip(1.0 - dist / blend_radius, 0, 1)
blend_mask *= content_mask
blend_mask = blend_mask[..., np.newaxis]
# Process all frames with same transformations
result = np.zeros_like(video)
for t in range(T):
frame = video[t]
modified = frame.copy()
content = modified[y_slice, x_slice]
if apply_blur:
content = cv2.GaussianBlur(content, (self.blur_kernel, self.blur_kernel), 0)
if apply_noise:
background = np.random.randint(0, 256, content.shape, dtype=np.uint8) / 255.0
content = alpha * content + (1 - alpha) * background
content = np.clip(content, 0, 1)
modified[y_slice, x_slice] = content
result[t] = frame * blend_mask + modified * (1 - blend_mask)
return result[0] if is_single_frame else result
class VideoNormalize(VideoTransform):
mean: list[float] = Field(..., description="Mean for normalization")
std: list[float] = Field(..., description="Standard deviation for normalization")
def get_transform(self, mode: Literal["train", "eval"] = "train") -> Callable:
"""Get the normalization transform. Same for train and eval mode.
Args:
mode (Literal["train", "eval"]): The mode to get the transform for.
Returns:
Callable: The normalization transform.
"""
print("Using VideoNormalize transform")
if self.backend == "torchvision":
return T.Normalize(mean=self.mean, std=self.std)
elif self.backend == "albumentations":
return A.Normalize(mean=self.mean, std=self.std, max_pixel_value=1.0, p=1.0)
else:
raise ValueError(f"Backend {self.backend} not supported")
def check_input(self, data: dict):
for key in self.apply_to:
assert key in data, f"Key {key} not found in data"
assert isinstance(data[key], torch.Tensor), f"Video {key} is not a torch tensor"
assert data[key].ndim in [4, 5], f"Video {key} must have 4 or 5 dimensions, got {data[key].ndim}"
assert data[key].dtype == torch.float32, f"Video {key} must be float32, got {data[key].dtype}"
assert data[key].min() >= 0.0 and data[key].max() <= 1.0, (
f"Video {key} must be in [0,1] range before normalization"
)
# class VideoTransformLegacy(ModalityTransform):
# def __init__(
# self,
# modality_keys: list[str],
# backend: str = "torchvision",
# crop_cfg: CropConfig | None = None,
# resize_cfg: ResizeConfig | None = None,
# random_rotation_cfg: RandomRotationConfig | None = None,
# horizontal_flip_cfg: HorizontalFlipConfig | None = None,
# grayscale_cfg: GrayscaleConfig | None = None,
# color_jitter_cfg: ColorJitterConfig | None = None,
# strong_vision_aug: bool = False,
# ):
# """
# Initialize the video transform.
# With the default settings, the input will be (T, H, W, C) where T is the number of frames.
# The output will be (K, T, C, H, W) where K is the number of video keys.
# Args:
# modality_keys (list[str]): The keys of the modalities to load and transform.
# backend (str): The backend to use for the transformations. The default is "torchvision".
# crop_cfg (CropConfig | None): Configuration for the crop transformation. See CropConfig for more details.
# resize_cfg (ResizeConfig | None): Configuration for the resize transformation. See ResizeConfig for more details.
# random_rotation_cfg (RandomRotationConfig | None): Configuration for the random rotation transformation. See RandomRotationConfig for more details.
# horizontal_flip_cfg (HorizontalFlipConfig | None): Configuration for the horizontal flip transformation. See HorizontalFlipConfig for more details.
# grayscale_cfg (GrayscaleConfig | None): Configuration for the grayscale transformation. See GrayscaleConfig for more details.
# color_jitter_cfg (ColorJitterConfig | None): Configuration for the color jitter transformation. See ColorJitterConfig for more details.
# strong_vision_aug (bool): Whether to apply strong vision augmentation. The default is False.
# """
# super().__init__(modality_keys)
# self.backend = backend
# self.crop_cfg = crop_cfg
# self.resize_cfg = resize_cfg
# self.random_rotation_cfg = random_rotation_cfg
# self.horizontal_flip_cfg = horizontal_flip_cfg
# self.grayscale_cfg = grayscale_cfg
# self.color_jitter_cfg = color_jitter_cfg
# self.strong_vision_aug = strong_vision_aug
# self.transforms = None
# def set_metadata(self, dataset_metadata: TrainableDatasetMetadata_V1_1):
# super().set_metadata(dataset_metadata)
# # Get the original height and width
# video_metadata = dataset_metadata.modalities.video
# resolutions = {}
# for key in self.modality_keys:
# split_keys = key.split(".")
# assert len(split_keys) == 2, f"Invalid key: {key}. Expected format: modality.key"
# sub_key = split_keys[1]
# resolutions[key] = video_metadata[sub_key].resolution
# assert (
# len(set(resolutions.values())) == 1
# ), f"All video keys must have the same resolution, got: {resolutions}"
# width, height = resolutions[self.modality_keys[0]]
# transforms = []
# if self.crop_cfg is not None:
# self.crop_cfg.set_original_height_width(height, width)
# transforms.append(self.crop_cfg.get_transform(self.backend))
# if self.resize_cfg is not None:
# transforms.append(self.resize_cfg.get_transform(self.backend))
# if self.random_rotation_cfg is not None:
# transforms.append(self.random_rotation_cfg.get_transform(self.backend))
# if self.horizontal_flip_cfg is not None:
# transforms.append(self.horizontal_flip_cfg.get_transform(self.backend))
# if self.grayscale_cfg is not None:
# transforms.append(self.grayscale_cfg.get_transform(self.backend))
# if self.color_jitter_cfg is not None:
# transforms.append(self.color_jitter_cfg.get_transform(self.backend))
# if self.backend == "torchvision":
# if len(transforms) == 0:
# transforms.append(T.Identity())
# self.transforms = T.Compose(transforms)
# else:
# raise ValueError(f"Backend {self.backend} not supported")
# if self.strong_vision_aug:
# import kornia.augmentation as K
# from kornia.augmentation import ImageSequential
# assert (
# self.backend == "torchvision"
# ), "Temporarily only support torchvision backend for strong augmentation"
# self.strong_transform = ImageSequential(
# K.RandomErasing(p=0.5, scale=(0.005, 0.01), ratio=(0.3, 1.3)),
# K.RandomSaltAndPepperNoise(p=0.5, amount=0.05, salt_vs_pepper=0.5),
# K.RandomCutMixV2(p=0.5, num_mix=1, cut_size=(0.98, 1.0)),
# random_apply=1,
# keepdim=True,
# same_on_batch=True,
# )
# def __call__(self, data: dict) -> dict[str, torch.Tensor | np.ndarray | Image.Image]:
# # Batch frames along the first dimension
# frames = [data[key] for key in self.modality_keys] # view x [T, H, W, C]
# n_view, n_frames = len(frames), len(frames[0])
# frames = np.concatenate(frames, 0) # [view*T, H, W, C]
# if self.backend == "torchvision":
# transformed_frames = self.transform_torchvision(frames)
# else:
# raise ValueError(f"Backend {self.backend} not supported")
# # De-batch the frames
# transformed_frames = np.array(transformed_frames) # [view*T, H, W, C]
# H, W, C = transformed_frames.shape[-3:]
# transformed_frames = {
# key: x
# for key, x in zip(
# self.modality_keys, transformed_frames.reshape(n_view, n_frames, H, W, C)
# )
# }
# return transformed_frames
# def check_input(self, data: dict):
# for key in self.modality_keys:
# assert key in data, f"Key {key} not found in data"
# video = data[key]
# assert isinstance(video, np.ndarray), f"Video {key} is not a numpy array"
# assert video.ndim == 4, f"Video {key} must have 4 dimensions, got {video.ndim}"
# assert video.dtype == np.uint8, f"Video {key} must have dtype uint8, got {video.dtype}"
# shape = video.shape
# expected_resolution = self.dataset_metadata.modalities.video[key].resolution
# assert (
# shape[1:3] == expected_resolution
# ), f"Video {key} has invalid shape {shape}, expected {expected_resolution}"
# def transform_torchvision(
# self, frames: np.ndarray
# ) -> list[torch.Tensor | np.ndarray | Image.Image]:
# """
# frames: [view * T, H, W, C], np.uint8
# """
# if self.transforms is None:
# raise ValueError(
# "Transform is not set. Please call set_metadata() before calling __call__()"
# )
# # Convert to batched tensor, using ToTensor() is too slow
# frames_tensor = torch.from_numpy(frames).to(torch.float32) / 255.0
# frames_tensor = frames_tensor.permute(0, 3, 1, 2) # [view * T, C, H, W]
# transformed_frames = self.transforms(frames_tensor)
# if self.strong_vision_aug:
# transformed_frames = self.strong_transform(transformed_frames)
# to_pil = T.ToPILImage()
# transformed_frames = [to_pil(frame) for frame in transformed_frames]
# return transformed_frames # type: ignore
# class IdentityTransform(ModalityTransform):
# def __call__(self, data: dict) -> dict: # type: ignore
# warnings.warn("IdentityTransform is used, further transformations is required.")
# output = {}
# for key in self.modality_keys:
# output[key] = data[key]
# return output