From a7e79bf37d546a4774e9b2bb86875d37108ab559 Mon Sep 17 00:00:00 2001 From: b-peri Date: Tue, 13 Aug 2024 17:47:34 +0100 Subject: [PATCH 01/18] Basic implementation of `compute_head_direction_vector()` --- movement/analysis/navigation.py | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 movement/analysis/navigation.py diff --git a/movement/analysis/navigation.py b/movement/analysis/navigation.py new file mode 100644 index 000000000..70f3b8be8 --- /dev/null +++ b/movement/analysis/navigation.py @@ -0,0 +1,73 @@ +"""Extract variables useful for the study of spatial navigation.""" + +import numpy as np +import xarray as xr + +from movement.utils.logging import log_error + + +def compute_head_direction_vector( + data: xr.DataArray, left_keypoint: str, right_keypoint: str +): + """Compute the head direction vector given two keypoints on the head. + + The head direction vector is computed as a vector perpendicular to the + line connecting two keypoints on either side of the head, pointing + forwards (in a rostral direction). + + Parameters + ---------- + data : xarray.DataArray + The input data representing position. This must contain + the two chosen keypoints corresponding to the left and + right of the head. + left_keypoint : str + Name of the right keypoint, e.g., "right_ear" + right_keypoint : str + Name of the left keypoint, e.g., "left_ear" + + Returns + ------- + xarray.DataArray + An xarray DataArray representing the head direction vector, + with dimensions matching the input data array, but without the + ``keypoints`` dimension. + + """ + # Validate input dataset + if not isinstance(data, xr.DataArray): + raise log_error( + TypeError, + f"Input data must be an xarray.DataArray, but got {type(data)}.", + ) + if not all(coord in data.dims for coord in ["time", "keypoints", "space"]): + raise log_error( + AttributeError, + "Input data must contain 'time', 'space', and 'keypoints' as " + "dimensions.", + ) + if not all( + keypoint in data.keypoints + for keypoint in [left_keypoint, right_keypoint] + ): + raise log_error( + AttributeError, + "The given keypoints could not be found in the input dataset", + ) + + # Select the right and left keypoints + head_left = data.sel(keypoints=left_keypoint, drop=True) + head_right = data.sel(keypoints=right_keypoint, drop=True) + + # Initialize a vector from right to left ear, and another vector + # perpendicular to the X-Y plane + right_to_left_vector = head_left - head_right + perpendicular_vector = np.array([0, 0, -1]) + + # Compute cross product + head_vector = head_right.copy() + head_vector.values = np.cross(right_to_left_vector, perpendicular_vector)[ + :, :, :-1 + ] + + return head_vector From 1f86141fa07c3d32bb85fc332e247116dd92fc48 Mon Sep 17 00:00:00 2001 From: b-peri Date: Mon, 19 Aug 2024 08:03:14 +0100 Subject: [PATCH 02/18] Minor fixes docstring --- movement/analysis/navigation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/movement/analysis/navigation.py b/movement/analysis/navigation.py index 70f3b8be8..75947333e 100644 --- a/movement/analysis/navigation.py +++ b/movement/analysis/navigation.py @@ -22,9 +22,9 @@ def compute_head_direction_vector( the two chosen keypoints corresponding to the left and right of the head. left_keypoint : str - Name of the right keypoint, e.g., "right_ear" - right_keypoint : str Name of the left keypoint, e.g., "left_ear" + right_keypoint : str + Name of the right keypoint, e.g., "right_ear" Returns ------- @@ -52,7 +52,7 @@ def compute_head_direction_vector( ): raise log_error( AttributeError, - "The given keypoints could not be found in the input dataset", + "The selected keypoints could not be found in the input dataset", ) # Select the right and left keypoints From e1758a7b2e40af0c1a4d059413cb3c2cfa15ee4b Mon Sep 17 00:00:00 2001 From: b-peri Date: Thu, 22 Aug 2024 10:52:26 +0100 Subject: [PATCH 03/18] Added unit test for `compute_head_direction_vector()` --- movement/analysis/navigation.py | 12 ++--- tests/test_unit/test_navigation.py | 82 ++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 tests/test_unit/test_navigation.py diff --git a/movement/analysis/navigation.py b/movement/analysis/navigation.py index 75947333e..89bb10bea 100644 --- a/movement/analysis/navigation.py +++ b/movement/analysis/navigation.py @@ -9,11 +9,11 @@ def compute_head_direction_vector( data: xr.DataArray, left_keypoint: str, right_keypoint: str ): - """Compute the head direction vector given two keypoints on the head. + """Compute the 2D head direction vector given two keypoints on the head. The head direction vector is computed as a vector perpendicular to the line connecting two keypoints on either side of the head, pointing - forwards (in a rostral direction). + forwards (in the rostral direction). Parameters ---------- @@ -46,13 +46,9 @@ def compute_head_direction_vector( "Input data must contain 'time', 'space', and 'keypoints' as " "dimensions.", ) - if not all( - keypoint in data.keypoints - for keypoint in [left_keypoint, right_keypoint] - ): + if left_keypoint == right_keypoint: raise log_error( - AttributeError, - "The selected keypoints could not be found in the input dataset", + ValueError, "The left and right keypoints may not be identical." ) # Select the right and left keypoints diff --git a/tests/test_unit/test_navigation.py b/tests/test_unit/test_navigation.py new file mode 100644 index 000000000..dc7f40eaf --- /dev/null +++ b/tests/test_unit/test_navigation.py @@ -0,0 +1,82 @@ +import numpy as np +import pytest +import xarray as xr + +from movement.analysis import navigation + + +@pytest.fixture +def mock_dataset(): + """Return a mock DataArray containing four known head orientations.""" + time = np.array([0, 1, 2, 3]) + individuals = np.array(["individual_0"]) + keypoints = np.array(["left_ear", "right_ear", "nose"]) + space = np.array(["x", "y"]) + + ds = xr.DataArray( + [ + [[[1, 0], [-1, 0], [0, -1]]], # time 0 + [[[0, 1], [0, -1], [1, 0]]], # time 1 + [[[-1, 0], [1, 0], [0, 1]]], # time 2 + [[[0, -1], [0, 1], [-1, 0]]], # time 3 + ], + dims=["time", "individuals", "keypoints", "space"], + coords={ + "time": time, + "individuals": individuals, + "keypoints": keypoints, + "space": space, + }, + ) + return ds + + +def test_compute_head_direction_vector(mock_dataset): + """Test that the correct head direction vectors + are computed from a basic mock dataset. + """ + # Test that validators work + with pytest.raises( + TypeError, + match="Input data must be an xarray.DataArray, but got .", + ): + np_array = [ + [[[1, 0], [-1, 0], [0, -1]]], + [[[0, 1], [0, -1], [1, 0]]], + [[[-1, 0], [1, 0], [0, 1]]], + [[[0, -1], [0, 1], [-1, 0]]], + ] + navigation.compute_head_direction_vector( + np_array, "left_ear", "right_ear" + ) + + with pytest.raises( + ValueError, + match="Input data must contain 'time', 'space', and 'keypoints'" + " as dimensions.", + ): + mock_dataset_keypoint = mock_dataset.sel(keypoints="nose", drop=True) + navigation.compute_head_direction_vector( + mock_dataset_keypoint, "left_ear", "right_ear" + ) + + with pytest.raises( + ValueError, match="The left and right keypoints may not be identical." + ): + navigation.compute_head_direction_vector( + mock_dataset, "left_ear", "left_ear" + ) + + # Test that output contains correct datatype, dimensions, and values + head_vector = navigation.compute_head_direction_vector( + mock_dataset, "left_ear", "right_ear" + ) + known_vectors = np.array([[0, 2], [-2, 0], [0, -2], [2, 0]]) + + assert ( + isinstance(head_vector, xr.DataArray) + and ("space" in head_vector.dims) + and ("keypoints" not in head_vector.dims) + ) + assert np.equal(head_vector.values, known_vectors).all() From 31ca959384929db17658c92ad7d894fb97e2ac12 Mon Sep 17 00:00:00 2001 From: b-peri Date: Thu, 22 Aug 2024 11:03:17 +0100 Subject: [PATCH 04/18] Bug fixes for `test_compute_head_direction_vector()` --- tests/test_unit/test_navigation.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/tests/test_unit/test_navigation.py b/tests/test_unit/test_navigation.py index dc7f40eaf..2d84a7076 100644 --- a/tests/test_unit/test_navigation.py +++ b/tests/test_unit/test_navigation.py @@ -36,11 +36,7 @@ def test_compute_head_direction_vector(mock_dataset): are computed from a basic mock dataset. """ # Test that validators work - with pytest.raises( - TypeError, - match="Input data must be an xarray.DataArray, but got .", - ): + with pytest.raises(TypeError): np_array = [ [[[1, 0], [-1, 0], [0, -1]]], [[[0, 1], [0, -1], [1, 0]]], @@ -51,19 +47,13 @@ def test_compute_head_direction_vector(mock_dataset): np_array, "left_ear", "right_ear" ) - with pytest.raises( - ValueError, - match="Input data must contain 'time', 'space', and 'keypoints'" - " as dimensions.", - ): + with pytest.raises(AttributeError): mock_dataset_keypoint = mock_dataset.sel(keypoints="nose", drop=True) navigation.compute_head_direction_vector( mock_dataset_keypoint, "left_ear", "right_ear" ) - with pytest.raises( - ValueError, match="The left and right keypoints may not be identical." - ): + with pytest.raises(ValueError): navigation.compute_head_direction_vector( mock_dataset, "left_ear", "left_ear" ) @@ -72,7 +62,7 @@ def test_compute_head_direction_vector(mock_dataset): head_vector = navigation.compute_head_direction_vector( mock_dataset, "left_ear", "right_ear" ) - known_vectors = np.array([[0, 2], [-2, 0], [0, -2], [2, 0]]) + known_vectors = np.array([[[0, 2]], [[-2, 0]], [[0, -2]], [[2, 0]]]) assert ( isinstance(head_vector, xr.DataArray) From 1bc3e2ae53df9b55b7cbc72af448bcbbc8d88697 Mon Sep 17 00:00:00 2001 From: b-peri Date: Thu, 22 Aug 2024 12:45:36 +0100 Subject: [PATCH 05/18] Added validator (and test) to ensure input is 2D --- movement/analysis/navigation.py | 6 ++++++ tests/test_unit/test_navigation.py | 27 ++++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/movement/analysis/navigation.py b/movement/analysis/navigation.py index 89bb10bea..022d713f0 100644 --- a/movement/analysis/navigation.py +++ b/movement/analysis/navigation.py @@ -50,6 +50,12 @@ def compute_head_direction_vector( raise log_error( ValueError, "The left and right keypoints may not be identical." ) + if len(data.space) != 2: + raise log_error( + ValueError, + "Input data must have 2 (and only 2) spatial dimensions, but " + f"currently has {len(data.space)}.", + ) # Select the right and left keypoints head_left = data.sel(keypoints=left_keypoint, drop=True) diff --git a/tests/test_unit/test_navigation.py b/tests/test_unit/test_navigation.py index 2d84a7076..a8a90e028 100644 --- a/tests/test_unit/test_navigation.py +++ b/tests/test_unit/test_navigation.py @@ -36,7 +36,7 @@ def test_compute_head_direction_vector(mock_dataset): are computed from a basic mock dataset. """ # Test that validators work - with pytest.raises(TypeError): + with pytest.raises(TypeError): # Catch incorrect datatype np_array = [ [[[1, 0], [-1, 0], [0, -1]]], [[[0, 1], [0, -1], [1, 0]]], @@ -47,17 +47,38 @@ def test_compute_head_direction_vector(mock_dataset): np_array, "left_ear", "right_ear" ) - with pytest.raises(AttributeError): + with pytest.raises(AttributeError): # Catch incorrect dimensions mock_dataset_keypoint = mock_dataset.sel(keypoints="nose", drop=True) navigation.compute_head_direction_vector( mock_dataset_keypoint, "left_ear", "right_ear" ) - with pytest.raises(ValueError): + with pytest.raises(ValueError): # Catch identical left and right keypoints navigation.compute_head_direction_vector( mock_dataset, "left_ear", "left_ear" ) + with pytest.raises(ValueError): # Catch incorrect spatial dimensions + time = np.array([0]) + individuals = np.array(["individual_0"]) + keypoints = np.array(["left", "right"]) + space = np.array(["x", "y", "z"]) + + ds = xr.DataArray( + [ + [[[1, 0, 0], [-1, 0, 0]]], + ], + dims=["time", "individuals", "keypoints", "space"], + coords={ + "time": time, + "individuals": individuals, + "keypoints": keypoints, + "space": space, + }, + ) + + navigation.compute_head_direction_vector(ds, "left", "right") + # Test that output contains correct datatype, dimensions, and values head_vector = navigation.compute_head_direction_vector( mock_dataset, "left_ear", "right_ear" From e38d552f317f3e7356f56c25d7a226bae2d328c2 Mon Sep 17 00:00:00 2001 From: b-peri Date: Fri, 30 Aug 2024 16:35:00 +0100 Subject: [PATCH 06/18] Refactored `navigation.py` and implemented PR review feedback --- movement/analysis/kinematics.py | 122 +++++++++++++++++++++++++++++ movement/analysis/navigation.py | 75 ------------------ tests/test_unit/test_kinematics.py | 103 ++++++++++++++++++++++++ tests/test_unit/test_navigation.py | 93 ---------------------- 4 files changed, 225 insertions(+), 168 deletions(-) delete mode 100644 movement/analysis/navigation.py delete mode 100644 tests/test_unit/test_navigation.py diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index b2bbbf9bc..ed0abff78 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -1,5 +1,6 @@ """Compute kinematic variables like velocity and acceleration.""" +import numpy as np import xarray as xr from movement.utils.logging import log_error @@ -165,3 +166,124 @@ def compute_time_derivative(data: xr.DataArray, order: int) -> xr.DataArray: for _ in range(order): result = result.differentiate("time") return result + + +def compute_head_direction_vector( + data: xr.DataArray, left_keypoint: str, right_keypoint: str +): + """Compute the 2D head direction vector given two keypoints on the head. + + The head direction vector is computed as a vector perpendicular to the + line connecting two keypoints on either side of the head, pointing + forwards (in the rostral direction). + + Parameters + ---------- + data : xarray.DataArray + The input data representing position. This must contain + the two chosen keypoints corresponding to the left and + right of the head. + left_keypoint : str + Name of the left keypoint, e.g., "left_ear" + right_keypoint : str + Name of the right keypoint, e.g., "right_ear" + + Returns + ------- + xarray.DataArray + An xarray DataArray representing the head direction vector, + with dimensions matching the input data array, but without the + ``keypoints`` dimension. + + """ + # Validate input dataset + if left_keypoint == right_keypoint: + raise log_error( + ValueError, "The left and right keypoints may not be identical." + ) + if len(data.space) != 2: + raise log_error( + ValueError, + "Input data must have 2 (and only 2) spatial dimensions, but " + f"currently has {len(data.space)}.", + ) + + # Select the right and left keypoints + head_left = data.sel(keypoints=left_keypoint, drop=True) + head_right = data.sel(keypoints=right_keypoint, drop=True) + + # Initialize a vector from right to left ear, and another vector + # perpendicular to the X-Y plane + right_to_left_vector = head_left - head_right + perpendicular_vector = np.array([0, 0, -1]) + + # Compute cross product + head_vector = head_right.copy() + head_vector.values = np.cross(right_to_left_vector, perpendicular_vector)[ + :, :, :-1 + ] + + return head_vector + + +def _validate_time_dimension(data: xr.DataArray) -> None: + """Validate the input data contains a ``time`` dimension. + + Parameters + ---------- + data : xarray.DataArray + The input data to validate. + + Raises + ------ + ValueError + If the input data does not contain a ``time`` dimension. + + """ + if "time" not in data.dims: + raise log_error( + ValueError, "Input data must contain 'time' as a dimension." + ) + + +def _validate_type_data_array(data: xr.DataArray) -> None: + """Validate the input data is an xarray DataArray. + + Parameters + ---------- + data : xarray.DataArray + The input data to validate. + + Raises + ------ + ValueError + If the input data is not an xarray DataArray. + + """ + if not isinstance(data, xr.DataArray): + raise log_error( + TypeError, + f"Input data must be an xarray.DataArray, but got {type(data)}.", + ) + + +def _validate_time_keypoints_space_dimensions(data: xr.DataArray) -> None: + """Validate if input data contains ``time``, ``keypoints`` and ``space``. + + Parameters + ---------- + data : xarray.DataArray + The input data to validate. + + Raises + ------ + ValueError + If the input data is not an xarray DataArray. + + """ + if not all(coord in data.dims for coord in ["time", "keypoints", "space"]): + raise log_error( + AttributeError, + "Input data must contain 'time', 'space', and 'keypoints' as " + "dimensions.", + ) \ No newline at end of file diff --git a/movement/analysis/navigation.py b/movement/analysis/navigation.py deleted file mode 100644 index 022d713f0..000000000 --- a/movement/analysis/navigation.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Extract variables useful for the study of spatial navigation.""" - -import numpy as np -import xarray as xr - -from movement.utils.logging import log_error - - -def compute_head_direction_vector( - data: xr.DataArray, left_keypoint: str, right_keypoint: str -): - """Compute the 2D head direction vector given two keypoints on the head. - - The head direction vector is computed as a vector perpendicular to the - line connecting two keypoints on either side of the head, pointing - forwards (in the rostral direction). - - Parameters - ---------- - data : xarray.DataArray - The input data representing position. This must contain - the two chosen keypoints corresponding to the left and - right of the head. - left_keypoint : str - Name of the left keypoint, e.g., "left_ear" - right_keypoint : str - Name of the right keypoint, e.g., "right_ear" - - Returns - ------- - xarray.DataArray - An xarray DataArray representing the head direction vector, - with dimensions matching the input data array, but without the - ``keypoints`` dimension. - - """ - # Validate input dataset - if not isinstance(data, xr.DataArray): - raise log_error( - TypeError, - f"Input data must be an xarray.DataArray, but got {type(data)}.", - ) - if not all(coord in data.dims for coord in ["time", "keypoints", "space"]): - raise log_error( - AttributeError, - "Input data must contain 'time', 'space', and 'keypoints' as " - "dimensions.", - ) - if left_keypoint == right_keypoint: - raise log_error( - ValueError, "The left and right keypoints may not be identical." - ) - if len(data.space) != 2: - raise log_error( - ValueError, - "Input data must have 2 (and only 2) spatial dimensions, but " - f"currently has {len(data.space)}.", - ) - - # Select the right and left keypoints - head_left = data.sel(keypoints=left_keypoint, drop=True) - head_right = data.sel(keypoints=right_keypoint, drop=True) - - # Initialize a vector from right to left ear, and another vector - # perpendicular to the X-Y plane - right_to_left_vector = head_left - head_right - perpendicular_vector = np.array([0, 0, -1]) - - # Compute cross product - head_vector = head_right.copy() - head_vector.values = np.cross(right_to_left_vector, perpendicular_vector)[ - :, :, :-1 - ] - - return head_vector diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index a1b933e0b..a975fab2d 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -182,3 +182,106 @@ def test_approximate_derivative_with_invalid_order(order): expected_exception = ValueError if isinstance(order, int) else TypeError with pytest.raises(expected_exception): kinematics.compute_time_derivative(data, order=order) + + +class TestNavigation: + """Test suite for navigation-related functions in the kinematics module.""" + + @pytest.fixture + def mock_data_array(self): + """Return a mock DataArray containing four known head orientations.""" + time = [0, 1, 2, 3] + individuals = ["individual_0"] + keypoints = ["left_ear", "right_ear", "nose"] + space = ["x", "y"] + + ds = xr.DataArray( + [ + [[[1, 0], [-1, 0], [0, -1]]], # time 0 + [[[0, 1], [0, -1], [1, 0]]], # time 1 + [[[-1, 0], [1, 0], [0, 1]]], # time 2 + [[[0, -1], [0, 1], [-1, 0]]], # time 3 + ], + dims=["time", "individuals", "keypoints", "space"], + coords={ + "time": time, + "individuals": individuals, + "keypoints": keypoints, + "space": space, + }, + ) + return ds + + @pytest.fixture + def mock_data_array_3D(self): + """Return a 3D DataArray containing a known head orientation.""" + time = [0] + individuals = ["individual_0"] + keypoints = ["left", "right"] + space = ["x", "y", "z"] + + ds = xr.DataArray( + [ + [[[1, 0, 0], [-1, 0, 0]]], + ], + dims=["time", "individuals", "keypoints", "space"], + coords={ + "time": time, + "individuals": individuals, + "keypoints": keypoints, + "space": space, + }, + ) + return ds + + def test_compute_head_direction_vector( + self, mock_data_array, mock_data_array_3D + ): + """Test that the correct head direction vectors + are computed from a basic mock dataset. + """ + # Test that validators work + + # Catch incorrect datatype + with pytest.raises(TypeError, match="must be an xarray.DataArray"): + kinematics.compute_head_direction_vector( + mock_data_array.values, "left_ear", "right_ear" + ) + + # Catch incorrect dimensions + with pytest.raises( + AttributeError, match="'time', 'space', and 'keypoints'" + ): + mock_data_keypoint = mock_data_array.sel( + keypoints="nose", drop=True + ) + kinematics.compute_head_direction_vector( + mock_data_keypoint, "left_ear", "right_ear" + ) + + # Catch identical left and right keypoints + with pytest.raises(ValueError, match="keypoints may not be identical"): + kinematics.compute_head_direction_vector( + mock_data_array, "left_ear", "left_ear" + ) + + # Catch incorrect spatial dimensions + with pytest.raises( + ValueError, match="must have 2 (and only 2) spatial dimensions" + ): + kinematics.compute_head_direction_vector( + mock_data_array_3D, "left", "right" + ) + + # Test that output contains correct datatype, dimensions, and values + head_vector = kinematics.compute_head_direction_vector( + mock_data_array, "left_ear", "right_ear" + ) + known_vectors = np.array([[[0, 2]], [[-2, 0]], [[0, -2]], [[2, 0]]]) + + assert ( + isinstance(head_vector, xr.DataArray) + and ("space" in head_vector.dims) + and ("keypoints" not in head_vector.dims) + ) + assert np.equal(head_vector.values, known_vectors).all() \ No newline at end of file diff --git a/tests/test_unit/test_navigation.py b/tests/test_unit/test_navigation.py deleted file mode 100644 index a8a90e028..000000000 --- a/tests/test_unit/test_navigation.py +++ /dev/null @@ -1,93 +0,0 @@ -import numpy as np -import pytest -import xarray as xr - -from movement.analysis import navigation - - -@pytest.fixture -def mock_dataset(): - """Return a mock DataArray containing four known head orientations.""" - time = np.array([0, 1, 2, 3]) - individuals = np.array(["individual_0"]) - keypoints = np.array(["left_ear", "right_ear", "nose"]) - space = np.array(["x", "y"]) - - ds = xr.DataArray( - [ - [[[1, 0], [-1, 0], [0, -1]]], # time 0 - [[[0, 1], [0, -1], [1, 0]]], # time 1 - [[[-1, 0], [1, 0], [0, 1]]], # time 2 - [[[0, -1], [0, 1], [-1, 0]]], # time 3 - ], - dims=["time", "individuals", "keypoints", "space"], - coords={ - "time": time, - "individuals": individuals, - "keypoints": keypoints, - "space": space, - }, - ) - return ds - - -def test_compute_head_direction_vector(mock_dataset): - """Test that the correct head direction vectors - are computed from a basic mock dataset. - """ - # Test that validators work - with pytest.raises(TypeError): # Catch incorrect datatype - np_array = [ - [[[1, 0], [-1, 0], [0, -1]]], - [[[0, 1], [0, -1], [1, 0]]], - [[[-1, 0], [1, 0], [0, 1]]], - [[[0, -1], [0, 1], [-1, 0]]], - ] - navigation.compute_head_direction_vector( - np_array, "left_ear", "right_ear" - ) - - with pytest.raises(AttributeError): # Catch incorrect dimensions - mock_dataset_keypoint = mock_dataset.sel(keypoints="nose", drop=True) - navigation.compute_head_direction_vector( - mock_dataset_keypoint, "left_ear", "right_ear" - ) - - with pytest.raises(ValueError): # Catch identical left and right keypoints - navigation.compute_head_direction_vector( - mock_dataset, "left_ear", "left_ear" - ) - - with pytest.raises(ValueError): # Catch incorrect spatial dimensions - time = np.array([0]) - individuals = np.array(["individual_0"]) - keypoints = np.array(["left", "right"]) - space = np.array(["x", "y", "z"]) - - ds = xr.DataArray( - [ - [[[1, 0, 0], [-1, 0, 0]]], - ], - dims=["time", "individuals", "keypoints", "space"], - coords={ - "time": time, - "individuals": individuals, - "keypoints": keypoints, - "space": space, - }, - ) - - navigation.compute_head_direction_vector(ds, "left", "right") - - # Test that output contains correct datatype, dimensions, and values - head_vector = navigation.compute_head_direction_vector( - mock_dataset, "left_ear", "right_ear" - ) - known_vectors = np.array([[[0, 2]], [[-2, 0]], [[0, -2]], [[2, 0]]]) - - assert ( - isinstance(head_vector, xr.DataArray) - and ("space" in head_vector.dims) - and ("keypoints" not in head_vector.dims) - ) - assert np.equal(head_vector.values, known_vectors).all() From cd9c3b23cb954296b54088bfb8878aa2ce66b0d4 Mon Sep 17 00:00:00 2001 From: b-peri Date: Tue, 10 Sep 2024 10:57:48 +0100 Subject: [PATCH 07/18] Extended testing and added `front_keypoint` argument to `compute_head_direction_vector()` --- movement/analysis/kinematics.py | 35 +++++++++++++++++++++++++-- tests/test_unit/test_kinematics.py | 38 ++++++++++++++++++++++-------- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index ed0abff78..9950b0cc3 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -5,6 +5,7 @@ from movement.utils.logging import log_error from movement.validators.arrays import validate_dims_coords +from movement.utils.vector import convert_to_unit def compute_displacement(data: xr.DataArray) -> xr.DataArray: @@ -169,13 +170,19 @@ def compute_time_derivative(data: xr.DataArray, order: int) -> xr.DataArray: def compute_head_direction_vector( - data: xr.DataArray, left_keypoint: str, right_keypoint: str + data: xr.DataArray, + left_keypoint: str, + right_keypoint: str, + front_keypoint: str | None = None, ): """Compute the 2D head direction vector given two keypoints on the head. The head direction vector is computed as a vector perpendicular to the line connecting two keypoints on either side of the head, pointing - forwards (in the rostral direction). + forwards (in the rostral direction). As the forward direction may + differ between coordinate systems, the front keypoint is used ..., + when present. Otherwise, we assume that coordinates are given in the + image coordinate system (where the origin is located in the top-left). Parameters ---------- @@ -187,6 +194,8 @@ def compute_head_direction_vector( Name of the left keypoint, e.g., "left_ear" right_keypoint : str Name of the right keypoint, e.g., "right_ear" + front_keypoint : str | None + (Optional) Name of the front keypoint, e.g., "nose". Returns ------- @@ -197,6 +206,9 @@ def compute_head_direction_vector( """ # Validate input dataset + _validate_type_data_array(data) + _validate_time_keypoints_space_dimensions(data) + if left_keypoint == right_keypoint: raise log_error( ValueError, "The left and right keypoints may not be identical." @@ -223,6 +235,25 @@ def compute_head_direction_vector( :, :, :-1 ] + # Check computed head_vector is pointing in the same direction as vector + # from head midpoint to snout + if front_keypoint: + head_front = data.sel(keypoints=front_keypoint, drop=True) + head_midpoint = (head_right + head_left) / 2 + mid_to_front_vector = head_front - head_midpoint + dot_product_array = ( + convert_to_unit(head_vector.sel(individuals=data.individuals[0])) + * convert_to_unit(mid_to_front_vector).sel( + individuals=data.individuals[0] + ) + ).sum(dim="space") + median_dot_product = float(dot_product_array.median(dim="time").values) + if median_dot_product < 0: + perpendicular_vector = np.array([0, 0, 1]) + head_vector.values = np.cross( + right_to_left_vector, perpendicular_vector + )[:, :, :-1] + return head_vector diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index a975fab2d..2ed7ca046 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -1,3 +1,6 @@ +import re +from contextlib import nullcontext as does_not_raise + import numpy as np import pytest import xarray as xr @@ -188,7 +191,7 @@ class TestNavigation: """Test suite for navigation-related functions in the kinematics module.""" @pytest.fixture - def mock_data_array(self): + def mock_dataarray(self): """Return a mock DataArray containing four known head orientations.""" time = [0, 1, 2, 3] individuals = ["individual_0"] @@ -213,7 +216,7 @@ def mock_data_array(self): return ds @pytest.fixture - def mock_data_array_3D(self): + def mock_dataarray_3D(self): """Return a 3D DataArray containing a known head orientation.""" time = [0] individuals = ["individual_0"] @@ -235,7 +238,7 @@ def mock_data_array_3D(self): return ds def test_compute_head_direction_vector( - self, mock_data_array, mock_data_array_3D + self, mock_dataarray, mock_dataarray_3D ): """Test that the correct head direction vectors are computed from a basic mock dataset. @@ -245,14 +248,14 @@ def test_compute_head_direction_vector( # Catch incorrect datatype with pytest.raises(TypeError, match="must be an xarray.DataArray"): kinematics.compute_head_direction_vector( - mock_data_array.values, "left_ear", "right_ear" + mock_dataarray.values, "left_ear", "right_ear" ) # Catch incorrect dimensions with pytest.raises( AttributeError, match="'time', 'space', and 'keypoints'" ): - mock_data_keypoint = mock_data_array.sel( + mock_data_keypoint = mock_dataarray.sel( keypoints="nose", drop=True ) kinematics.compute_head_direction_vector( @@ -262,20 +265,21 @@ def test_compute_head_direction_vector( # Catch identical left and right keypoints with pytest.raises(ValueError, match="keypoints may not be identical"): kinematics.compute_head_direction_vector( - mock_data_array, "left_ear", "left_ear" + mock_dataarray, "left_ear", "left_ear" ) # Catch incorrect spatial dimensions with pytest.raises( - ValueError, match="must have 2 (and only 2) spatial dimensions" + ValueError, + match=re.escape("must have 2 (and only 2) spatial dimensions"), ): kinematics.compute_head_direction_vector( - mock_data_array_3D, "left", "right" + mock_dataarray_3D, "left", "right" ) # Test that output contains correct datatype, dimensions, and values head_vector = kinematics.compute_head_direction_vector( - mock_data_array, "left_ear", "right_ear" + mock_dataarray, "left_ear", "right_ear" ) known_vectors = np.array([[[0, 2]], [[-2, 0]], [[0, -2]], [[2, 0]]]) @@ -284,4 +288,18 @@ def test_compute_head_direction_vector( and ("space" in head_vector.dims) and ("keypoints" not in head_vector.dims) ) - assert np.equal(head_vector.values, known_vectors).all() \ No newline at end of file + + assert np.equal(head_vector.values, known_vectors).all() + + # Test behaviour with NaNs + nan_dataarray = mock_dataarray.where( + (mock_dataarray.time != 1) + | (mock_dataarray.keypoints != "left_ear") + ) + head_vector = kinematics.compute_head_direction_vector( + nan_dataarray, "left_ear", "right_ear" + ) + assert ( + np.isnan(head_vector.values[1, 0, :]).all() + and not np.isnan(head_vector.values[[0, 2, 3], 0, :]).any() + ) From 39208d4a553559635e9c880632aab8ac853412ac Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 10:11:09 +0000 Subject: [PATCH 08/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_unit/test_kinematics.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 2ed7ca046..d2f77de48 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -1,5 +1,4 @@ import re -from contextlib import nullcontext as does_not_raise import numpy as np import pytest From dd20af6bbc9093896782d46e6ad24b53641ae1b9 Mon Sep 17 00:00:00 2001 From: b-peri Date: Mon, 16 Sep 2024 12:39:51 +0100 Subject: [PATCH 09/18] Implemented PR feedback and bugfixes for `compute_2d_head_direction_vector()` --- movement/analysis/kinematics.py | 116 ++++++++------ tests/test_unit/test_kinematics.py | 248 ++++++++++++++++------------- 2 files changed, 204 insertions(+), 160 deletions(-) diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index 9950b0cc3..69c548bea 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -1,11 +1,12 @@ """Compute kinematic variables like velocity and acceleration.""" import numpy as np +import numpy.typing as npt import xarray as xr from movement.utils.logging import log_error from movement.validators.arrays import validate_dims_coords -from movement.utils.vector import convert_to_unit +from movement.utils.vector import compute_norm def compute_displacement(data: xr.DataArray) -> xr.DataArray: @@ -169,20 +170,44 @@ def compute_time_derivative(data: xr.DataArray, order: int) -> xr.DataArray: return result -def compute_head_direction_vector( +def compute_2d_head_direction_vector( data: xr.DataArray, left_keypoint: str, right_keypoint: str, - front_keypoint: str | None = None, + upward_vector: npt.ArrayLike = (0, 0, -1), ): """Compute the 2D head direction vector given two keypoints on the head. The head direction vector is computed as a vector perpendicular to the - line connecting two keypoints on either side of the head, pointing - forwards (in the rostral direction). As the forward direction may - differ between coordinate systems, the front keypoint is used ..., - when present. Otherwise, we assume that coordinates are given in the - image coordinate system (where the origin is located in the top-left). + line connecting two symmetrical keypoints on either side of the head + (i.e., symmetrical relative to the sagittal plane), and pointing + forwards (in the rostral direction). A top-down or bottom-up view of the + animal is assumed. + + 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. For the forward direction, if no additional information is + provided, we assume the keypoints are expressed in the image coordinate + system (where the origin is located in the top-left corner of the screen), + and that the analysed image is a top-down view of the animal. In this case + the upward direction of the animal is the negative z direction of the image + coordinate system. Alternatively, users can specify the upward direction + of the animal directly. + + 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. + + Notes + ----- + If specified, the upward direction must be expressed in the same coordinate + system as the keypoint data. + + Note that the assumed upward direction would be incorrect if the animal + is recorded from its belly (bottom-up view). The default upward direction + would be the negative z direction in the image coordinate system, but the + true upward direction of the animal is the positive z direction. Parameters ---------- @@ -194,8 +219,10 @@ def compute_head_direction_vector( Name of the left keypoint, e.g., "left_ear" right_keypoint : str Name of the right keypoint, e.g., "right_ear" - front_keypoint : str | None - (Optional) Name of the front keypoint, e.g., "nose". + upward_vector : array-like, optional + The upward vector in the coordinate system the keypoints are in. + By default, it is the negative z-axis in the image coordinate + system, i.e., [0, 0, -1]. Returns ------- @@ -205,14 +232,9 @@ def compute_head_direction_vector( ``keypoints`` dimension. """ - # Validate input dataset + # Validate input data _validate_type_data_array(data) _validate_time_keypoints_space_dimensions(data) - - if left_keypoint == right_keypoint: - raise log_error( - ValueError, "The left and right keypoints may not be identical." - ) if len(data.space) != 2: raise log_error( ValueError, @@ -220,41 +242,33 @@ def compute_head_direction_vector( f"currently has {len(data.space)}.", ) - # Select the right and left keypoints - head_left = data.sel(keypoints=left_keypoint, drop=True) - head_right = data.sel(keypoints=right_keypoint, drop=True) - - # Initialize a vector from right to left ear, and another vector - # perpendicular to the X-Y plane - right_to_left_vector = head_left - head_right - perpendicular_vector = np.array([0, 0, -1]) - - # Compute cross product - head_vector = head_right.copy() - head_vector.values = np.cross(right_to_left_vector, perpendicular_vector)[ - :, :, :-1 - ] - - # Check computed head_vector is pointing in the same direction as vector - # from head midpoint to snout - if front_keypoint: - head_front = data.sel(keypoints=front_keypoint, drop=True) - head_midpoint = (head_right + head_left) / 2 - mid_to_front_vector = head_front - head_midpoint - dot_product_array = ( - convert_to_unit(head_vector.sel(individuals=data.individuals[0])) - * convert_to_unit(mid_to_front_vector).sel( - individuals=data.individuals[0] - ) - ).sum(dim="space") - median_dot_product = float(dot_product_array.median(dim="time").values) - if median_dot_product < 0: - perpendicular_vector = np.array([0, 0, 1]) - head_vector.values = np.cross( - right_to_left_vector, perpendicular_vector - )[:, :, :-1] - - return head_vector + # Validate input keypoints + if left_keypoint == right_keypoint: + raise log_error( + ValueError, "The left and right keypoints may not be identical." + ) + + # Define right-to-left vector + right_to_left_vector = data.sel( + keypoints=left_keypoint, drop=True + ) - data.sel(keypoints=right_keypoint, drop=True) + + # Define upward vector + # default: negative z direction in the image coordinate system + upward_vector = xr.DataArray( + np.tile(np.array(upward_vector).reshape(1, -1), [len(data.time), 1]), + dims=["time", "space"], + ) + + # Compute forward direction as the cross product + # (right-to-left) cross (forward) = up + forward_vector = xr.cross( + right_to_left_vector, upward_vector, dim="space" + )[:, :, :-1] # keep only the first 2 dimensions of the result + + # Return unit vector + + return forward_vector / compute_norm(forward_vector) def _validate_time_dimension(data: xr.DataArray) -> None: diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index d2f77de48..c6c4235cd 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -186,119 +186,149 @@ def test_approximate_derivative_with_invalid_order(order): kinematics.compute_time_derivative(data, order=order) -class TestNavigation: - """Test suite for navigation-related functions in the kinematics module.""" - - @pytest.fixture - def mock_dataarray(self): - """Return a mock DataArray containing four known head orientations.""" - time = [0, 1, 2, 3] - individuals = ["individual_0"] - keypoints = ["left_ear", "right_ear", "nose"] - space = ["x", "y"] - - ds = xr.DataArray( - [ - [[[1, 0], [-1, 0], [0, -1]]], # time 0 - [[[0, 1], [0, -1], [1, 0]]], # time 1 - [[[-1, 0], [1, 0], [0, 1]]], # time 2 - [[[0, -1], [0, 1], [-1, 0]]], # time 3 - ], - dims=["time", "individuals", "keypoints", "space"], - coords={ - "time": time, - "individuals": individuals, - "keypoints": keypoints, - "space": space, - }, - ) - return ds +@pytest.fixture +def valid_data_array_for_head_vector(): + """Return a position data array for an individual with 3 keypoints + (left ear, right ear and nose), tracked for 4 frames, in x-y space. + """ + time = [0, 1, 2, 3] + individuals = ["individual_0"] + keypoints = ["left_ear", "right_ear", "nose"] + space = ["x", "y"] + + ds = xr.DataArray( + [ + [[[1, 0], [-1, 0], [0, -1]]], # time 0 + [[[0, 1], [0, -1], [1, 0]]], # time 1 + [[[-1, 0], [1, 0], [0, 1]]], # time 2 + [[[0, -1], [0, 1], [-1, 0]]], # time 3 + ], + dims=["time", "individuals", "keypoints", "space"], + coords={ + "time": time, + "individuals": individuals, + "keypoints": keypoints, + "space": space, + }, + ) + return ds - @pytest.fixture - def mock_dataarray_3D(self): - """Return a 3D DataArray containing a known head orientation.""" - time = [0] - individuals = ["individual_0"] - keypoints = ["left", "right"] - space = ["x", "y", "z"] - ds = xr.DataArray( - [ - [[[1, 0, 0], [-1, 0, 0]]], - ], - dims=["time", "individuals", "keypoints", "space"], - coords={ - "time": time, - "individuals": individuals, - "keypoints": keypoints, - "space": space, - }, - ) - return ds - - def test_compute_head_direction_vector( - self, mock_dataarray, mock_dataarray_3D - ): - """Test that the correct head direction vectors - are computed from a basic mock dataset. - """ - # Test that validators work - - # Catch incorrect datatype - with pytest.raises(TypeError, match="must be an xarray.DataArray"): - kinematics.compute_head_direction_vector( - mock_dataarray.values, "left_ear", "right_ear" - ) - - # Catch incorrect dimensions - with pytest.raises( - AttributeError, match="'time', 'space', and 'keypoints'" - ): - mock_data_keypoint = mock_dataarray.sel( - keypoints="nose", drop=True - ) - kinematics.compute_head_direction_vector( - mock_data_keypoint, "left_ear", "right_ear" - ) - - # Catch identical left and right keypoints - with pytest.raises(ValueError, match="keypoints may not be identical"): - kinematics.compute_head_direction_vector( - mock_dataarray, "left_ear", "left_ear" - ) - - # Catch incorrect spatial dimensions - with pytest.raises( +@pytest.fixture +def invalid_input_type_for_head_vector(valid_data_array_for_head_vector): + """Return a numpy array of position values by individual, per keypoint, + over time. + """ + return valid_data_array_for_head_vector.values + + +@pytest.fixture +def invalid_dimensions_for_head_vector(valid_data_array_for_head_vector): + """Return a position DataArray in which the ``keypoints`` dimension has + been dropped. + """ + return valid_data_array_for_head_vector.sel(keypoints="nose", drop=True) + + +@pytest.fixture +def invalid_spatial_dimensions_for_head_vector( + valid_data_array_for_head_vector, +): + """Return a position DataArray containing three spatial dimensions.""" + dataarray_3d = valid_data_array_for_head_vector.pad( + space=(0, 1), constant_values=0 + ) + return dataarray_3d.assign_coords(space=["x", "y", "z"]) + + +@pytest.fixture +def valid_data_array_for_head_vector_with_NaNs( + valid_data_array_for_head_vector, +): + """Return a position DataArray where position values are NaN for the + ``left_ear`` keypoint at time ``1``. + """ + nan_dataarray = valid_data_array_for_head_vector.where( + (valid_data_array_for_head_vector.time != 1) + | (valid_data_array_for_head_vector.keypoints != "left_ear") + ) + return nan_dataarray + + +def test_compute_2d_head_direction_vector(valid_data_array_for_head_vector): + """Test that the correct output head direction vectors + are computed from a valid mock dataset. + """ + head_vector = kinematics.compute_2d_head_direction_vector( + valid_data_array_for_head_vector, "left_ear", "right_ear" + ) + known_vectors = np.array([[[0, 1]], [[-1, 0]], [[0, -1]], [[1, 0]]]) + + assert ( + isinstance(head_vector, xr.DataArray) + and ("space" in head_vector.dims) + and ("keypoints" not in head_vector.dims) + ) + assert np.equal(head_vector.values, known_vectors).all() + + +@pytest.mark.parametrize( + "input_data, expected_error, expected_match_str, keypoints", + [ + ( + "invalid_input_type_for_head_vector", + TypeError, + "must be an xarray.DataArray", + ["left_ear", "right_ear"], + ), + ( + "invalid_dimensions_for_head_vector", + AttributeError, + "'time', 'space', and 'keypoints'", + ["left_ear", "right_ear"], + ), + ( + "invalid_spatial_dimensions_for_head_vector", ValueError, - match=re.escape("must have 2 (and only 2) spatial dimensions"), - ): - kinematics.compute_head_direction_vector( - mock_dataarray_3D, "left", "right" - ) - - # Test that output contains correct datatype, dimensions, and values - head_vector = kinematics.compute_head_direction_vector( - mock_dataarray, "left_ear", "right_ear" - ) - known_vectors = np.array([[[0, 2]], [[-2, 0]], [[0, -2]], [[2, 0]]]) + "must have 2 (and only 2) spatial dimensions", + ["left_ear", "right_ear"], + ), + ( + "valid_data_array_for_head_vector", + ValueError, + "keypoints may not be identical", + ["left_ear", "left_ear"], + ), + ], +) +def test_compute_2d_head_direction_vector_with_invalid_input( + input_data, keypoints, expected_error, expected_match_str, request +): + """Test that ``compute_2d_head_direction_vector`` catches errors + correctly when passed invalid inputs. + """ + # Get fixture + input_data = request.getfixturevalue(input_data) - assert ( - isinstance(head_vector, xr.DataArray) - and ("space" in head_vector.dims) - and ("keypoints" not in head_vector.dims) + # Catch error + with pytest.raises(expected_error, match=re.escape(expected_match_str)): + kinematics.compute_2d_head_direction_vector( + input_data, keypoints[0], keypoints[1] ) - assert np.equal(head_vector.values, known_vectors).all() - # Test behaviour with NaNs - nan_dataarray = mock_dataarray.where( - (mock_dataarray.time != 1) - | (mock_dataarray.keypoints != "left_ear") - ) - head_vector = kinematics.compute_head_direction_vector( - nan_dataarray, "left_ear", "right_ear" - ) - assert ( - np.isnan(head_vector.values[1, 0, :]).all() - and not np.isnan(head_vector.values[[0, 2, 3], 0, :]).any() - ) +def test_nan_behavior_2D_head_vector( + valid_data_array_for_head_vector_with_NaNs, +): + """Test that ``compute_head_direction_vector()`` generates the + expected output for a valid input DataArray containing ``NaN`` + position values at a single time (``1``) and keypoint + (``left_ear``). + """ + head_vector = kinematics.compute_2d_head_direction_vector( + valid_data_array_for_head_vector_with_NaNs, "left_ear", "right_ear" + ) + assert ( + np.isnan(head_vector.values[1, 0, :]).all() + and not np.isnan(head_vector.values[[0, 2, 3], 0, :]).any() + ) From 6420ce07f2430b0a6d9df311944e106fb78d9513 Mon Sep 17 00:00:00 2001 From: b-peri Date: Mon, 16 Sep 2024 13:59:56 +0100 Subject: [PATCH 10/18] Removed uppercase letters from function names --- tests/test_unit/test_kinematics.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index c6c4235cd..2bebb3765 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -242,7 +242,7 @@ def invalid_spatial_dimensions_for_head_vector( @pytest.fixture -def valid_data_array_for_head_vector_with_NaNs( +def valid_data_array_for_head_vector_with_nans( valid_data_array_for_head_vector, ): """Return a position DataArray where position values are NaN for the @@ -317,8 +317,8 @@ def test_compute_2d_head_direction_vector_with_invalid_input( ) -def test_nan_behavior_2D_head_vector( - valid_data_array_for_head_vector_with_NaNs, +def test_nan_behavior_2d_head_vector( + valid_data_array_for_head_vector_with_nans, ): """Test that ``compute_head_direction_vector()`` generates the expected output for a valid input DataArray containing ``NaN`` @@ -326,7 +326,7 @@ def test_nan_behavior_2D_head_vector( (``left_ear``). """ head_vector = kinematics.compute_2d_head_direction_vector( - valid_data_array_for_head_vector_with_NaNs, "left_ear", "right_ear" + valid_data_array_for_head_vector_with_nans, "left_ear", "right_ear" ) assert ( np.isnan(head_vector.values[1, 0, :]).all() From 75badfc5b84282a44c8800ce51ef99f5a799e806 Mon Sep 17 00:00:00 2001 From: b-peri Date: Mon, 16 Sep 2024 16:17:19 +0100 Subject: [PATCH 11/18] Tweaked `compute_polar_coordinates.py` to use new function --- examples/compute_polar_coordinates.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/examples/compute_polar_coordinates.py b/examples/compute_polar_coordinates.py index bad25b790..3fe981ce5 100644 --- a/examples/compute_polar_coordinates.py +++ b/examples/compute_polar_coordinates.py @@ -15,6 +15,7 @@ from matplotlib import pyplot as plt from movement import sample_data +from movement.analysis.kinematics import compute_2d_head_direction_vector from movement.io import load_poses from movement.utils.vector import cart2pol, pol2cart @@ -48,20 +49,16 @@ # To demonstrate how polar coordinates can be useful in behavioural analyses, # we will compute the head vector of the mouse. # -# We define it as the vector from the midpoint between the ears to the snout. - -# compute the midpoint between the ears -midpoint_ears = position.sel(keypoints=["left_ear", "right_ear"]).mean( - dim="keypoints" +# In ``movement``, head vector is defined as the vector perpendicular to the +# line connecting two symmetrical keypoints on either side of the head (usually +# the ears), pointing forwards. (See :func:`here\ +# ` for a more +# detailed explanation). + +head_vector = compute_2d_head_direction_vector( + position, "left_ear", "right_ear" ) -# compute the head vector -head_vector = position.sel(keypoints="snout") - midpoint_ears - -# drop the keypoints dimension -# (otherwise the `head_vector` data array retains a `snout` keypoint from the -# operation above) -head_vector = head_vector.drop_vars("keypoints") # %% # Visualise the head trajectory @@ -72,9 +69,12 @@ # We can start by plotting the trajectory of the midpoint between the ears. We # will refer to this as the head trajectory. -fig, ax = plt.subplots(1, 1) +midpoint_ears = position.sel(keypoints=["left_ear", "right_ear"]).mean( + dim="keypoints" +) mouse_name = ds.individuals.values[0] +fig, ax = plt.subplots(1, 1) sc = ax.scatter( midpoint_ears.sel(individuals=mouse_name, space="x"), midpoint_ears.sel(individuals=mouse_name, space="y"), From 870933747de198805eaa1883b05e3020e0b0301f Mon Sep 17 00:00:00 2001 From: b-peri Date: Tue, 17 Sep 2024 16:22:08 +0100 Subject: [PATCH 12/18] Implemented feedback from Zulip discussion and created `compute_head_direction_vector()` alias function --- examples/compute_polar_coordinates.py | 8 +- movement/analysis/kinematics.py | 110 ++++++++++++++++++-------- 2 files changed, 80 insertions(+), 38 deletions(-) diff --git a/examples/compute_polar_coordinates.py b/examples/compute_polar_coordinates.py index 3fe981ce5..ef0622f4e 100644 --- a/examples/compute_polar_coordinates.py +++ b/examples/compute_polar_coordinates.py @@ -15,7 +15,7 @@ from matplotlib import pyplot as plt from movement import sample_data -from movement.analysis.kinematics import compute_2d_head_direction_vector +from movement.analysis.kinematics import compute_head_direction_vector from movement.io import load_poses from movement.utils.vector import cart2pol, pol2cart @@ -52,12 +52,10 @@ # In ``movement``, head vector is defined as the vector perpendicular to the # line connecting two symmetrical keypoints on either side of the head (usually # the ears), pointing forwards. (See :func:`here\ -# ` for a more +# ` for a more # detailed explanation). -head_vector = compute_2d_head_direction_vector( - position, "left_ear", "right_ear" -) +head_vector = compute_head_direction_vector(position, "left_ear", "right_ear") # %% diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index 69c548bea..2621e3e3d 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -1,7 +1,8 @@ """Compute kinematic variables like velocity and acceleration.""" +from typing import Literal + import numpy as np -import numpy.typing as npt import xarray as xr from movement.utils.logging import log_error @@ -170,65 +171,60 @@ def compute_time_derivative(data: xr.DataArray, order: int) -> xr.DataArray: return result -def compute_2d_head_direction_vector( +def compute_forward_vector( data: xr.DataArray, left_keypoint: str, right_keypoint: str, - upward_vector: npt.ArrayLike = (0, 0, -1), + camera_view: Literal["top_down", "bottom_up"] = "top_down", ): - """Compute the 2D head direction vector given two keypoints on the head. + """Compute a 2D forward vector given two left-right symmetrical keypoints. - The head direction vector is computed as a vector perpendicular to the - line connecting two symmetrical keypoints on either side of the head - (i.e., symmetrical relative to the sagittal plane), and pointing + 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. 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. For the forward direction, if no additional information is - provided, we assume the keypoints are expressed in the image coordinate - system (where the origin is located in the top-left corner of the screen), - and that the analysed image is a top-down view of the animal. In this case - the upward direction of the animal is the negative z direction of the image - coordinate system. Alternatively, users can specify the upward direction - of the animal directly. + 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. - Notes - ----- - If specified, the upward direction must be expressed in the same coordinate - system as the keypoint data. - - Note that the assumed upward direction would be incorrect if the animal - is recorded from its belly (bottom-up view). The default upward direction - would be the negative z direction in the image coordinate system, but the - true upward direction of the animal is the positive z direction. - Parameters ---------- data : xarray.DataArray The input data representing position. This must contain - the two chosen keypoints corresponding to the left and - right of the head. + 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" - upward_vector : array-like, optional - The upward vector in the coordinate system the keypoints are in. - By default, it is the negative z-axis in the image coordinate - system, i.e., [0, 0, -1]. + 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"``. Returns ------- xarray.DataArray - An xarray DataArray representing the head direction vector, - with dimensions matching the input data array, but without the + An xarray DataArray representing the forward vector, with + dimensions matching the input data array, but without the ``keypoints`` dimension. """ @@ -255,8 +251,13 @@ def compute_2d_head_direction_vector( # Define upward vector # default: negative z direction in the image coordinate system + if camera_view == "top-down": + upward_vector = np.array([0, 0, -1]) + else: + upward_vector = np.array([0, 0, 1]) + upward_vector = xr.DataArray( - np.tile(np.array(upward_vector).reshape(1, -1), [len(data.time), 1]), + np.tile(upward_vector.reshape(1, -1), [len(data.time), 1]), dims=["time", "space"], ) @@ -271,6 +272,49 @@ def compute_2d_head_direction_vector( return forward_vector / compute_norm(forward_vector) +def compute_head_direction_vector( + data: xr.DataArray, + left_keypoint: str, + right_keypoint: str, + camera_view: Literal["top_down", "bottom_up"] = "top_down", +): + """Compute the 2D head direction vector given two keypoints on the head. + + This function is an alias for :func:`compute_forward_vector\ + `. For more + detailed information on how the head direction vector is computed, + please refer to the documentation for this function. + + Parameters + ---------- + data : xarray.DataArray + The input data representing position. This must contain + the two chosen keypoints corresponding to the left and + right of the head. + left_keypoint : str + Name of the left keypoint, e.g., "left_ear" + right_keypoint : str + Name of the right keypoint, e.g., "right_ear" + 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"``. + + Returns + ------- + xarray.DataArray + An xarray DataArray representing the head direction vector, with + dimensions matching the input data array, but without the + ``keypoints`` dimension. + + """ + return compute_forward_vector( + data, left_keypoint, right_keypoint, camera_view=camera_view + ) + + def _validate_time_dimension(data: xr.DataArray) -> None: """Validate the input data contains a ``time`` dimension. From 054d50c55ff056eb3ba9588c4374579f0d930028 Mon Sep 17 00:00:00 2001 From: b-peri Date: Tue, 17 Sep 2024 16:25:58 +0100 Subject: [PATCH 13/18] Fixed typo in docstring --- movement/analysis/kinematics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index 2621e3e3d..a7d6d3798 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -280,7 +280,7 @@ def compute_head_direction_vector( ): """Compute the 2D head direction vector given two keypoints on the head. - This function is an alias for :func:`compute_forward_vector\ + This function is an alias for :func:`compute_forward_vector()\ `. For more detailed information on how the head direction vector is computed, please refer to the documentation for this function. From df6bed93f13ef31de733e71ea24892194efe3dd8 Mon Sep 17 00:00:00 2001 From: b-peri Date: Tue, 24 Sep 2024 16:52:44 +0100 Subject: [PATCH 14/18] Tweaked `compute_forward_vector()` to use new validator --- movement/analysis/kinematics.py | 53 ++++++--------------------------- 1 file changed, 9 insertions(+), 44 deletions(-) diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index a7d6d3798..7b4b26b31 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -6,8 +6,8 @@ import xarray as xr from movement.utils.logging import log_error -from movement.validators.arrays import validate_dims_coords from movement.utils.vector import compute_norm +from movement.validators.arrays import validate_dims_coords def compute_displacement(data: xr.DataArray) -> xr.DataArray: @@ -230,7 +230,14 @@ def compute_forward_vector( """ # Validate input data _validate_type_data_array(data) - _validate_time_keypoints_space_dimensions(data) + validate_dims_coords( + data, + { + "time": [], + "keypoints": [left_keypoint, right_keypoint], + "space": [], + }, + ) if len(data.space) != 2: raise log_error( ValueError, @@ -315,26 +322,6 @@ def compute_head_direction_vector( ) -def _validate_time_dimension(data: xr.DataArray) -> None: - """Validate the input data contains a ``time`` dimension. - - Parameters - ---------- - data : xarray.DataArray - The input data to validate. - - Raises - ------ - ValueError - If the input data does not contain a ``time`` dimension. - - """ - if "time" not in data.dims: - raise log_error( - ValueError, "Input data must contain 'time' as a dimension." - ) - - def _validate_type_data_array(data: xr.DataArray) -> None: """Validate the input data is an xarray DataArray. @@ -354,25 +341,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_time_keypoints_space_dimensions(data: xr.DataArray) -> None: - """Validate if input data contains ``time``, ``keypoints`` and ``space``. - - Parameters - ---------- - data : xarray.DataArray - The input data to validate. - - Raises - ------ - ValueError - If the input data is not an xarray DataArray. - - """ - if not all(coord in data.dims for coord in ["time", "keypoints", "space"]): - raise log_error( - AttributeError, - "Input data must contain 'time', 'space', and 'keypoints' as " - "dimensions.", - ) \ No newline at end of file From b00edcdfb6dfd58cc9eea54ae54d687760ac875d Mon Sep 17 00:00:00 2001 From: b-peri Date: Tue, 24 Sep 2024 17:23:50 +0100 Subject: [PATCH 15/18] More fixes for `test_kinematics.py` --- tests/test_unit/test_kinematics.py | 76 +++++++++++++++--------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 2bebb3765..2b6bfed40 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -187,7 +187,7 @@ def test_approximate_derivative_with_invalid_order(order): @pytest.fixture -def valid_data_array_for_head_vector(): +def valid_data_array_for_forward_vector(): """Return a position data array for an individual with 3 keypoints (left ear, right ear and nose), tracked for 4 frames, in x-y space. """ @@ -215,96 +215,96 @@ def valid_data_array_for_head_vector(): @pytest.fixture -def invalid_input_type_for_head_vector(valid_data_array_for_head_vector): +def invalid_input_type_for_forward_vector(valid_data_array_for_forward_vector): """Return a numpy array of position values by individual, per keypoint, over time. """ - return valid_data_array_for_head_vector.values + return valid_data_array_for_forward_vector.values @pytest.fixture -def invalid_dimensions_for_head_vector(valid_data_array_for_head_vector): +def invalid_dimensions_for_forward_vector(valid_data_array_for_forward_vector): """Return a position DataArray in which the ``keypoints`` dimension has been dropped. """ - return valid_data_array_for_head_vector.sel(keypoints="nose", drop=True) + return valid_data_array_for_forward_vector.sel(keypoints="nose", drop=True) @pytest.fixture -def invalid_spatial_dimensions_for_head_vector( - valid_data_array_for_head_vector, +def invalid_spatial_dimensions_for_forward_vector( + valid_data_array_for_forward_vector, ): """Return a position DataArray containing three spatial dimensions.""" - dataarray_3d = valid_data_array_for_head_vector.pad( + dataarray_3d = valid_data_array_for_forward_vector.pad( space=(0, 1), constant_values=0 ) return dataarray_3d.assign_coords(space=["x", "y", "z"]) @pytest.fixture -def valid_data_array_for_head_vector_with_nans( - valid_data_array_for_head_vector, +def valid_data_array_for_forward_vector_with_nans( + valid_data_array_for_forward_vector, ): """Return a position DataArray where position values are NaN for the ``left_ear`` keypoint at time ``1``. """ - nan_dataarray = valid_data_array_for_head_vector.where( - (valid_data_array_for_head_vector.time != 1) - | (valid_data_array_for_head_vector.keypoints != "left_ear") + nan_dataarray = valid_data_array_for_forward_vector.where( + (valid_data_array_for_forward_vector.time != 1) + | (valid_data_array_for_forward_vector.keypoints != "left_ear") ) return nan_dataarray -def test_compute_2d_head_direction_vector(valid_data_array_for_head_vector): - """Test that the correct output head direction vectors +def test_compute_forward_vector(valid_data_array_for_forward_vector): + """Test that the correct output forward direction vectors are computed from a valid mock dataset. """ - head_vector = kinematics.compute_2d_head_direction_vector( - valid_data_array_for_head_vector, "left_ear", "right_ear" + forward_vector = kinematics.compute_forward_vector( + valid_data_array_for_forward_vector, "left_ear", "right_ear" ) - known_vectors = np.array([[[0, 1]], [[-1, 0]], [[0, -1]], [[1, 0]]]) + known_vectors = np.array([[[0, -1]], [[1, 0]], [[0, 1]], [[-1, 0]]]) assert ( - isinstance(head_vector, xr.DataArray) - and ("space" in head_vector.dims) - and ("keypoints" not in head_vector.dims) + isinstance(forward_vector, xr.DataArray) + and ("space" in forward_vector.dims) + and ("keypoints" not in forward_vector.dims) ) - assert np.equal(head_vector.values, known_vectors).all() + assert np.equal(forward_vector.values, known_vectors).all() @pytest.mark.parametrize( "input_data, expected_error, expected_match_str, keypoints", [ ( - "invalid_input_type_for_head_vector", + "invalid_input_type_for_forward_vector", TypeError, "must be an xarray.DataArray", ["left_ear", "right_ear"], ), ( - "invalid_dimensions_for_head_vector", - AttributeError, - "'time', 'space', and 'keypoints'", + "invalid_dimensions_for_forward_vector", + ValueError, + "Input data must contain ['keypoints']", ["left_ear", "right_ear"], ), ( - "invalid_spatial_dimensions_for_head_vector", + "invalid_spatial_dimensions_for_forward_vector", ValueError, "must have 2 (and only 2) spatial dimensions", ["left_ear", "right_ear"], ), ( - "valid_data_array_for_head_vector", + "valid_data_array_for_forward_vector", ValueError, "keypoints may not be identical", ["left_ear", "left_ear"], ), ], ) -def test_compute_2d_head_direction_vector_with_invalid_input( +def test_compute_forward_vector_with_invalid_input( input_data, keypoints, expected_error, expected_match_str, request ): - """Test that ``compute_2d_head_direction_vector`` catches errors + """Test that ``compute_forward_vector`` catches errors correctly when passed invalid inputs. """ # Get fixture @@ -312,23 +312,23 @@ def test_compute_2d_head_direction_vector_with_invalid_input( # Catch error with pytest.raises(expected_error, match=re.escape(expected_match_str)): - kinematics.compute_2d_head_direction_vector( + kinematics.compute_forward_vector( input_data, keypoints[0], keypoints[1] ) -def test_nan_behavior_2d_head_vector( - valid_data_array_for_head_vector_with_nans, +def test_nan_behavior_forward_vector( + valid_data_array_for_forward_vector_with_nans, ): - """Test that ``compute_head_direction_vector()`` generates the + """Test that ``compute_forward_vector()`` generates the expected output for a valid input DataArray containing ``NaN`` position values at a single time (``1``) and keypoint (``left_ear``). """ - head_vector = kinematics.compute_2d_head_direction_vector( - valid_data_array_for_head_vector_with_nans, "left_ear", "right_ear" + forward_vector = kinematics.compute_forward_vector( + valid_data_array_for_forward_vector_with_nans, "left_ear", "right_ear" ) assert ( - np.isnan(head_vector.values[1, 0, :]).all() - and not np.isnan(head_vector.values[[0, 2, 3], 0, :]).any() + np.isnan(forward_vector.values[1, 0, :]).all() + and not np.isnan(forward_vector.values[[0, 2, 3], 0, :]).any() ) From be750927448496ff5a397c9d31825a6c2b78ef03 Mon Sep 17 00:00:00 2001 From: b-peri Date: Tue, 24 Sep 2024 17:45:14 +0100 Subject: [PATCH 16/18] Bugfix for `compute_forward_vector()` and expanded `test_compute_forward_vector` to cover both `camera_view` options --- movement/analysis/kinematics.py | 2 +- tests/test_unit/test_kinematics.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index 7b4b26b31..a2689c0c5 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -258,7 +258,7 @@ def compute_forward_vector( # Define upward vector # default: negative z direction in the image coordinate system - if camera_view == "top-down": + if camera_view == "top_down": upward_vector = np.array([0, 0, -1]) else: upward_vector = np.array([0, 0, 1]) diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 2b6bfed40..39bcb4c09 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -260,7 +260,16 @@ def test_compute_forward_vector(valid_data_array_for_forward_vector): are computed from a valid mock dataset. """ forward_vector = kinematics.compute_forward_vector( - valid_data_array_for_forward_vector, "left_ear", "right_ear" + valid_data_array_for_forward_vector, + "left_ear", + "right_ear", + camera_view="bottom_up", + ) + forward_vector_flipped = kinematics.compute_forward_vector( + valid_data_array_for_forward_vector, + "left_ear", + "right_ear", + camera_view="top_down", ) known_vectors = np.array([[[0, -1]], [[1, 0]], [[0, 1]], [[-1, 0]]]) @@ -270,6 +279,7 @@ def test_compute_forward_vector(valid_data_array_for_forward_vector): and ("keypoints" not in forward_vector.dims) ) assert np.equal(forward_vector.values, known_vectors).all() + assert np.equal(forward_vector_flipped.values, known_vectors * -1).all() @pytest.mark.parametrize( From b2de46aa5b37eda2a4ee37fe4a0e27c7fb7799ed Mon Sep 17 00:00:00 2001 From: b-peri Date: Tue, 24 Sep 2024 17:57:48 +0100 Subject: [PATCH 17/18] Added test coverage for `compute_head_direction_vector()` alias --- tests/test_unit/test_kinematics.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 39bcb4c09..01bf3ef68 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -271,6 +271,12 @@ def test_compute_forward_vector(valid_data_array_for_forward_vector): "right_ear", camera_view="top_down", ) + head_vector = kinematics.compute_head_direction_vector( + valid_data_array_for_forward_vector, + "left_ear", + "right_ear", + camera_view="bottom_up", + ) known_vectors = np.array([[[0, -1]], [[1, 0]], [[0, 1]], [[-1, 0]]]) assert ( @@ -280,6 +286,7 @@ def test_compute_forward_vector(valid_data_array_for_forward_vector): ) assert np.equal(forward_vector.values, known_vectors).all() assert np.equal(forward_vector_flipped.values, known_vectors * -1).all() + assert head_vector.equals(forward_vector) @pytest.mark.parametrize( From 268d3956a6047c3de0ba544fda82f7e878b47ec3 Mon Sep 17 00:00:00 2001 From: b-peri Date: Fri, 4 Oct 2024 15:11:51 +0100 Subject: [PATCH 18/18] Reversed changes to `compute_polar_coordinates.py` and implemented final feedback --- examples/compute_polar_coordinates.py | 24 +++++++------- movement/analysis/kinematics.py | 46 ++++++++++++++------------- tests/test_unit/test_kinematics.py | 2 +- 3 files changed, 38 insertions(+), 34 deletions(-) diff --git a/examples/compute_polar_coordinates.py b/examples/compute_polar_coordinates.py index ef0622f4e..bad25b790 100644 --- a/examples/compute_polar_coordinates.py +++ b/examples/compute_polar_coordinates.py @@ -15,7 +15,6 @@ from matplotlib import pyplot as plt from movement import sample_data -from movement.analysis.kinematics import compute_head_direction_vector from movement.io import load_poses from movement.utils.vector import cart2pol, pol2cart @@ -49,14 +48,20 @@ # To demonstrate how polar coordinates can be useful in behavioural analyses, # we will compute the head vector of the mouse. # -# In ``movement``, head vector is defined as the vector perpendicular to the -# line connecting two symmetrical keypoints on either side of the head (usually -# the ears), pointing forwards. (See :func:`here\ -# ` for a more -# detailed explanation). +# We define it as the vector from the midpoint between the ears to the snout. -head_vector = compute_head_direction_vector(position, "left_ear", "right_ear") +# compute the midpoint between the ears +midpoint_ears = position.sel(keypoints=["left_ear", "right_ear"]).mean( + dim="keypoints" +) +# compute the head vector +head_vector = position.sel(keypoints="snout") - midpoint_ears + +# drop the keypoints dimension +# (otherwise the `head_vector` data array retains a `snout` keypoint from the +# operation above) +head_vector = head_vector.drop_vars("keypoints") # %% # Visualise the head trajectory @@ -67,12 +72,9 @@ # We can start by plotting the trajectory of the midpoint between the ears. We # will refer to this as the head trajectory. -midpoint_ears = position.sel(keypoints=["left_ear", "right_ear"]).mean( - dim="keypoints" -) +fig, ax = plt.subplots(1, 1) mouse_name = ds.individuals.values[0] -fig, ax = plt.subplots(1, 1) sc = ax.scatter( midpoint_ears.sel(individuals=mouse_name, space="x"), midpoint_ears.sel(individuals=mouse_name, space="y"), diff --git a/movement/analysis/kinematics.py b/movement/analysis/kinematics.py index a2689c0c5..b1bce6ac4 100644 --- a/movement/analysis/kinematics.py +++ b/movement/analysis/kinematics.py @@ -177,31 +177,13 @@ def compute_forward_vector( right_keypoint: str, camera_view: Literal["top_down", "bottom_up"] = "top_down", ): - """Compute a 2D forward vector given two left-right symmetrical keypoints. + """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. - - 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. + animal is assumed (see Notes). Parameters ---------- @@ -227,6 +209,26 @@ def compute_forward_vector( dimensions matching the input data array, but without the ``keypoints`` dimension. + 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 input data _validate_type_data_array(data) @@ -241,7 +243,7 @@ def compute_forward_vector( if len(data.space) != 2: raise log_error( ValueError, - "Input data must have 2 (and only 2) spatial dimensions, but " + "Input data must have exactly 2 spatial dimensions, but " f"currently has {len(data.space)}.", ) @@ -290,7 +292,7 @@ def compute_head_direction_vector( This function is an alias for :func:`compute_forward_vector()\ `. For more detailed information on how the head direction vector is computed, - please refer to the documentation for this function. + please refer to the documentation for that function. Parameters ---------- diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 01bf3ef68..a54d199ca 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -307,7 +307,7 @@ def test_compute_forward_vector(valid_data_array_for_forward_vector): ( "invalid_spatial_dimensions_for_forward_vector", ValueError, - "must have 2 (and only 2) spatial dimensions", + "must have exactly 2 spatial dimensions", ["left_ear", "right_ear"], ), (