"""Augmentation module for transforming samples in a dataset.
This module provides data augmentation and preprocessing transforms for pose tracking
datasets stored as xarray.Dataset objects or canonical NumPy arrays. NumPy pose arrays
must have shape ``(time, individuals, keypoints, space)``. The transforms can be used
in training pipelines to improve model robustness and generalization.
Available Transforms
--------------------
GaussianJitter
Adds independent Gaussian noise to every pose coordinate across the full time
window and clamps the result to the normalized [0, 1] range.
RandomPermutation
Randomly permutes both coordinate labels and their associated data together across
the entire time window. Useful for making models invariant to coordinate ordering
(e.g., individual identities, spatial axes).
RandomBlockPermutation
Randomly permutes data within a contiguous block of frames while keeping coordinate
labels unchanged. Creates temporal identity confusion within part of the window.
Useful for more challenging augmentation scenarios.
RandomRotation
Applies a random rotation to keypoint coordinates in normalized [0, 1] space.
Supports 2D and 3D keypoints with configurable maximum angle and post-rotation
normalization modes (truncate, rescale, or none).
KeypointAblation
Independently selects (keypoint, individual) pairs and sets their coordinates to
0.0 across the full time window. Simulates sustained missing or occluded keypoints
for robustness testing.
PoseToTensor
Converts xarray or canonical NumPy pose tracking data to PyTorch tensors by
stacking spatial dimensions into a single feature dimension.
PoseToVideo
Renders pose tracking data as video frames (RGB images) using OpenCV, with
customizable body specifications for visualization. Coordinate labels are
required, so this transform is xarray-only.
VideoToTensor
Converts video frames from NumPy arrays to PyTorch tensors with optional
normalization for video model inputs.
Usage Examples
--------------
>>> from lisbet.transforms_extra import RandomPermutation, PoseToTensor
>>> from torchvision import transforms
>>>
>>> # Simple augmentation pipeline
>>> transform = transforms.Compose([
... RandomPermutation(seed=42, coordinate='individuals'),
... PoseToTensor(),
... ])
>>>
>>> # Apply with probability using torchvision.transforms.RandomApply
>>> transform = transforms.Compose([
... transforms.RandomApply([
... RandomPermutation(seed=42, coordinate='individuals')
... ], p=0.5),
... PoseToTensor(),
... ])
>>>
>>> # Block permutation for temporal identity confusion
>>> from lisbet.transforms_extra import RandomBlockPermutation
>>> transform = transforms.Compose([
... RandomBlockPermutation(
... seed=42, coordinate='individuals', permute_fraction=0.3
... ),
... PoseToTensor(),
... ])
>>>
>>> # Keypoint ablation for robustness to missing data
>>> from lisbet.transforms_extra import KeypointAblation
>>> transform = transforms.Compose([
... transforms.RandomApply([
... KeypointAblation(seed=42, pB=0.05)
... ], p=1.0),
... PoseToTensor(),
... ])
>>>
>>> # Random rotation augmentation for spatial invariance
>>> from lisbet.transforms_extra import RandomRotation
>>> transform = transforms.Compose([
... RandomRotation(seed=42, max_angle=30.0, mode='truncate'),
... PoseToTensor(),
... ])
Notes
-----
- Augmentations should be applied thoughtfully based on dataset characteristics
- Spatial axis permutation (coordinate='space') is only suitable for top-down view
datasets where axes are symmetric
- Identity permutations work best for datasets where individual labels are
interchangeable
"""
import cv2
import numpy as np
import torch
import xarray as xr
from lisbet.drawing import BodySpecs, body_specs_registry, color_to_bgr
_CANONICAL_DIMS = ("time", "individuals", "keypoints", "space")
_NUMPY_AXES = {dim: axis for axis, dim in enumerate(_CANONICAL_DIMS)}
def _canonical_position(posetracks):
"""Return position values in canonical NumPy axis order."""
if isinstance(posetracks, np.ndarray):
if posetracks.ndim != 4:
raise ValueError(
"NumPy pose arrays must have shape "
"(time, individuals, keypoints, space)."
)
return posetracks
if not isinstance(posetracks, xr.Dataset):
raise TypeError("Pose transforms require an xarray Dataset or NumPy array.")
pos_var = posetracks["position"]
missing_dims = set(_CANONICAL_DIMS) - set(pos_var.dims)
if missing_dims:
raise ValueError(
f"Position variable must contain {set(_CANONICAL_DIMS)} dimensions. "
f"Missing: {missing_dims}"
)
return pos_var.transpose(*_CANONICAL_DIMS).values
def _restore_position(posetracks, position):
"""Return transformed values in the same container type as the input."""
if isinstance(posetracks, np.ndarray):
return np.ascontiguousarray(position, dtype=posetracks.dtype)
original_dims = posetracks["position"].dims
axes = tuple(_NUMPY_AXES[dim] for dim in original_dims)
posetracks["position"].values[...] = np.transpose(position, axes)
return posetracks
def _random_permutation(n, generator, exclude_identity=False):
"""Generate a random permutation of n elements.
Parameters
----------
n : int
Number of elements to permute.
generator : torch.Generator
Random number generator.
exclude_identity : bool
If True, the identity permutation is excluded.
Returns
-------
list
A random permutation as a list of indices.
Raises
------
ValueError
If exclude_identity=True and n < 2.
"""
if exclude_identity and n < 2:
raise ValueError("Cannot exclude identity permutation for n < 2")
perm = torch.randperm(n, generator=generator).tolist()
if exclude_identity:
identity = list(range(n))
while perm == identity:
perm = torch.randperm(n, generator=generator).tolist()
return perm
[docs]
class GaussianJitter:
"""Apply Gaussian jitter across the full window.
Gaussian noise drawn from N(0, sigma^2) is added independently to every
coordinate. Coordinates are assumed normalized in [0, 1] and are clamped to that
range post-perturbation.
Parameters
----------
seed : int
RNG seed for reproducibility.
sigma : float
Standard deviation of the Gaussian noise.
"""
[docs]
def __init__(self, seed: int, sigma: float):
self.seed = seed
self.sigma = float(sigma)
self.g = torch.Generator().manual_seed(seed)
def __call__(self, posetracks):
position = _canonical_position(posetracks)
# Draw in movement's xarray storage order (time, space, keypoints,
# individuals), then transpose to canonical order. This preserves the exact
# seeded output of the pre-NumPy implementation for this minor release.
# TODO: In the next major release, replace the legacy_shape/noise block with
# ``noise = torch.randn(position.shape, generator=self.g) * self.sigma``
# and document the resulting reproducibility change.
legacy_shape = (
position.shape[0],
position.shape[3],
position.shape[2],
position.shape[1],
)
noise = (torch.randn(legacy_shape, generator=self.g) * self.sigma).permute(
0, 3, 2, 1
)
transformed = torch.from_numpy(np.ascontiguousarray(position)) + noise
transformed.clamp_(0.0, 1.0)
return _restore_position(posetracks, transformed.numpy())
[docs]
class KeypointAblation:
"""Apply keypoint ablation with per-(keypoint, individual) Bernoulli sampling.
Probability ``pB`` is applied independently to each (keypoint, individual) pair.
For every selected pair, all spatial coordinates (x, y, z, etc.) are set to zero
across the entire time window, simulating sustained missing or occluded keypoints.
This augmentation helps models become robust to missing data, which commonly occurs
due to occlusions, tracking failures, or low-confidence detections.
Parameters
----------
seed : int
RNG seed for reproducibility.
pB : float
Bernoulli probability for each (keypoint, individual) pair across the full
window.
Examples
--------
>>> from lisbet.transforms_extra import KeypointAblation
>>> ablation = KeypointAblation(seed=42, pB=0.05)
>>> ablated_ds = ablation(posetracks)
"""
[docs]
def __init__(self, seed: int, pB: float):
self.seed = seed
self.pB = float(pB)
self.g = torch.Generator().manual_seed(seed)
def __call__(self, posetracks):
position = _canonical_position(posetracks)
# Draw in movement's xarray mask layout (time, space, keypoints,
# individuals), then transpose to canonical order. This preserves the exact
# seeded output of the pre-NumPy implementation for this minor release.
# TODO: In the next major release, replace the legacy_mask_shape/bern block
# with:
# bern = torch.rand(
# (1, position.shape[1], position.shape[2], 1),
# generator=self.g,
# ) < self.pB
legacy_mask_shape = (1, 1, position.shape[2], position.shape[1])
bern = (torch.rand(legacy_mask_shape, generator=self.g) < self.pB).permute(
0, 3, 2, 1
)
pos = torch.from_numpy(np.ascontiguousarray(position))
transformed = torch.where(bern, torch.zeros((), dtype=pos.dtype), pos)
return _restore_position(posetracks, transformed.numpy())
[docs]
class RandomPermutation:
"""
Randomly permute the order of a specified pose coordinate.
For an xarray dataset, both the coordinate labels and their associated data are
reordered. For a NumPy array, ``coordinate`` identifies the corresponding axis in
the canonical ``(time, individuals, keypoints, space)`` layout; raw arrays have no
coordinate labels to reorder.
This augmentation can be used to increase invariance to coordinate order (e.g.,
fixed identity, axis orientation). The permutation is applied to the entire dataset.
Parameters
----------
seed : int
Random seed for reproducibility.
coordinate : str
Name of the coordinate to permute (e.g., 'individuals', 'keypoints', 'space').
For NumPy input, this must name an axis in the canonical pose layout.
exclude_identity : bool
If True, the identity permutation (no change) is excluded. This guarantees
that at least one element will be moved. Default is False.
Methods
-------
__call__(posetracks)
Apply the random permutation and return the same container type as the input.
Examples
--------
>>> permute = RandomPermutation(seed=42, coordinate='individuals')
>>> permuted_ds = permute(posetracks)
>>> # Guarantee a permutation occurs
>>> permute = RandomPermutation(seed=42, coordinate='space', exclude_identity=True)
>>> permuted_ds = permute(posetracks)
"""
[docs]
def __init__(self, seed, coordinate="individuals", exclude_identity=False):
self.seed = seed
self.coordinate = coordinate
self.exclude_identity = exclude_identity
self.g = torch.Generator().manual_seed(seed)
[docs]
def __call__(self, posetracks):
"""
Apply random permutation to the specified coordinate.
Parameters
----------
posetracks : xarray.Dataset or numpy.ndarray
Pose tracks represented by an xarray dataset with a ``position`` variable,
or by a NumPy array in canonical
``(time, individuals, keypoints, space)`` order.
Returns
-------
xarray.Dataset or numpy.ndarray
Pose tracks with the selected coordinate or canonical axis permuted. The
returned container type matches ``posetracks``.
"""
if isinstance(posetracks, np.ndarray):
_canonical_position(posetracks)
if self.coordinate not in _NUMPY_AXES:
raise ValueError(f"Unknown NumPy pose coordinate '{self.coordinate}'.")
coordinate_axis = _NUMPY_AXES[self.coordinate]
coordinate_size = posetracks.shape[coordinate_axis]
elif isinstance(posetracks, xr.Dataset):
coordinate_size = posetracks.coords[self.coordinate].size
else:
raise TypeError("Pose transforms require an xarray Dataset or NumPy array.")
# Generate a random permutation
perm = _random_permutation(coordinate_size, self.g, self.exclude_identity)
if isinstance(posetracks, np.ndarray):
return np.take(posetracks, perm, axis=coordinate_axis)
# For xarray, retain the public behavior of reordering coordinate labels and
# every variable that uses the selected coordinate.
return posetracks.isel({self.coordinate: perm})
[docs]
class RandomBlockPermutation:
"""
Randomly permutes the data (but not coordinate labels) of a specified coordinate
within a random contiguous block of frames.
For an xarray dataset, coordinate labels remain fixed while the associated pose
data are permuted inside the block. For a NumPy array, ``coordinate`` identifies
the corresponding axis in the canonical
``(time, individuals, keypoints, space)`` layout; raw arrays have no coordinate
labels.
This augmentation is useful to create identity swaps within a portion of the time
series, mimicking the effects of a tracking error, while maintaining consistent
coordinate labels throughout.
Parameters
----------
seed : int
Random seed for reproducibility.
coordinate : str
Name of the coordinate to permute (e.g., 'individuals', 'keypoints').
For NumPy input, this must name an axis in the canonical pose layout.
permute_fraction : float
Fraction of the time window to which the permutation is applied.
Must be in (0, 1]. A continuous block of frames of this relative size will be
selected at random, and the permutation will be applied only to the data
within this block, keeping coordinate labels unchanged.
exclude_identity : bool
If True, the identity permutation (no change) is excluded. This guarantees
that at least one element will be moved. Default is False.
Methods
-------
__call__(posetracks)
Apply the random block permutation and return the same container type as the
input.
Notes
-----
This implementation uses uniform frame probability sampling to ensure that every
frame in the window has an equal probability of being affected by the permutation,
regardless of its position. This is achieved by allowing the block's starting
position to extend beyond window boundaries, then clipping to the valid range.
As a consequence, the actual number of affected frames may be smaller than
``permute_fraction * window_size`` when the block overlaps with window boundaries.
On average, the expected probability for any given frame to be affected is::
block_size / (window_size + block_size - 1)
which simplifies to approximately ``permute_fraction / (1 + permute_fraction)``
for large windows. For example, with ``permute_fraction=0.3``, the expected
probability per frame is approximately 0.23 (about 77% of the nominal fraction).
Note that ``permute_fraction`` specifies the *nominal* block size, not the
expected fraction of affected frames. Even with ``permute_fraction=1.0``, the
expected probability per frame would be ~0.5, not 1.0, because the block can
"hang off" either edge of the window. This is the expected tradeoff for
achieving uniform frame probability.
Examples
--------
>>> permute = RandomBlockPermutation(seed=42, coordinate='individuals',
... permute_fraction=0.3)
>>> permuted_ds = permute(posetracks)
>>> # Guarantee a permutation occurs within the block
>>> permute = RandomBlockPermutation(seed=42, coordinate='individuals',
... permute_fraction=0.3, exclude_identity=True)
>>> permuted_ds = permute(posetracks)
"""
[docs]
def __init__(
self,
seed,
coordinate="individuals",
permute_fraction=0.5,
exclude_identity=False,
):
self.seed = seed
self.coordinate = coordinate
if not 0 < permute_fraction <= 1:
raise ValueError("permute_fraction must be a float in (0, 1].")
self.permute_fraction = permute_fraction
self.exclude_identity = exclude_identity
self.g = torch.Generator().manual_seed(seed)
[docs]
def __call__(self, posetracks):
"""
Apply random block permutation to the specified coordinate.
Parameters
----------
posetracks : xarray.Dataset or numpy.ndarray
Pose tracks represented by an xarray dataset with a ``position`` variable,
or by a NumPy array in canonical
``(time, individuals, keypoints, space)`` order.
Returns
-------
xarray.Dataset or numpy.ndarray
Pose tracks with data permuted inside a random block. The returned
container type matches ``posetracks``; xarray coordinates remain
unchanged.
"""
if isinstance(posetracks, xr.Dataset):
return self._apply_xarray(posetracks)
if isinstance(posetracks, np.ndarray):
return self._apply_numpy(posetracks)
raise TypeError("Pose transforms require an xarray Dataset or NumPy array.")
def _apply_xarray(self, posetracks):
"""Apply the legacy xarray implementation without changing its semantics."""
# Get current coordinate values
coord_vals = list(posetracks.coords[self.coordinate].values)
# Generate a random permutation
perm = _random_permutation(len(coord_vals), self.g, self.exclude_identity)
window_size = posetracks.sizes["time"]
block_size = int(self.permute_fraction * window_size)
if block_size == 0:
# No permutation needed
return posetracks
# Sample start_idx from extended range to ensure uniform frame probability.
# Range: [1 - block_size, window_size - 1] gives each frame exactly
# block_size chances to be included in the block.
start_idx = torch.randint(
1 - block_size, window_size, (1,), generator=self.g
).item()
# Clip to valid range
actual_start = max(0, start_idx)
actual_end = min(window_size, start_idx + block_size)
# For block permutation, we permute only the data
# while keeping coordinates unchanged across the full time series
block_to_permute = posetracks.isel(time=slice(actual_start, actual_end))
# Get the dimension index for the coordinate
coord_dim = list(posetracks["position"].dims).index(self.coordinate)
# Permute the data along the coordinate dimension
permuted_data = np.take(
block_to_permute["position"].values, perm, axis=coord_dim
)
# Create a new block with permuted data but original coordinates
permuted_block = block_to_permute.copy(deep=True)
permuted_block["position"].values[:] = permuted_data
# Split and concatenate
before_block = posetracks.isel(time=slice(None, actual_start))
after_block = posetracks.isel(time=slice(actual_end, None))
posetracks = xr.concat(
[before_block, permuted_block, after_block], dim="time", join="outer"
)
return posetracks
def _apply_numpy(self, posetracks):
"""Apply a block permutation to a canonical NumPy pose array."""
position = _canonical_position(posetracks)
if self.coordinate not in _NUMPY_AXES:
raise ValueError(f"Unknown pose coordinate '{self.coordinate}'.")
coordinate_axis = _NUMPY_AXES[self.coordinate]
# Generate a random permutation
perm = _random_permutation(
position.shape[coordinate_axis], self.g, self.exclude_identity
)
window_size = position.shape[0]
block_size = int(self.permute_fraction * window_size)
if block_size == 0:
return _restore_position(posetracks, position)
# Sample start_idx from extended range to ensure uniform frame probability.
# Range: [1 - block_size, window_size - 1] gives each frame exactly
# block_size chances to be included in the block.
start_idx = torch.randint(
1 - block_size, window_size, (1,), generator=self.g
).item()
# Clip to valid range
actual_start = max(0, start_idx)
actual_end = min(window_size, start_idx + block_size)
transformed = np.array(position, copy=True)
transformed[actual_start:actual_end] = np.take(
position[actual_start:actual_end], perm, axis=coordinate_axis
)
return _restore_position(posetracks, transformed)
[docs]
class RandomRotation:
"""Apply a random rotation to keypoint coordinates in normalized [0, 1] space.
The input may be an xarray dataset or a NumPy array in canonical
``(time, individuals, keypoints, space)`` order. The returned container type
matches the input.
Samples a rotation angle uniformly from [-max_angle, +max_angle] and applies it
consistently across all frames in the window. For 2D data, rotates around the
center (0.5, 0.5). For 3D data, rotates around (0.5, 0.5, 0.5) about a randomly
sampled unit axis using Rodrigues' formula.
After rotation, coordinates can be normalized back to [0, 1] using one of three
modes: ``"truncate"`` (clamp), ``"rescale"`` (min-max rescaling per spatial
dimension), or ``"none"`` (no normalization).
Note: input data is assumed to be free of NaN values. NaN values are replaced
with 0.0 at load time (see ``lisbet.io.core._load_posetracks``).
Parameters
----------
seed : int
RNG seed for reproducibility.
max_angle : float
Maximum rotation angle in degrees. The angle is sampled uniformly from
[-max_angle, +max_angle]. Default is 180.0.
mode : str
Normalization mode after rotation. One of:
- ``"truncate"``: Clamp coordinates to [0, 1].
- ``"rescale"``: If any coordinate falls outside [0, 1] after rotation,
rescale each spatial dimension independently so that the min maps to 0
and the max maps to 1 (across all keypoints, individuals, and time).
If all coordinates are already within [0, 1], no rescaling is applied.
- ``"none"``: No normalization is applied.
Default is ``"truncate"``.
Examples
--------
>>> from lisbet.transforms_extra import RandomRotation
>>> rotation = RandomRotation(seed=42, max_angle=30.0)
>>> rotated_ds = rotation(posetracks)
>>> # Rescale mode for 3D data
>>> rotation = RandomRotation(seed=42, max_angle=45.0, mode='rescale')
>>> rotated_ds = rotation(posetracks)
"""
[docs]
def __init__(self, seed: int, max_angle: float = 180.0, mode: str = "truncate"):
valid_modes = ("truncate", "rescale", "none")
if mode not in valid_modes:
raise ValueError(f"mode must be one of {valid_modes}, got '{mode}'")
self.seed = seed
self.max_angle = float(max_angle)
self.mode = mode
self.g = torch.Generator().manual_seed(seed)
def __call__(self, posetracks):
"""
Apply random rotation to keypoint coordinates.
Parameters
----------
posetracks : xarray.Dataset or numpy.ndarray
Pose tracks represented by an xarray dataset with a ``position`` variable,
or by a NumPy array in canonical
``(time, individuals, keypoints, space)`` order.
Returns
-------
xarray.Dataset or numpy.ndarray
Pose tracks with rotated position coordinates. The returned container type
matches ``posetracks``.
Raises
------
ValueError
If the 'space' dimension has a size other than 2 or 3.
"""
position = _canonical_position(posetracks)
n_space = position.shape[3]
if n_space not in (2, 3):
raise ValueError(f"'space' dimension must have size 2 or 3, got {n_space}")
# Sample rotation angle uniformly from [-max_angle, +max_angle]
angle_deg = (
torch.rand(1, generator=self.g).item() * 2.0 - 1.0
) * self.max_angle
angle_rad = angle_deg * (np.pi / 180.0)
# Build rotation matrix
c, s = np.cos(angle_rad), np.sin(angle_rad)
if n_space == 2:
R = np.array([[c, -s], [s, c]])
else:
# 3D: sample a random unit axis uniformly on the unit sphere
axis = torch.randn(3, generator=self.g).numpy()
axis = axis / np.linalg.norm(axis)
# Rodrigues' rotation formula: R = I + sin(θ)K + (1 - cos(θ))K²
kx, ky, kz = axis
K = np.array([[0.0, -kz, ky], [kz, 0.0, -kx], [-ky, kx, 0.0]])
R = np.eye(3) + s * K + (1.0 - c) * (K @ K)
# Rotate around center of the [0, 1] space
pos = (position - 0.5) @ R.T + 0.5
# Apply normalization mode
if self.mode == "truncate":
np.clip(pos, 0.0, 1.0, out=pos)
elif self.mode == "rescale" and (np.any(pos < 0.0) or np.any(pos > 1.0)):
for s_i in range(n_space):
spatial_slice = pos[..., s_i]
vmin, vmax = spatial_slice.min(), spatial_slice.max()
if vmin != vmax:
pos[..., s_i] = (spatial_slice - vmin) / (vmax - vmin)
return _restore_position(posetracks, pos)
[docs]
class PoseToTensor:
"""
Convert xarray or canonical NumPy pose data into a PyTorch tensor.
This transformation stacks the 'individuals', 'keypoints', and 'space' dimensions
into a single 'features' dimension, resulting in a tensor of shape
(time, features), where features = individuals * keypoints * space.
Parameters
----------
None
Methods
-------
__call__(posetracks)
Stack the 'individuals', 'keypoints', and 'space' dimensions of the 'position'
variable and return as a PyTorch tensor.
Examples
--------
>>> tensor = PoseToTensor()(posetracks)
>>> tensor.shape
torch.Size([time, features])
"""
[docs]
def __call__(self, posetracks):
"""
Stack the 'individuals', 'keypoints', and 'space' dimensions of the 'position'
data and return them as a PyTorch tensor.
Parameters
----------
posetracks : xarray.Dataset or numpy.ndarray
Pose tracks dataset, or a NumPy array with canonical shape
``(time, individuals, keypoints, space)``.
Returns
-------
torch.Tensor
Tensor of shape (time, features), where features =
individuals * keypoints * space, containing the stacked position data.
"""
if isinstance(posetracks, np.ndarray):
_canonical_position(posetracks)
values = np.ascontiguousarray(
posetracks.reshape(posetracks.shape[0], -1), dtype=np.float32
)
return torch.from_numpy(values)
if not isinstance(posetracks, xr.Dataset):
raise TypeError("PoseToTensor requires an xarray Dataset or NumPy array.")
return torch.from_numpy(
posetracks.stack(
features=("individuals", "keypoints", "space")
).position.values.astype("float32")
)
[docs]
class PoseToVideo:
"""
Fast OpenCV-based transformation: posetracks (xarray.Dataset) to a sequence of RGB
images.
"""
[docs]
def __init__(
self,
body_specs: dict[str, BodySpecs],
image_size=(256, 256),
bg_color="black",
):
"""
Fast OpenCV-based transformation using BodySpecs for each individual.
Parameters
----------
body_specs : dict of str to BodySpecs
Dictionary mapping individual_name (or species) to BodySpecs.
image_size : tuple of int, optional
(width, height) of output frames. Default is (256, 256).
bg_color : tuple or str, optional
BGR tuple or color name/hex for background color (default is black).
"""
self.body_specs = body_specs
self.width, self.height = image_size
self.bg_color = color_to_bgr(bg_color)
def __call__(self, posetracks, show_progress=False):
if not isinstance(posetracks, xr.Dataset):
raise TypeError(
"PoseToVideo requires an xarray Dataset with coordinate labels."
)
frames = [
self.render_frame(posetracks, t) for t in range(posetracks.sizes["time"])
]
frames = np.stack(frames, axis=0)
# # Convert to PyTorch tensor
# frames = torch.Tensor(frames)
return frames
[docs]
def render_frame(self, posetracks, t_idx):
"""
Render a single frame of pose tracks as an RGB image.
Parameters
----------
posetracks : xarray.Dataset
The pose tracks dataset containing keypoints and individuals.
Must have a "position" variable with dimensions ("time", "individuals",
"keypoints", "space").
t_idx : int
The time index of the frame to render.
Returns
-------
frame : numpy.ndarray
The rendered frame as a (height, width, 3) uint8 RGB image.
"""
frame = np.full((self.height, self.width, 3), self.bg_color, dtype=np.uint8)
pos = (
posetracks["position"]
.isel(time=t_idx)
.transpose("individuals", "keypoints", "space")
.values
)
keypoints = list(posetracks.keypoints.values)
individuals = list(posetracks.individuals.values)
for ind_idx, ind_name in enumerate(individuals):
spec = self.body_specs.get(ind_name, body_specs_registry.get(ind_name))
if spec is None:
continue
# Draw polygons (with alpha blending)
for poly in spec.polygons:
pts = []
for kp in poly:
if kp in keypoints:
idx = keypoints.index(kp)
x, y = pos[ind_idx, idx, :]
# Skip if coordinates are NaN (ablated keypoints)
if not (np.isnan(x) or np.isnan(y)):
pts.append([int(x * self.width), int(y * self.height)])
if len(pts) >= 3:
pts_np = np.array([pts], dtype=np.int32)
overlay = frame.copy()
color = color_to_bgr(spec.polygon_color)
cv2.fillPoly(overlay, pts_np, color)
frame = cv2.addWeighted(
overlay, spec.polygon_alpha, frame, 1 - spec.polygon_alpha, 0
)
# Draw skeleton
for edge in spec.skeleton_edges:
if edge[0] in keypoints and edge[1] in keypoints:
idx1 = keypoints.index(edge[0])
idx2 = keypoints.index(edge[1])
x1, y1 = pos[ind_idx, idx1, :]
x2, y2 = pos[ind_idx, idx2, :]
# Skip if any coordinates are NaN (ablated keypoints)
if not (
np.isnan(x1) or np.isnan(y1) or np.isnan(x2) or np.isnan(y2)
):
color = color_to_bgr(spec.skeleton_color)
cv2.line(
frame,
(int(x1 * self.width), int(y1 * self.height)),
(int(x2 * self.width), int(y2 * self.height)),
color=color,
thickness=spec.skeleton_thickness,
lineType=cv2.LINE_AA,
)
# Draw keypoints
for k, kp in enumerate(keypoints):
x, y = pos[ind_idx, k, :]
# Skip if coordinates are NaN (ablated keypoints)
if not (np.isnan(x) or np.isnan(y)):
color = color_to_bgr(spec.get_keypoint_color(kp))
cv2.circle(
frame,
(int(x * self.width), int(y * self.height)),
spec.keypoint_size,
color=color,
thickness=-1,
lineType=cv2.LINE_AA,
)
# Convert a BGR frame (OpenCV) to RGB
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
return frame
[docs]
class VideoToTensor:
"""
Transform a video (NumPy RGB array) into a PyTorch tensor suitable for video models.
Converts (frames, H, W, 3) RGB uint8/float arrays to (frames, 3, H, W) float
tensors, with optional normalization and mean/std normalization.
Parameters
----------
normalize : bool, optional
If True, scale pixel values to [0, 1] (default: True).
mean : tuple or list or np.ndarray or torch.Tensor, optional
Per-channel mean for normalization (applied after scaling to [0, 1]).
If None, no mean subtraction is performed.
std : tuple or list or np.ndarray or torch.Tensor, optional
Per-channel std for normalization (applied after mean subtraction).
If None, no std division is performed.
dtype : torch.dtype, optional
Output tensor dtype (default: torch.float32).
"""
[docs]
def __init__(self, normalize=True, mean=None, std=None, dtype=torch.float32):
self.normalize = normalize
self.mean = mean
self.std = std
self.dtype = dtype
def __call__(self, video):
"""
Parameters
----------
video : np.ndarray
Video as (frames, H, W, 3) RGB, dtype uint8 or float.
Returns
-------
torch.Tensor
Video as (frames, 3, H, W), dtype as specified.
"""
if not isinstance(video, np.ndarray):
raise TypeError("Input video must be a numpy ndarray.")
if video.ndim != 4 or video.shape[-1] != 3:
raise ValueError("Input video must have shape (frames, H, W, 3) [RGB].")
# If uint8, convert to float32 for normalization
if video.dtype == np.uint8:
video = video.astype(np.float32)
if self.normalize:
video = video / 255.0
elif self.normalize:
# Assume already float, but ensure in [0, 1]
video = np.clip(video, 0.0, 1.0)
# Rearrange to (frames, 3, H, W)
video = np.transpose(video, (0, 3, 1, 2))
tensor = torch.from_numpy(video).type(self.dtype)
# Optional mean/std normalization (per channel)
if self.mean is not None:
mean = torch.as_tensor(self.mean, dtype=self.dtype, device=tensor.device)
if mean.ndim == 1:
mean = mean.view(1, 3, 1, 1)
tensor = tensor - mean
if self.std is not None:
std = torch.as_tensor(self.std, dtype=self.dtype, device=tensor.device)
if std.ndim == 1:
std = std.view(1, 3, 1, 1)
tensor = tensor / std
return tensor