From 4622f0febde2459d6e34652d96d11199a9906530 Mon Sep 17 00:00:00 2001 From: b-peri Date: Thu, 3 Oct 2024 10:20:10 +0100 Subject: [PATCH 1/5] Basic implementation of `compute_heading()` and `compute_relative_heading()` --- movement/analysis/kinematics.py | 291 +++++++++++++++++++++++++++++++- 1 file changed, 287 insertions(+), 4 deletions(-) diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index b1bce6ac..ee7d0553 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -3,10 +3,11 @@ from typing import Literal import numpy as np +import numpy.typing as npt import xarray as xr from movement.utils.logging import log_error -from movement.utils.vector import compute_norm +from movement.utils.vector import convert_to_unit from movement.validators.arrays import validate_dims_coords @@ -176,7 +177,7 @@ def compute_forward_vector( left_keypoint: str, right_keypoint: str, 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 @@ -278,7 +279,7 @@ def compute_forward_vector( # Return unit vector - return forward_vector / compute_norm(forward_vector) + return convert_to_unit(forward_vector) def compute_head_direction_vector( @@ -286,7 +287,7 @@ def compute_head_direction_vector( 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()\ @@ -324,6 +325,219 @@ def compute_head_direction_vector( ) +def compute_heading( + data: xr.DataArray, + left_keypoint: str, + right_keypoint: str, + reference_vector: npt.NDArray | list | tuple = (1, 0), + camera_view: Literal["top_down", "bottom_up"] = "top_down", + in_radians=False, +) -> xr.DataArray: + """Compute the 2D heading given two keypoints on the head. + + Heading is defined as the signed angle between the animal's forward + vector (see :func:`compute_forward_direction()\ + `) + and a reference vector. By default, the reference vector + corresponds to the direction of the positive x-axis. + + Parameters + ---------- + data : xarray.DataArray + 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 : str + Name of the left keypoint, e.g., "left_ear" + right_keypoint : str + Name of the right keypoint, e.g., "right_ear" + reference_vector : ndt.NDArray | list | tuple, optional + The reference vector against which the ```forward_vector`` is + compared to compute 2D heading. Must be a two-dimensional vector, + 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 : Literal["top_down", "bottom_up"], optional + 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_radians : bool, optional + If true, the returned heading array is given in radians. + If false, the array is given in degrees. False by default. + + Returns + ------- + xarray.DataArray + An xarray DataArray containing the computed heading + timeseries, with dimensions matching the input data array, + but without the ``keypoints`` and ``space`` dimensions. + + """ + # Convert reference vector to np.array if list + if isinstance(reference_vector, (list | tuple)): + reference_vector = np.array(reference_vector) + + # Validate that reference vector has correct dimensionality and type + if reference_vector.shape != (2,): + raise log_error( + ValueError, + f"Reference vector must be two-dimensional (with" + f" shape `(2,)`), but got {reference_vector.shape}.", + ) + if not (reference_vector.dtype == int) or ( + reference_vector.dtype == float + ): + raise log_error( + ValueError, + "Reference vector may only contain values of type ``int``" + "or ``float``.", + ) + + # Compute forward vector and separate x and y components + forward_vector = compute_forward_vector( + data, left_keypoint, right_keypoint, camera_view=camera_view + ) + forward_x = forward_vector.sel(space="x") + forward_y = forward_vector.sel(space="y") + + # Normalize reference vector and separate x and y components + reference_vector = reference_vector / np.linalg.norm(reference_vector) + ref_x = reference_vector[0] + ref_y = reference_vector[1] + + # Compute perp dot product to find signed angular difference between + # forward vector and reference vector + heading_array = np.arctan2( + forward_y * ref_x - forward_x * ref_y, + forward_x * ref_x + forward_y * ref_y, + ) + + # Convert to degrees + if not in_radians: + heading_array = np.rad2deg(heading_array) + + return heading_array + + +def compute_relative_heading( + data: xr.DataArray, + left_keypoint: str, + right_keypoint: str, + ROI: xr.DataArray | np.ndarray, + camera_view: Literal["top_down", "bottom_up"] = "top_down", + in_radians: bool = False, +) -> xr.DataArray: + """Compute the 2D heading relative to an ROI. + + Relative heading is computed as the signed angle between + the animal's forward vector (see :func:`compute_forward_direction()\ + `) + and the vector pointing from the midpoint between the two provided + left and right keypoints towards a region of interest (ROI). + + Parameters + ---------- + data : xarray.DataArray + 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 : str + Name of the left keypoint, e.g., "left_ear" + right_keypoint : str + Name of the right keypoint, e.g., "right_ear" + ROI : xarray.DataArray | np.ndarray + The position array for the region of interest against + which heading will be computed. Position may be provided in the form of + an ``xarray.DataArray`` (containing ``time``, ``space``, and optionally + , ``keypoints`` dimensions) or a ``numpy.ndarray`` with the following + axes: 0: time, 1: space (x, y), and (optionally) 2: keypoints. In both + cases, if the input ROI contains multiple keypoints (e.g. the vertices + of a bounding box), a centroid will be computed and the heading + computed relative to this centroid. For ROIs provided as + ``xarray.DataArray``'s, the time dimension must be equal in length to + ``data.time``. For ROIs given as ``numpy.ndarray``'s, the time + dimension must either have length 1 (e.g. a fixed coordinate for which + to compute relative heading across a recording) or be equal in length + to ``data.time``. For ``ndarray``'s with a single time point, take care + to ensure the required axes are adhered to (e.g. ``np.array([[0,1]]))`` + is a valid ROI, while ``np.array([0,1])`` is not). Note also that the + provided ROI position array may only contain one individual. + camera_view : Literal["top_down", "bottom_up"], optional + 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_radians : bool, optional + If true, the returned heading array is given in radians. + If false, the array is given in degrees. False by default. + + Returns + ------- + xarray.DataArray + An xarray DataArray containing the computed relative heading + timeseries, with dimensions matching the input data array, + but without the ``keypoints`` and ``space`` dimensions. + + """ + # Validate ROI + _validate_ROI_for_relative_heading(ROI, data) + + # Drop individuals dim if present + if "individuals" in data.dims: + data = data.sel(individuals=data.individuals.values[0], drop=True) + + # Compute forward vector + heading_vector = compute_forward_vector( + data, left_keypoint, right_keypoint, camera_view=camera_view + ) + forward_x = heading_vector.sel(space="x") + forward_y = heading_vector.sel(space="y") + + # Get ROI coordinates + if isinstance(ROI, xr.DataArray): + # If ROI has a keypoints dimension, compute centroid over provided + # points + if "keypoints" in ROI.dims: + ROI_coords = ROI.mean(dim="keypoints") + else: + ROI_coords = ROI + else: + # If ROI has a keypoints axis, compute centroid over provided points + if len(ROI.shape) > 2: + ROI = np.mean(ROI, axis=2) + + # If single timepoint, tile ROI array to match dimensions of ``data`` + if ROI.shape[0] == 1: + ROI_coords = np.tile(ROI, [len(data.time), 1]) + else: + ROI_coords = ROI + + # Compute reference vectors from Left-Right-Midpoint to ROI + left_right_midpoint = data.sel( + keypoints=[left_keypoint, right_keypoint] + ).mean(dim="keypoints") + + reference_vectors = convert_to_unit(ROI_coords - left_right_midpoint) + ref_x = reference_vectors.sel(space="x") + ref_y = reference_vectors.sel(space="y") + + # Compute perp dot product to find signed angular difference between + # forward vector and reference vector + rel_heading_array = np.arctan2( + forward_y * ref_x - forward_x * ref_y, + forward_x * ref_x + forward_y * ref_y, + ) + + # Convert to degrees + if not in_radians: + rel_heading_array = np.rad2deg(rel_heading_array) + + return rel_heading_array + + def _validate_type_data_array(data: xr.DataArray) -> None: """Validate the input data is an xarray DataArray. @@ -343,3 +557,72 @@ def _validate_type_data_array(data: xr.DataArray) -> None: TypeError, f"Input data must be an xarray.DataArray, but got {type(data)}.", ) + + +def _validate_ROI_for_relative_heading( + ROI: xr.DataArray | np.ndarray, data: xr.DataArray +): + """Validate the ROI has the correct type and dimensions. + + Parameters + ---------- + ROI : xarray.DataArray | numpy.ndarray + The ROI position array to validate. + data : xarray.DataArray + The input data against which to validate the ROI. + + Returns + ------- + TypeError + If ROI is not an xarray.DataArray or a numpy.ndarray + ValueError + If ROI does not have the correct dimensions + + """ + if not isinstance(ROI, (xr.DataArray | np.ndarray)): + raise log_error( + TypeError, + f"ROI must be an xarray.DataArray or a np.ndarray, but got " + f"{type(data)}.", + ) + if isinstance(ROI, xr.DataArray): + validate_dims_coords( + ROI, + { + "time": [], + "space": [], + }, + ) + if not len(ROI.time) == len(data.time): + raise log_error( + ValueError, + "Input data and ROI must have matching time dimensions.", + ) + if "individuals" in ROI.dims and len(ROI.individuals) > 1: + raise log_error( + ValueError, "ROI may not contain multiple individuals." + ) + else: + if not ( + ROI.shape[0] == 1 or ROI.shape[0] == len(data.time) + ): # Validate time dim + raise log_error( + ValueError, + "Dimension ``0`` of the ``ROI`` argument must have length 1 or" + " be equal in length to the ``time`` dimension of ``data``. \n" + "\n If passing a single coordinate, make sure that ... (e.g. " + "``np.array([[0,1]])``", + ) + if not ROI.shape[1] == 2: # Validate space dimension + raise log_error( + ValueError, + "Dimension ``1`` of the ``ROI`` argument must correspond to " + "coordinates in 2-D space, and may therefore only have size " + f"``2``. Instead, got size ``{ROI.shape[1]}``.", + ) + if len(ROI.shape) > 3: + raise log_error( + ValueError, + "ROI may not have more than 3 dimensions (O: time, 1: space, " + "2: keypoints).", + ) From e8cf89d7b73911548be546cde3fa6a7c89fb45cb Mon Sep 17 00:00:00 2001 From: b-peri Date: Fri, 4 Oct 2024 10:37:39 +0100 Subject: [PATCH 2/5] Minor fixes and docstring edits --- movement/analysis/kinematics.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index ee7d0553..63465dbe 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -354,9 +354,10 @@ def compute_heading( reference_vector : ndt.NDArray | list | tuple, optional The reference vector against which the ```forward_vector`` is compared to compute 2D heading. Must be a two-dimensional vector, - 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. + 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 : Literal["top_down", "bottom_up"], optional The camera viewing angle, used to determine the upwards direction of the animal. Can be either ``"top_down"`` (where the @@ -375,7 +376,7 @@ def compute_heading( but without the ``keypoints`` and ``space`` dimensions. """ - # Convert reference vector to np.array if list + # Convert reference vector to np.array if list or tuple if isinstance(reference_vector, (list | tuple)): reference_vector = np.array(reference_vector) @@ -483,7 +484,7 @@ def compute_relative_heading( """ # Validate ROI - _validate_ROI_for_relative_heading(ROI, data) + _validate_roi_for_relative_heading(ROI, data) # Drop individuals dim if present if "individuals" in data.dims: @@ -559,7 +560,7 @@ def _validate_type_data_array(data: xr.DataArray) -> None: ) -def _validate_ROI_for_relative_heading( +def _validate_roi_for_relative_heading( ROI: xr.DataArray | np.ndarray, data: xr.DataArray ): """Validate the ROI has the correct type and dimensions. From 7b2669fc92f83e81f149891b9e618847e8cf56db Mon Sep 17 00:00:00 2001 From: b-peri Date: Fri, 4 Oct 2024 17:04:03 +0100 Subject: [PATCH 3/5] Remove `compute_relative_heading()` from this PR --- movement/analysis/kinematics.py | 186 -------------------------------- 1 file changed, 186 deletions(-) diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index 63465dbe..1640f447 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -422,123 +422,6 @@ def compute_heading( return heading_array -def compute_relative_heading( - data: xr.DataArray, - left_keypoint: str, - right_keypoint: str, - ROI: xr.DataArray | np.ndarray, - camera_view: Literal["top_down", "bottom_up"] = "top_down", - in_radians: bool = False, -) -> xr.DataArray: - """Compute the 2D heading relative to an ROI. - - Relative heading is computed as the signed angle between - the animal's forward vector (see :func:`compute_forward_direction()\ - `) - and the vector pointing from the midpoint between the two provided - left and right keypoints towards a region of interest (ROI). - - Parameters - ---------- - data : xarray.DataArray - 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 : str - Name of the left keypoint, e.g., "left_ear" - right_keypoint : str - Name of the right keypoint, e.g., "right_ear" - ROI : xarray.DataArray | np.ndarray - The position array for the region of interest against - which heading will be computed. Position may be provided in the form of - an ``xarray.DataArray`` (containing ``time``, ``space``, and optionally - , ``keypoints`` dimensions) or a ``numpy.ndarray`` with the following - axes: 0: time, 1: space (x, y), and (optionally) 2: keypoints. In both - cases, if the input ROI contains multiple keypoints (e.g. the vertices - of a bounding box), a centroid will be computed and the heading - computed relative to this centroid. For ROIs provided as - ``xarray.DataArray``'s, the time dimension must be equal in length to - ``data.time``. For ROIs given as ``numpy.ndarray``'s, the time - dimension must either have length 1 (e.g. a fixed coordinate for which - to compute relative heading across a recording) or be equal in length - to ``data.time``. For ``ndarray``'s with a single time point, take care - to ensure the required axes are adhered to (e.g. ``np.array([[0,1]]))`` - is a valid ROI, while ``np.array([0,1])`` is not). Note also that the - provided ROI position array may only contain one individual. - camera_view : Literal["top_down", "bottom_up"], optional - 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_radians : bool, optional - If true, the returned heading array is given in radians. - If false, the array is given in degrees. False by default. - - Returns - ------- - xarray.DataArray - An xarray DataArray containing the computed relative heading - timeseries, with dimensions matching the input data array, - but without the ``keypoints`` and ``space`` dimensions. - - """ - # Validate ROI - _validate_roi_for_relative_heading(ROI, data) - - # Drop individuals dim if present - if "individuals" in data.dims: - data = data.sel(individuals=data.individuals.values[0], drop=True) - - # Compute forward vector - heading_vector = compute_forward_vector( - data, left_keypoint, right_keypoint, camera_view=camera_view - ) - forward_x = heading_vector.sel(space="x") - forward_y = heading_vector.sel(space="y") - - # Get ROI coordinates - if isinstance(ROI, xr.DataArray): - # If ROI has a keypoints dimension, compute centroid over provided - # points - if "keypoints" in ROI.dims: - ROI_coords = ROI.mean(dim="keypoints") - else: - ROI_coords = ROI - else: - # If ROI has a keypoints axis, compute centroid over provided points - if len(ROI.shape) > 2: - ROI = np.mean(ROI, axis=2) - - # If single timepoint, tile ROI array to match dimensions of ``data`` - if ROI.shape[0] == 1: - ROI_coords = np.tile(ROI, [len(data.time), 1]) - else: - ROI_coords = ROI - - # Compute reference vectors from Left-Right-Midpoint to ROI - left_right_midpoint = data.sel( - keypoints=[left_keypoint, right_keypoint] - ).mean(dim="keypoints") - - reference_vectors = convert_to_unit(ROI_coords - left_right_midpoint) - ref_x = reference_vectors.sel(space="x") - ref_y = reference_vectors.sel(space="y") - - # Compute perp dot product to find signed angular difference between - # forward vector and reference vector - rel_heading_array = np.arctan2( - forward_y * ref_x - forward_x * ref_y, - forward_x * ref_x + forward_y * ref_y, - ) - - # Convert to degrees - if not in_radians: - rel_heading_array = np.rad2deg(rel_heading_array) - - return rel_heading_array - - def _validate_type_data_array(data: xr.DataArray) -> None: """Validate the input data is an xarray DataArray. @@ -558,72 +441,3 @@ def _validate_type_data_array(data: xr.DataArray) -> None: TypeError, f"Input data must be an xarray.DataArray, but got {type(data)}.", ) - - -def _validate_roi_for_relative_heading( - ROI: xr.DataArray | np.ndarray, data: xr.DataArray -): - """Validate the ROI has the correct type and dimensions. - - Parameters - ---------- - ROI : xarray.DataArray | numpy.ndarray - The ROI position array to validate. - data : xarray.DataArray - The input data against which to validate the ROI. - - Returns - ------- - TypeError - If ROI is not an xarray.DataArray or a numpy.ndarray - ValueError - If ROI does not have the correct dimensions - - """ - if not isinstance(ROI, (xr.DataArray | np.ndarray)): - raise log_error( - TypeError, - f"ROI must be an xarray.DataArray or a np.ndarray, but got " - f"{type(data)}.", - ) - if isinstance(ROI, xr.DataArray): - validate_dims_coords( - ROI, - { - "time": [], - "space": [], - }, - ) - if not len(ROI.time) == len(data.time): - raise log_error( - ValueError, - "Input data and ROI must have matching time dimensions.", - ) - if "individuals" in ROI.dims and len(ROI.individuals) > 1: - raise log_error( - ValueError, "ROI may not contain multiple individuals." - ) - else: - if not ( - ROI.shape[0] == 1 or ROI.shape[0] == len(data.time) - ): # Validate time dim - raise log_error( - ValueError, - "Dimension ``0`` of the ``ROI`` argument must have length 1 or" - " be equal in length to the ``time`` dimension of ``data``. \n" - "\n If passing a single coordinate, make sure that ... (e.g. " - "``np.array([[0,1]])``", - ) - if not ROI.shape[1] == 2: # Validate space dimension - raise log_error( - ValueError, - "Dimension ``1`` of the ``ROI`` argument must correspond to " - "coordinates in 2-D space, and may therefore only have size " - f"``2``. Instead, got size ``{ROI.shape[1]}``.", - ) - if len(ROI.shape) > 3: - raise log_error( - ValueError, - "ROI may not have more than 3 dimensions (O: time, 1: space, " - "2: keypoints).", - ) From 867916953105e5c47ae8cfa316d9bdbc09661f84 Mon Sep 17 00:00:00 2001 From: b-peri Date: Thu, 10 Oct 2024 16:01:00 +0100 Subject: [PATCH 4/5] Created `signed_angle_between_2d_vectors()` vector util and refactored `compute_heading()` --- movement/analysis/kinematics.py | 105 +++++++++++++++----- movement/utils/vector.py | 165 ++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 25 deletions(-) diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index 1640f447..d745ae22 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -7,7 +7,10 @@ import xarray as xr from movement.utils.logging import log_error -from movement.utils.vector import convert_to_unit +from movement.utils.vector import ( + convert_to_unit, + signed_angle_between_2d_vectors, +) from movement.validators.arrays import validate_dims_coords @@ -380,39 +383,22 @@ def compute_heading( if isinstance(reference_vector, (list | tuple)): reference_vector = np.array(reference_vector) - # Validate that reference vector has correct dimensionality and type + # Validate that reference vector has correct dimensionality if reference_vector.shape != (2,): raise log_error( ValueError, f"Reference vector must be two-dimensional (with" - f" shape `(2,)`), but got {reference_vector.shape}.", - ) - if not (reference_vector.dtype == int) or ( - reference_vector.dtype == float - ): - raise log_error( - ValueError, - "Reference vector may only contain values of type ``int``" - "or ``float``.", + f" shape ``(2,)``), but got {reference_vector.shape}.", ) - # Compute forward vector and separate x and y components + # Compute forward vector forward_vector = compute_forward_vector( data, left_keypoint, right_keypoint, camera_view=camera_view ) - forward_x = forward_vector.sel(space="x") - forward_y = forward_vector.sel(space="y") - - # Normalize reference vector and separate x and y components - reference_vector = reference_vector / np.linalg.norm(reference_vector) - ref_x = reference_vector[0] - ref_y = reference_vector[1] - - # Compute perp dot product to find signed angular difference between - # forward vector and reference vector - heading_array = np.arctan2( - forward_y * ref_x - forward_x * ref_y, - forward_x * ref_x + forward_y * ref_y, + + # Compute signed angle between forward vector and reference vector + heading_array = signed_angle_between_2d_vectors( + forward_vector, reference_vector ) # Convert to degrees @@ -441,3 +427,72 @@ def _validate_type_data_array(data: xr.DataArray) -> None: TypeError, f"Input data must be an xarray.DataArray, but got {type(data)}.", ) + + +def _validate_roi_for_relative_heading( + ROI: xr.DataArray | np.ndarray, data: xr.DataArray +): + """Validate the ROI has the correct type and dimensions. + + Parameters + ---------- + ROI : xarray.DataArray | numpy.ndarray + The ROI position array to validate. + data : xarray.DataArray + The input data against which to validate the ROI. + + Returns + ------- + TypeError + If ROI is not an xarray.DataArray or a numpy.ndarray + ValueError + If ROI does not have the correct dimensions + + """ + if not isinstance(ROI, (xr.DataArray | np.ndarray)): + raise log_error( + TypeError, + f"ROI must be an xarray.DataArray or a np.ndarray, but got " + f"{type(data)}.", + ) + if isinstance(ROI, xr.DataArray): + validate_dims_coords( + ROI, + { + "time": [], + "space": [], + }, + ) + if not len(ROI.time) == len(data.time): + raise log_error( + ValueError, + "Input data and ROI must have matching time dimensions.", + ) + if "individuals" in ROI.dims and len(ROI.individuals) > 1: + raise log_error( + ValueError, "ROI may not contain multiple individuals." + ) + else: + if not ( + ROI.shape[0] == 1 or ROI.shape[0] == len(data.time) + ): # Validate time dim + raise log_error( + ValueError, + "Dimension ``0`` of the ``ROI`` argument must have length 1 or" + " be equal in length to the ``time`` dimension of ``data``. \n" + "\n If passing a single coordinate, make sure that ... (e.g. " + "``np.array([[0,1]])``", + ) + if not ROI.shape[1] == 2: # Validate space dimension + raise log_error( + ValueError, + "Dimension ``1`` of the ``ROI`` argument must correspond to " + "coordinates in 2-D space, and may therefore only have size " + f"``2``. Instead, got size ``{ROI.shape[1]}``.", + ) + if len(ROI.shape) > 3: + raise log_error( + ValueError, + "ROI may not have more than 3 dimensions (O: time, 1: space, " + "2: keypoints).", + ) diff --git a/movement/utils/vector.py b/movement/utils/vector.py index c91e43ec..d01a29a3 100644 --- a/movement/utils/vector.py +++ b/movement/utils/vector.py @@ -165,9 +165,174 @@ def pol2cart(data: xr.DataArray) -> xr.DataArray: ).transpose(*dims) +def signed_angle_between_2d_vectors( + test_vector: xr.DataArray, reference_vector: xr.DataArray | np.ndarray +) -> xr.DataArray: + """Compute the signed angle between two 2-D vectors. + + Parameters + ---------- + test_vector : xarray.DataArray + An array of position vectors containing the ``space`` + dimension with only ``"x"`` and ``"y"`` coordinates. + reference_vector : xarray.DataArray | numpy.ndarray + A 2D vector (or array of 2D vectors) against which to + compare ``test_vector``. May either be an xarray + DataArray containing the ``space`` dimension or a numpy + array containing one or more 2D vectors. (See Notes) + + Returns + ------- + xarray.DataArray : + An xarray DataArray containing signed angle between + ``test_vector`` and ``reference_vector`` for every + time-point. Matches the dimensions of ``test_vector``, + but without the ``space`` dimension. + + Notes + ----- + If passed as an xarray DataArray, the reference vector must + have the spatial coordinates ``x`` and ``y`` only, and must + have a ``time`` dimension matching that of the test vector. + + If passed as a numpy array, the reference vector must have + one of three shapes: + 1. ``(2,)`` - Where dimension ``0`` contains spatial + coordinates (x,y), and no time dimension is specified. + 2. ``(1,2)`` - Where dimension ``0`` corresponds to a + single time-point and dimension ``1`` contains spatial + coordinates (x,y). + 3. ``(n,2)`` - Where dimension ``0`` corresponds to + time and dimension ``1`` contains spatial coordinates + (x,y), and where ``n == len(test_vector.time)``. + + Reference vectors containing more dimensions, or with shapes + otherwise different from those defined above are considered + invalid. + + """ + if isinstance(reference_vector, np.ndarray) and reference_vector.shape == ( + 2, + ): + reference_vector = reference_vector.reshape(1, 2) + + validate_dims_coords(test_vector, {"space": ["x", "y"]}) + _validate_reference_vector(reference_vector, test_vector) + + test_unit = convert_to_unit(test_vector) + test_x = test_unit.sel(space="x") + test_y = test_unit.sel(space="y") + + if isinstance(reference_vector, xr.DataArray): + ref_unit = convert_to_unit(reference_vector) + ref_x = ref_unit.sel(space="x") + ref_y = ref_unit.sel(space="y") + else: + ref_unit = reference_vector / np.linalg.norm(reference_vector) + ref_x = np.take(ref_unit, 0, axis=-1).reshape(-1, 1) + ref_y = np.take(ref_unit, 1, axis=-1).reshape(-1, 1) + + signed_angles = np.arctan2( + test_y * ref_x - test_x * ref_y, + test_x * ref_x + test_y * ref_y, + ) + + return signed_angles + + def _raise_error_for_missing_spatial_dim() -> None: raise log_error( ValueError, "Input data array must contain either 'space' or 'space_pol' " "as dimensions.", ) + + +def _validate_reference_vector( + reference_vector: xr.DataArray | np.ndarray, test_vector: xr.DataArray +): + """Validate the reference vector has the correct type and dimensions. + + Parameters + ---------- + reference_vector : xarray.DataArray | numpy.ndarray + The reference vector array to validate. + test_vector : xarray.DataArray + The input data against which to validate the + reference vector. + + Returns + ------- + TypeError + If reference_vector is not an xarray DataArray or + a numpy array + ValueError + If reference_vector does not have the correct dimensions + + """ + # Validate reference vector type + if not isinstance(reference_vector, (xr.DataArray | np.ndarray)): + raise log_error( + TypeError, + f"Reference vector must be an xarray.DataArray or a np.ndarray, " + f"but got {type(reference_vector)}.", + ) + if isinstance(reference_vector, xr.DataArray): + validate_dims_coords( + reference_vector, + { + "space": ["x", "y"], + }, + ) + # Check reference_vector is 2D + if len(reference_vector.space) > 2: + raise log_error( + ValueError, + "Reference vector may not have more than 2 spatial " + "coordinates.", + ) + # Check reference vector has valid time dimension + if "time" in reference_vector.dims and not len( + reference_vector.time + ) == len(test_vector.time): + raise log_error( + ValueError, + "Input data and reference vector must have matching time " + "dimensions.", + ) + if any(dim not in ["time", "space"] for dim in reference_vector.dims): + raise log_error( + ValueError, "Reference vector contains invalid dimensions." + ) + else: + if not (reference_vector.dtype == int) or ( + reference_vector.dtype == float + ): + raise log_error( + ValueError, + "Reference vector may only contain values of type ``int``" + "or ``float``.", + ) + if not ( + reference_vector.shape[0] == 1 + or reference_vector.shape[0] == len(test_vector.time) + ): # Validate time dim + raise log_error( + ValueError, + "Dimension ``0`` of the reference vector must have length " + "``1`` or be equal in length to the ``time`` dimension of the " + "test vector.", + ) + if not reference_vector.shape[-1] == 2: # Validate space dimension + raise log_error( + ValueError, + "Dimension ``-1`` of the reference_vector must correspond to " + "coordinates in 2-D space, and may therefore only have size " + f"``2``. Instead, got size ``{reference_vector.shape[1]}``.", + ) + if len(reference_vector.shape) > 2: + raise log_error( + ValueError, + "Reference vector may not have more than 2 dimensions (time" + "and space, respectively)", + ) From 246801eaf12796c307101917b2375192fe2faa5a Mon Sep 17 00:00:00 2001 From: b-peri Date: Thu, 10 Oct 2024 16:02:47 +0100 Subject: [PATCH 5/5] Cleaned up redundant code --- movement/analysis/kinematics.py | 69 --------------------------------- 1 file changed, 69 deletions(-) diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index d745ae22..1529f187 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -427,72 +427,3 @@ def _validate_type_data_array(data: xr.DataArray) -> None: TypeError, f"Input data must be an xarray.DataArray, but got {type(data)}.", ) - - -def _validate_roi_for_relative_heading( - ROI: xr.DataArray | np.ndarray, data: xr.DataArray -): - """Validate the ROI has the correct type and dimensions. - - Parameters - ---------- - ROI : xarray.DataArray | numpy.ndarray - The ROI position array to validate. - data : xarray.DataArray - The input data against which to validate the ROI. - - Returns - ------- - TypeError - If ROI is not an xarray.DataArray or a numpy.ndarray - ValueError - If ROI does not have the correct dimensions - - """ - if not isinstance(ROI, (xr.DataArray | np.ndarray)): - raise log_error( - TypeError, - f"ROI must be an xarray.DataArray or a np.ndarray, but got " - f"{type(data)}.", - ) - if isinstance(ROI, xr.DataArray): - validate_dims_coords( - ROI, - { - "time": [], - "space": [], - }, - ) - if not len(ROI.time) == len(data.time): - raise log_error( - ValueError, - "Input data and ROI must have matching time dimensions.", - ) - if "individuals" in ROI.dims and len(ROI.individuals) > 1: - raise log_error( - ValueError, "ROI may not contain multiple individuals." - ) - else: - if not ( - ROI.shape[0] == 1 or ROI.shape[0] == len(data.time) - ): # Validate time dim - raise log_error( - ValueError, - "Dimension ``0`` of the ``ROI`` argument must have length 1 or" - " be equal in length to the ``time`` dimension of ``data``. \n" - "\n If passing a single coordinate, make sure that ... (e.g. " - "``np.array([[0,1]])``", - ) - if not ROI.shape[1] == 2: # Validate space dimension - raise log_error( - ValueError, - "Dimension ``1`` of the ``ROI`` argument must correspond to " - "coordinates in 2-D space, and may therefore only have size " - f"``2``. Instead, got size ``{ROI.shape[1]}``.", - ) - if len(ROI.shape) > 3: - raise log_error( - ValueError, - "ROI may not have more than 3 dimensions (O: time, 1: space, " - "2: keypoints).", - )