Source code for lisbet.datasets.common

"""Common code for selecting windows from a dataset of records."""

from typing import Literal

import numpy as np
import torch


[docs] class WindowSelector: """ Selects windows from a dataset of records. This class provides methods to extract temporal windows from a list of records, handling padding and interpolation as needed. It supports mapping between global and local frame indices and can scale windows according to a frames-per-second (fps) scaling factor. """
[docs] def __init__( self, records, window_size, window_offset=0, fps_scaling=1.0, engine: Literal["xarray", "numpy"] = "xarray", ): """ Initialize the WindowSelector. Parameters ---------- records : list List of records containing pose tracking data. window_size : int Size of the window in frames. window_offset : int, optional Offset for the window in frames (default is 0). fps_scaling : float, optional Scaling factor for the frames per second (default is 1.0). engine : {"xarray", "numpy"}, optional Output engine. The default ``"xarray"`` returns an xarray Dataset with coordinates and data variables intact. ``"numpy"`` returns an owned, writable array with shape ``(time, individuals, keypoints, space)``. Raises ------ ValueError If no records are provided or if any record contains fewer than 2 individuals, or if ``engine`` is unsupported. """ # Validate input parameters if not records: raise ValueError("No records provided to the dataset.") if any([rec.posetracks["individuals"].size < 2 for rec in records]): raise ValueError("LISBET requires at least 2 individuals in each record.") if engine not in ("xarray", "numpy"): raise ValueError( f"Invalid engine '{engine}'. Choose either 'xarray' or 'numpy'." ) self.records = records self.n_records = len(records) self.engine = engine self.window_size = window_size self.window_offset = window_offset self.fps_scaling = fps_scaling self.rel_time_coords = np.linspace( 0, self.window_size - 1, self.window_size, dtype=int ) # NOTE: Using torch tensors to trigger proper initialization of multiprocessing # workers on MacOS. This is a workaround, but it does not affect the # functionality of the class. self.lengths = torch.tensor( [rec.posetracks.sizes["time"] for rec in self.records], dtype=int ) self.cumlens = torch.cumsum(self.lengths, dim=0) self.n_frames = int(self.cumlens[-1]) # NumPy selection only needs the position variable. Transpose and obtain its # backing array once so every sample can be sliced on canonical axes without # repeatedly asking xarray to index or materialize the record. self._position_arrays = None if self.engine == "numpy": self._position_arrays = tuple( rec.posetracks["position"] .transpose("time", "individuals", "keypoints", "space") .values for rec in self.records )
[docs] def global_to_local(self, global_idx): """ Map a global frame index to a local (record_index, local_frame_index) pair. Parameters ---------- global_idx : int Global frame index (0 ≤ global_idx < total_n_frames). Returns ------- rec_idx : int Index of the record containing the frame. local_idx : int Local frame index within the selected record. """ rec_idx = torch.searchsorted(self.cumlens, global_idx, side="right").item() prev_sum = 0 if rec_idx == 0 else self.cumlens[rec_idx - 1].item() local_idx = global_idx - prev_sum return rec_idx, local_idx
[docs] def select(self, rec_idx, frame_idx, fps_scaling=None): """ Select a window from the dataset, applying padding and interpolation as needed. The selected window is returned as an independent xarray Dataset or NumPy array to avoid unintentional changes to source records (for example, by a self-supervised task or augmentation). Parameters ---------- rec_idx : int Index of the record from which to select the window. frame_idx : int Index of the central frame within the record. fps_scaling : float, optional Override the default fps scaling factor for this selection. If None, uses the default fps_scaling set during initialization. Returns ------- x : xarray.Dataset or numpy.ndarray The selected and interpolated window. NumPy output has shape ``(time, individuals, keypoints, space)``. Notes ----- 1. The interpolation is done here, and not directly on the records, to avoid resampling at the original fps before returning the output during inference. Furthermore, even during training, it is useful to only iterate over the original frames, rather than artificially inflating or deflating the dataset. """ if fps_scaling is None: fps_scaling = self.fps_scaling if self.engine == "numpy": return self._select_numpy(rec_idx, frame_idx, fps_scaling) return self._select_xarray(rec_idx, frame_idx, fps_scaling)
def _select_xarray(self, rec_idx, frame_idx, fps_scaling): """Select a window using the original xarray implementation.""" x = self.records[rec_idx].posetracks if fps_scaling == 1.0: # NOTE: If no fps scaling is applied, we can directly select the window # from the posetrack data without (expensive) interpolation # Compute scaled time coordinates start_idx = frame_idx - self.window_size + self.window_offset + 1 stop_idx = frame_idx + self.window_offset time_coords = np.linspace(start_idx, stop_idx, self.window_size, dtype=int) x = x.reindex(time=time_coords, fill_value=0).assign_coords( time=self.rel_time_coords ) else: # NOTE: If fps scaling is applied, we need to interpolate the posetrack and # then select the window from the interpolated data # Compute scaled time coordinates scaled_window_size = int(np.rint(fps_scaling * self.window_size)) scaled_window_offset = int(np.rint(fps_scaling * self.window_offset)) scaled_start_idx = frame_idx - scaled_window_size + scaled_window_offset + 1 scaled_stop_idx = frame_idx + scaled_window_offset scaled_time_coords = np.linspace( scaled_start_idx, scaled_stop_idx, scaled_window_size, dtype=int ) # Compute interpolation time coordinates interp_time_coords = np.linspace( scaled_start_idx, scaled_stop_idx, self.window_size ) # Select, pad (reindex) and interpolate data x = ( x.reindex(time=scaled_time_coords, fill_value=0) .interp(time=interp_time_coords) .assign_coords(time=self.rel_time_coords) ) return x def _select_numpy(self, rec_idx, frame_idx, fps_scaling): """Select a canonical NumPy window without constructing xarray objects.""" source = self._position_arrays[rec_idx] if fps_scaling == 1.0: start_idx = frame_idx - self.window_size + self.window_offset + 1 return self._copy_padded_interval(source, start_idx, self.window_size) scaled_window_size = int(np.rint(fps_scaling * self.window_size)) scaled_window_offset = int(np.rint(fps_scaling * self.window_offset)) scaled_start_idx = frame_idx - scaled_window_size + scaled_window_offset + 1 if scaled_window_size <= 0: raise ValueError("fps_scaling produces an empty source window.") scaled = self._copy_padded_interval( source, scaled_start_idx, scaled_window_size ) # xarray's linear interpolation produces floating-point values even when the # source is integral or float32. A single source point is an underdetermined # linear interpolation and xarray returns NaNs for it. # TODO: In a future release, prefer an "at least float32" policy over xarray # parity: promote integer and float16 inputs to float32 while preserving # floating-point dtypes that are already float32 or higher. output_shape = (self.window_size, *source.shape[1:]) if scaled_window_size == 1: return np.full(output_shape, np.nan, dtype=np.float64) interp_coords = np.linspace( 0.0, scaled_window_size - 1, self.window_size, dtype=np.float64 ) lower = np.floor(interp_coords).astype(int) upper = np.ceil(interp_coords).astype(int) weights = interp_coords - lower weights = weights.reshape((-1,) + (1,) * (scaled.ndim - 1)) scaled = scaled.astype(np.float64, copy=False) return scaled[lower] * (1.0 - weights) + scaled[upper] * weights @staticmethod def _copy_padded_interval(source, start_idx, size): """Copy an interval into a zero-padded, writable output buffer.""" output = np.zeros((size, *source.shape[1:]), dtype=source.dtype) source_start = max(start_idx, 0) source_stop = min(start_idx + size, source.shape[0]) if source_start < source_stop: output_start = source_start - start_idx output_stop = output_start + source_stop - source_start output[output_start:output_stop] = source[source_start:source_stop] return output
[docs] class AnnotatedWindowSelector(WindowSelector): """ WindowSelector with annotation extraction. Extends WindowSelector to also extract annotation targets for each selected window, supporting binary, multiclass, and multilabel annotation formats. """
[docs] def __init__( self, records, window_size, window_offset=0, fps_scaling=1.0, annot_format="multiclass", engine: Literal["xarray", "numpy"] = "xarray", ): """ Initialize the AnnotatedWindowSelector. Parameters ---------- records : list List of records containing the data and annotations. window_size : int Size of the window in frames. window_offset : int, optional Offset for the window in frames (default is 0). fps_scaling : float, optional Scaling factor for the frames per second (default is 1.0). annot_format : str, optional Format of the annotations ('binary', 'multiclass', or 'multilabel'). engine : {"xarray", "numpy"}, optional Output engine (default is ``"xarray"``). Raises ------ ValueError If ``annot_format`` is not one of ``"binary"``, ``"multiclass"``, or ``"multilabel"``, or if ``engine`` is unsupported. """ # Validate input parameters if annot_format not in ("binary", "multiclass", "multilabel"): raise ValueError( f"Invalid label format '{annot_format}'. " "Choose either 'binary', 'multiclass', or 'multilabel'." ) super().__init__(records, window_size, window_offset, fps_scaling, engine) self.annot_format = annot_format self._annotation_arrays = None # NOTE: Keep annotation caching specific to the NumPy engine. Annotation files # opened by xarray may be lazy, so caching their full ``.values`` arrays would # change the xarray engine's initialization cost and memory use. Test lazy I/O # behavior explicitly before considering a shared eager cache in the future. if self.engine == "numpy": self._annotation_arrays = tuple( rec.annotations["target_cls"] .transpose("time", "behaviors", "annotators") .values for rec in self.records )
[docs] def select(self, rec_idx, frame_idx, fps_scaling=None): """ Select a window and its corresponding annotation target. Parameters ---------- rec_idx : int Index of the record from which to select the window. frame_idx : int Index of the central frame within the record. fps_scaling : float, optional Override the default fps scaling factor for this selection. If None, uses the default fps_scaling set during initialization. Returns ------- x : xarray.Dataset or numpy.ndarray The selected and interpolated window. NumPy output has shape ``(time, individuals, keypoints, space)``. y : numpy.ndarray The annotation target(s) for the selected window, format depends on annot_format. """ x = super().select(rec_idx, frame_idx, fps_scaling) if self.engine == "numpy": target = self._annotation_arrays[rec_idx][frame_idx] if self.annot_format == "binary": y = target.copy() elif self.annot_format == "multiclass": # xarray skips NaNs by default when reducing floating-point arrays. y = np.asarray(np.nanargmax(target, axis=0)).squeeze().copy() else: y = target.squeeze().copy() elif self.annot_format == "binary": y = self.records[rec_idx].annotations.target_cls.isel(time=frame_idx).values elif self.annot_format == "multiclass": y = ( self.records[rec_idx] .annotations.target_cls.isel(time=frame_idx) .argmax("behaviors") .squeeze() .values ) elif self.annot_format == "multilabel": y = ( self.records[rec_idx] .annotations.target_cls.isel(time=frame_idx) .squeeze() .values ) return x, y