Source code for movement.kinematics.orientation

"""Compute orientations as vectors and angles."""

from collections.abc import Hashable
from typing import Literal, cast

import numpy as np
import xarray as xr
from numpy.typing import ArrayLike
from scipy.signal import savgol_coeffs

from movement.kinematics.kinematics import compute_time_derivative
from movement.kinematics.path import _validate_time_points
from movement.utils.logging import logger
from movement.utils.vector import (
    compute_signed_angle_2d,
    convert_to_unit,
)
from movement.validators.arrays import validate_dims_coords


[docs] def compute_forward_vector( data: xr.DataArray, left_keypoint: Hashable, right_keypoint: Hashable, camera_view: Literal["top_down", "bottom_up"] = "top_down", ) -> xr.DataArray: """Compute a 2D forward vector given two left-right symmetric keypoints. The forward vector is computed as a vector perpendicular to the line connecting two symmetrical keypoints on either side of the body (i.e., symmetrical relative to the mid-sagittal plane), and pointing forwards (in the rostral direction). A top-down or bottom-up view of the animal is assumed (see Notes). Parameters ---------- data The input data representing position. This must contain the two symmetrical keypoints located on the left and right sides of the body, respectively. left_keypoint Name of the left keypoint, e.g., "left_ear" right_keypoint Name of the right keypoint, e.g., "right_ear" camera_view The camera viewing angle, used to determine the upwards direction of the animal. Can be either ``"top_down"`` (where the upwards direction is [0, 0, -1]), or ``"bottom_up"`` (where the upwards direction is [0, 0, 1]). If left unspecified, the camera view is assumed to be ``"top_down"``. Returns ------- xarray.DataArray An xarray DataArray representing the forward vector, with dimensions matching the input data array, but without the ``keypoint`` dimension. Raises ------ ValueError If ``camera_view`` is not one of ``"top_down"`` or ``"bottom_up"``. Notes ----- To determine the forward direction of the animal, we need to specify (1) the right-to-left direction of the animal and (2) its upward direction. We determine the right-to-left direction via the input left and right keypoints. The upwards direction, in turn, can be determined by passing the ``camera_view`` argument with either ``"top_down"`` or ``"bottom_up"``. If the camera view is specified as being ``"top_down"``, or if no additional information is provided, we assume that the upwards direction matches that of the vector ``[0, 0, -1]``. If the camera view is ``"bottom_up"``, the upwards direction is assumed to be given by ``[0, 0, 1]``. For both cases, we assume that position values are expressed in the image coordinate system (where the positive X-axis is oriented to the right, the positive Y-axis faces downwards, and positive Z-axis faces away from the person viewing the screen). If one of the required pieces of information is missing for a frame (e.g., the left keypoint is not visible), then the computed head direction vector is set to NaN. """ # Validate camera view if camera_view not in ("top_down", "bottom_up"): raise logger.error( ValueError( "camera_view must be 'top_down' or 'bottom_up', " f"but got {camera_view!r}." ) ) # Validate input data _validate_type_data_array(data) validate_dims_coords( data, { "time": [], "keypoint": [left_keypoint, right_keypoint], "space": [], }, ) if len(data.space) != 2: raise logger.error( ValueError( "Input data must have exactly 2 spatial dimensions, but " f"currently has {len(data.space)}." ) ) # Validate input keypoints if left_keypoint == right_keypoint: raise logger.error( ValueError("The left and right keypoints may not be identical.") ) # Define right-to-left vector right_to_left_vector = data.sel( keypoint=left_keypoint, drop=True ) - data.sel(keypoint=right_keypoint, drop=True) # Define upward vector # default: negative z direction in the image coordinate system upward_vector_arr = ( np.array([0, 0, -1]) if camera_view == "top_down" else np.array([0, 0, 1]) ) upward_vector = xr.DataArray( np.tile(upward_vector_arr.reshape(1, -1), [len(data.time), 1]), dims=["time", "space"], coords={ "space": ["x", "y", "z"], }, ) # Compute forward direction as the cross product # (right-to-left) cross (forward) = up forward_vector = cast( "xr.DataArray", xr.cross(right_to_left_vector, upward_vector, dim="space"), ).drop_sel( space="z" ) # keep only the first 2 spatal dimensions of the result # Return unit vector result = convert_to_unit(forward_vector) result.name = "forward_vector" return result
[docs] def compute_head_direction_vector( data: xr.DataArray, left_keypoint: str, right_keypoint: str, camera_view: Literal["top_down", "bottom_up"] = "top_down", ) -> xr.DataArray: """Compute the 2D head direction vector given two keypoints on the head. This function is an alias for :func:`compute_forward_vector()\ <movement.kinematics.compute_forward_vector>`. For more detailed information on how the head direction vector is computed, please refer to the documentation for that function. Parameters ---------- data The input data representing position. This must contain the two chosen keypoints corresponding to the left and right of the head. left_keypoint Name of the left keypoint, e.g., "left_ear" right_keypoint Name of the right keypoint, e.g., "right_ear" camera_view The camera viewing angle, used to determine the upwards direction of the animal. Can be either ``"top_down"`` (where the upwards direction is [0, 0, -1]), or ``"bottom_up"`` (where the upwards direction is [0, 0, 1]). If left unspecified, the camera view is assumed to be ``"top_down"``. Returns ------- xarray.DataArray An xarray DataArray representing the head direction vector, with dimensions matching the input data array, but without the ``keypoint`` dimension. """ result = compute_forward_vector( data, left_keypoint, right_keypoint, camera_view=camera_view ) result.name = "head_direction_vector" return result
[docs] def compute_forward_vector_angle( data: xr.DataArray, left_keypoint: Hashable, right_keypoint: Hashable, reference_vector: xr.DataArray | ArrayLike = (1, 0), camera_view: Literal["top_down", "bottom_up"] = "top_down", in_degrees: bool = False, ) -> xr.DataArray: r"""Compute the signed angle between a reference and a forward vector. Forward vector angle is the :func:`signed angle\ <movement.utils.vector.compute_signed_angle_2d>` between the reference vector and the animal's :func:`forward vector\ <movement.kinematics.compute_forward_vector>`. The returned angles are in radians, spanning the range :math:`(-\pi, \pi]`, unless ``in_degrees`` is set to ``True``. Parameters ---------- data The input data representing position. This must contain the two symmetrical keypoints located on the left and right sides of the body, respectively. left_keypoint Name of the left keypoint, e.g., "left_ear", used to compute the forward vector. right_keypoint Name of the right keypoint, e.g., "right_ear", used to compute the forward vector. reference_vector The reference vector against which the ``forward_vector`` is compared to compute 2D heading. Must be a two-dimensional vector, in the form [x,y] - where ``reference_vector[0]`` corresponds to the x-coordinate and ``reference_vector[1]`` corresponds to the y-coordinate. If left unspecified, the vector [1, 0] is used by default. camera_view The camera viewing angle, used to determine the upwards direction of the animal. Can be either ``"top_down"`` (where the upwards direction is [0, 0, -1]), or ``"bottom_up"`` (where the upwards direction is [0, 0, 1]). If left unspecified, the camera view is assumed to be ``"top_down"``. in_degrees If ``True``, the returned heading array is given in degrees. Otherwise, the array is given in radians. Default ``False``. Returns ------- xarray.DataArray An xarray DataArray containing the computed forward vector angles, with dimensions matching the input data array, but without the ``keypoint`` and ``space`` dimensions. See Also -------- movement.utils.vector.compute_signed_angle_2d : The underlying function used to compute the signed angle between two 2D vectors. See this function for a definition of the signed angle between two vectors. movement.kinematics.compute_forward_vector : The function used to compute the forward vector. """ # Convert reference vector to np.array if not already a valid array if not isinstance(reference_vector, np.ndarray | xr.DataArray): reference_vector = np.array(reference_vector) # Compute forward vector forward_vector = compute_forward_vector( data, left_keypoint, right_keypoint, camera_view=camera_view ) # Compute signed angle between reference vector and forward vector heading_array = compute_signed_angle_2d( forward_vector, reference_vector, v_as_left_operand=True ) # Convert to degrees if in_degrees: heading_array = cast("xr.DataArray", np.rad2deg(heading_array)) heading_array.name = "forward_vector_angle" return heading_array
[docs] def compute_angular_time_derivative( data: xr.DataArray, order: int, window: int | None = None, ) -> xr.DataArray: r"""Compute the time-derivative of a 2D orientation. The orientation is given as 2D vectors. Its rate of change is estimated from the signed angles between nearby orientation vectors, which avoids the spurious jumps that occur when angles wrap around at :math:`\pm\pi` (see Notes). Parameters ---------- data The input orientation vectors, containing ``time`` and ``space`` as dimensions. ``space`` must have exactly the coordinates ``["x", "y"]``. The vectors need not be of unit length. order The order of the time-derivative. Use 1 for angular velocity and 2 for angular acceleration. Must be a positive integer. window If ``None`` (default), the derivative is computed with central differences (one-sided at the first and last observations). Otherwise, the size of the centred rolling window, representing the fixed number of observations to which a least-squares polynomial is fitted (see Notes). Must be an odd integer greater than ``order``, and requires a uniformly sampled ``time`` coordinate. Returns ------- xarray.DataArray The time-derivative of the orientation, in radians per unit of time (raised to the power of ``order``). It has the dimensions of the input, without ``space``. See Also -------- compute_angular_velocity : Wrapper for ``order=1``. compute_angular_acceleration : Wrapper for ``order=2``. Notes ----- **Wrap-around.** Differentiating angles directly produces spurious spikes wherever they wrap around at :math:`\pm\pi`. Instead, this function uses the :func:`signed angles<movement.utils.vector.compute_signed_angle_2d>` between nearby orientations vectors. Without ``window``, ``order=1`` is a central-difference estimate built from the angles between consecutive observations. As with :func:`numpy.gradient`, it stays centred on each observation even when time steps are irregular. Higher orders differentiate that result further with :func:`~movement.kinematics.compute_time_derivative`. With ``window``, the angles are measured from the window's centre orientation (see *Windowed estimate*). This assumes rotations of less than :math:`\pi` between consecutive observations (or across half a ``window``). Undefined orientations (NaNs or zero-length vectors) are treated as NaN. **Sign.** Positive values are rotations from the positive x-axis towards the positive y-axis. In image coordinates (y pointing down) with a top-down camera, this is clockwise on screen. **Windowed estimate.** With ``window``, the angles of the orientations in the window, relative to the centre one, are differentiated with Savitzky-Golay coefficients (:func:`scipy.signal.savgol_coeffs`). For ``order=1``, this is the slope of a least-squares line over the window centred on each time point. It uses every observation in the window. The first and last ``window // 2`` values are NaN, as are all outputs whose window contains a NaN. For a window *ending* on each time point, shift the result by ``window // 2`` time steps. **Preprocessing.** Smooth, interpolate, or resample the orientation *vectors*, not their angles, since angle averages are wrong near :math:`\pm\pi`. Windowed estimates require uniform sampling, so make sure to resample irregularly sampled data onto a uniform ``time`` grid first, e.g. with :meth:`xarray.DataArray.interp` on the vectors. """ _validate_type_data_array(data) if not isinstance(order, int) or order <= 0: raise logger.error( ValueError(f"Order must be a positive integer, but got {order}.") ) if window is not None and not ( isinstance(window, int) and window > order and window % 2 == 1 ): raise logger.error( ValueError( f"Window must be an odd integer greater than the order " f"({order}), but got {window}." ) ) validate_dims_coords( data, {"time": [], "space": ["x", "y"]}, exact_coords=True ) _validate_time_points(data, "angular time derivative") if window is None: # Mean angular velocity over each time step (divided along "time" # by name), interpolated from the step midpoints to the time # points. The first and last steps' rates are used at the edges. vectors = data.drop_vars("time") step_angle = compute_signed_angle_2d( vectors.isel(time=slice(None, -1)), vectors.isel(time=slice(1, None)), ) time = data["time"].values step_dt = xr.DataArray(np.diff(time), dims="time") midpoints = (time[:-1] + time[1:]) / 2 result = ( (step_angle / step_dt) .pad(time=1, mode="edge") .assign_coords(time=np.r_[time[0], midpoints, time[-1]]) .interp(time=time) ) # The result is a rate, not an angle, so it doesn't wrap around; # higher orders can use the ordinary time derivative. if order > 1: result = compute_time_derivative(result, order - 1) else: coeffs = savgol_coeffs( window, order, deriv=order, delta=_uniform_time_step(data), use="dot", ) # Out-of-range neighbours are NaN, so the edge outputs are NaN neighbours = data.rolling(time=window, center=True).construct("offset") relative_angle = compute_signed_angle_2d(data, neighbours) result = (relative_angle * xr.DataArray(coeffs, dims="offset")).sum( "offset", skipna=False ) # Drop attrs for consistency with compute_time_derivative (for now). # See https://github.com/neuroinformatics-unit/movement/issues/1158 result.attrs = {} result.name = "angular_time_derivative" return result
[docs] def compute_angular_velocity( data: xr.DataArray, window: int | None = None, in_degrees: bool = False, ) -> xr.DataArray: r"""Compute the angular velocity of a 2D orientation. The orientation is given as 2D vectors, e.g. the output of :func:`compute_forward_vector`. Parameters ---------- data The input orientation vectors, containing ``time`` and ``space`` as dimensions. ``space`` must have exactly the coordinates ``["x", "y"]``. The vectors need not be of unit length. window If ``None`` (default), the angular velocity is computed with central differences (one-sided at the first and last observations). Otherwise, the size of the centred rolling window, representing the fixed number of observations to which a least-squares line is fitted; the angular velocity is its slope. Must be an odd integer greater than 1, and requires a uniformly sampled ``time`` coordinate. in_degrees If ``False`` (default), the output is in radians per unit of time. If ``True``, it is in degrees per unit of time. Returns ------- xarray.DataArray The angular velocity, with the dimensions of the input, without ``space``. The unit of time is that of the ``time`` coordinate (seconds or frames). See Also -------- compute_angular_time_derivative : The underlying function used. compute_angular_acceleration : The second time-derivative of orientation. Examples -------- >>> from movement.kinematics import ( ... compute_angular_velocity, ... compute_forward_vector, ... ) >>> head_vector = compute_forward_vector( ... ds.position, "left_ear", "right_ear" ... ) Central-difference angular head velocity: >>> ahv = compute_angular_velocity(head_vector) Least-squares estimate over the 7 observations centred on each time point, in degrees per unit of time: >>> ahv = compute_angular_velocity(head_vector, window=7, in_degrees=True) Shift the above estimate by half a window to get a trailing estimate, using only the 7 observations up to and including each time point, in radians per unit of time: >>> ahv_trailing = compute_angular_velocity(head_vector, window=7).shift( ... time=7 // 2 ... ) """ result = compute_angular_time_derivative(data, order=1, window=window) if in_degrees: result = cast("xr.DataArray", np.rad2deg(result)) result.name = "angular_velocity" return result
[docs] def compute_angular_acceleration( data: xr.DataArray, window: int | None = None, in_degrees: bool = False, ) -> xr.DataArray: r"""Compute the angular acceleration of a 2D orientation. The orientation is given as 2D vectors, e.g. the output of :func:`compute_forward_vector`. Parameters ---------- data The input orientation vectors, containing ``time`` and ``space`` as dimensions. ``space`` must have exactly the coordinates ``["x", "y"]``. The vectors need not be of unit length. window If ``None`` (default), the angular acceleration is computed by differentiating the central-difference angular velocity. Otherwise, the size of the centred rolling window, representing the fixed number of observations to which a least-squares quadratic is fitted; the angular acceleration is its second derivative. Must be an odd integer greater than 2, and requires a uniformly sampled ``time`` coordinate. in_degrees If ``False`` (default), the output is in radians per unit of time squared. If ``True``, it is in degrees per unit of time squared. Returns ------- xarray.DataArray The angular acceleration, with the dimensions of the input, without ``space``. The unit of time is that of the ``time`` coordinate (seconds or frames). See Also -------- compute_angular_time_derivative : The underlying function used. compute_angular_velocity : The first time-derivative of orientation. Examples -------- >>> from movement.kinematics import ( ... compute_angular_acceleration, ... compute_forward_vector, ... ) >>> head_vector = compute_forward_vector( ... ds.position, "left_ear", "right_ear" ... ) Least-squares estimate over the 9 observations centred on each time point, in degrees per unit of time squared: >>> aha = compute_angular_acceleration( ... head_vector, window=9, in_degrees=True ... ) """ result = compute_angular_time_derivative(data, order=2, window=window) if in_degrees: result = cast("xr.DataArray", np.rad2deg(result)) result.name = "angular_acceleration" return result
def _uniform_time_step(data: xr.DataArray) -> float: """Return the time step, which must be uniform.""" steps = np.diff(data["time"].values) if not np.allclose(steps, steps[0]): raise logger.error( ValueError( "A uniformly sampled 'time' coordinate is required " "when 'window' is set." ) ) return float(steps[0]) def _validate_type_data_array(data: xr.DataArray) -> None: """Validate the input data is an xarray DataArray. Parameters ---------- data The input data to validate. Raises ------ ValueError If the input data is not an xarray DataArray. """ if not isinstance(data, xr.DataArray): raise logger.error( TypeError( "Input data must be an xarray.DataArray, " f"but got {type(data)}." ) )