From c01078c47bb709942dc5d261465e1d2b50f627ce Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Tue, 11 Feb 2025 12:20:48 +0000 Subject: [PATCH 01/39] Write distance_to, nearest_point_to, and vector_to methods of RoIs --- movement/roi/base.py | 149 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/movement/roi/base.py b/movement/roi/base.py index 2f93877d0..e5daefa46 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -5,6 +5,7 @@ from collections.abc import Sequence from typing import Literal, TypeAlias +import numpy as np import shapely from numpy.typing import ArrayLike from shapely.coords import CoordinateSequence @@ -200,3 +201,151 @@ def contains_point( point ) return point_is_inside + + @broadcastable_method(only_broadcastable_along="space") + def distance_to(self, point: ArrayLike, boundary: bool = False) -> float: + """Compute the distance from the region to a point. + + Parameters + ---------- + point : ArrayLike + Coordinates of a point, from which to find the nearest point in the + region defined by ``self``. + boundary : bool, optional + If True, compute the distance from ``point`` to the boundary of + the region. Otherwise, the distance returned may be 0 for interior + points (see Notes). + + Returns + ------- + float + Euclidean distance from the ``point`` to the (closest point on the) + region. + + Notes + ----- + A point within the interior of a region is considered to be a distance + 0 from the region. This is desirable for 1-dimensional regions, but may + not be desirable for 2D regions. As such, passing the ``boundary`` + argument as ``True`` makes the method compute the distance from the + ``point`` to the boundary of the region, even if ``point`` is in the + interior of the region. + + See Also + -------- + shapely.distance : Underlying used to compute the nearest point. + + """ + from_where = self.region.boundary if boundary else self.region + return shapely.distance(from_where, shapely.Point(point)) + + @broadcastable_method( + only_broadcastable_along="space", new_dimension_name="nearest point" + ) + def nearest_point_to( + self, /, position: ArrayLike, boundary: bool = False + ) -> np.ndarray: + """Compute the nearest point in the region to the ``position``. + + position : ArrayLike + Coordinates of a point, from which to find the nearest point in the + region defined by ``self``. + boundary : bool, optional + If True, compute the nearest point to ``position`` that is on the + boundary of ``self``. Otherwise, the nearest point returned may be + inside ``self`` (see Notes). + + Returns + ------- + np.ndarray + Coordinates of the point on ``self`` that is closest to + ``position``. + + Notes + ----- + This function computes the nearest point to ``position`` in the region + defined by ``self``. This means that, given a ``position`` inside the + region, ``position`` itself will be returned. To find the nearest point + to ``position`` on the boundary of a region, pass the ``boundary` + argument as ``True`` to this method. Take care though - the boundary of + a line is considered to be just its endpoints. + + See Also + -------- + shapely.shortest_line : Underlying used to compute the nearest point. + + """ + from_where = self.region.boundary if boundary else self.region + # shortest_line returns a line from 1st arg to 2nd arg, + # therefore the point on self is the 0th coordinate + return np.array( + shapely.shortest_line(from_where, shapely.Point(position)).coords[ + 0 + ] + ) + + @broadcastable_method( + only_broadcastable_along="space", new_dimension_name="vector to" + ) + def vector_to( + self, + point: ArrayLike, + boundary: bool = False, + direction: Literal[ + "point to region", "region to point" + ] = "point to region", + unit: bool = True, + ) -> np.ndarray: + """Compute the vector from a point to the region. + + Specifically, the vector is directed from the given ``point`` to the + nearest point within the region. Points within the region return the + zero vector. + + Parameters + ---------- + point : ArrayLike + Coordinates of a point to compute the vector to (or from) the + region. + boundary : bool + If True, finds the vector to the nearest point on the boundary of + the region, instead of the nearest point within the region. + (See Notes). Default is False. + direction : Literal["point to region", "region to point"] + Which direction the returned vector should point in. Default is + "point to region". + unit : bool + If True, the unit vector in the appropriate direction is returned, + otherwise the displacement vector is returned. Default is False. + + Returns + ------- + np.ndarray + Vector directed between the point and the region. + + Notes + ----- + If given a ``point`` in the interior of the region, the vector from + this ``point`` to the region is treated as the zero vector. The + ``boundary`` argument can be used to force the method to find the + distance from the ``point`` to the nearest point on the boundary of the + region, if so desired. Note that a ``point`` on the boundary still + returns the zero vector. + + """ + from_where = self.region.boundary if boundary else self.region + + # "point to region" by virtue of order of arguments to shapely call + directed_line = shapely.shortest_line(shapely.Point(point), from_where) + + displacement_vector = np.array(directed_line.coords[1]) - np.array( + directed_line.coords[0] + ) + if direction == "region to point": + displacement_vector *= -1.0 + if unit: + norm = np.sqrt(np.sum(displacement_vector**2)) + # Cannot normalise the 0 vector + if norm != 0.0: + displacement_vector /= norm + return displacement_vector From ea3266145e21e4cc46f3d4c71f976d848ba671a6 Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Tue, 11 Feb 2025 12:21:30 +0000 Subject: [PATCH 02/39] Tests for distance_to and nearest_point_to --- tests/test_unit/test_roi/conftest.py | 19 +- .../test_unit/test_roi/test_nearest_points.py | 295 ++++++++++++++++++ 2 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 tests/test_unit/test_roi/test_nearest_points.py diff --git a/tests/test_unit/test_roi/conftest.py b/tests/test_unit/test_roi/conftest.py index 8ba15cb94..b8eeb720c 100644 --- a/tests/test_unit/test_roi/conftest.py +++ b/tests/test_unit/test_roi/conftest.py @@ -1,5 +1,8 @@ import numpy as np import pytest +import xarray as xr + +from movement.roi.polygon import PolygonOfInterest @pytest.fixture() @@ -17,5 +20,19 @@ def unit_square_pts() -> np.ndarray: @pytest.fixture() def unit_square_hole(unit_square_pts: np.ndarray) -> np.ndarray: - """Hole in the shape of a 0.5 side-length square centred on (0.5, 0.5).""" + """Hole in the shape of a 0.25 side-length square centred on 0.5, 0.5.""" return 0.25 + (unit_square_pts.copy() * 0.5) + + +@pytest.fixture +def unit_square(unit_square_pts: xr.DataArray) -> PolygonOfInterest: + return PolygonOfInterest(unit_square_pts, name="Unit square") + + +@pytest.fixture +def unit_square_with_hole( + unit_square_pts: xr.DataArray, unit_square_hole: xr.DataArray +) -> PolygonOfInterest: + return PolygonOfInterest( + unit_square_pts, holes=[unit_square_hole], name="Unit square with hole" + ) diff --git a/tests/test_unit/test_roi/test_nearest_points.py b/tests/test_unit/test_roi/test_nearest_points.py new file mode 100644 index 000000000..cc816f301 --- /dev/null +++ b/tests/test_unit/test_roi/test_nearest_points.py @@ -0,0 +1,295 @@ +from typing import Any + +import numpy as np +import pytest +import xarray as xr + +from movement.roi.base import BaseRegionOfInterest +from movement.roi.line import LineOfInterest + + +@pytest.fixture +def points_of_interest() -> dict[str, np.ndarray]: + return xr.DataArray( + np.array( + [ + [-0.5, 0.50], + [0.00, 0.50], + [0.40, 0.45], + [2.00, 1.00], + [0.40, 0.75], + [0.95, 0.90], + [0.80, 0.76], + ] + ), + dims=["time", "space"], + coords={"space": ["x", "y"]}, + ) + + +@pytest.fixture() +def unit_line_in_x() -> LineOfInterest: + return LineOfInterest([[0.0, 0.0], [1.0, 0.0]]) + + +@pytest.mark.parametrize( + ["region", "other_fn_args", "expected_output"], + [ + pytest.param( + "unit_square", + {"boundary": True}, + np.array( + [ + [0.00, 0.50], + [0.00, 0.50], + [0.00, 0.45], + [1.00, 1.00], + [0.40, 1.00], + [1.00, 0.90], + [1.00, 0.76], + ] + ), + id="Unit square, boundary only", + ), + pytest.param( + "unit_square", + {}, + np.array( + [ + [0.00, 0.50], + [0.00, 0.50], + [0.40, 0.45], + [1.00, 1.00], + [0.40, 0.75], + [0.95, 0.90], + [0.80, 0.76], + ] + ), + id="Unit square, whole region", + ), + pytest.param( + "unit_square_with_hole", + {"boundary": True}, + np.array( + [ + [0.00, 0.50], + [0.00, 0.50], + [0.25, 0.45], + [1.00, 1.00], + [0.40, 0.75], + [1.00, 0.90], + [0.75, 0.75], + ] + ), + id="Unit square w/ hole, boundary only", + ), + pytest.param( + "unit_square_with_hole", + {}, + np.array( + [ + [0.00, 0.50], + [0.00, 0.50], + [0.25, 0.45], + [1.00, 1.00], + [0.40, 0.75], + [0.95, 0.90], + [0.80, 0.76], + ] + ), + id="Unit square w/ hole, whole region", + ), + pytest.param( + "unit_line_in_x", + {}, + np.array( + [ + [0.00, 0.00], + [0.00, 0.00], + [0.40, 0.00], + [1.00, 0.00], + [0.40, 0.00], + [0.95, 0.00], + [0.80, 0.00], + ] + ), + id="Line, whole region", + ), + pytest.param( + "unit_line_in_x", + {"boundary": True}, + np.array( + [ + [0.00, 0.00], + [0.00, 0.00], + [0.00, 0.00], + [1.00, 0.00], + [0.00, 0.00], + [1.00, 0.00], + [1.00, 0.00], + ] + ), + id="Line, boundary only", + ), + ], +) +def test_nearest_point_to( + region: BaseRegionOfInterest, + points_of_interest: xr.DataArray, + other_fn_args: dict[str, Any], + expected_output: xr.DataArray, + request, +) -> None: + if isinstance(region, str): + region = request.getfixturevalue(region) + if isinstance(points_of_interest, str): + points_of_interest = request.getfixturevalue(points_of_interest) + if isinstance(expected_output, str): + expected_output = request.get(expected_output) + elif isinstance(expected_output, np.ndarray): + expected_output = xr.DataArray( + expected_output, + dims=["time", "nearest point"], + ) + + points_of_interest = points_of_interest + nearest_points = region.nearest_point_to( + points_of_interest, **other_fn_args + ) + + xr.testing.assert_allclose(nearest_points, expected_output) + + +@pytest.mark.parametrize( + ["region", "position", "fn_kwargs", "possible_nearest_points"], + [ + pytest.param( + "unit_square", + [0.5, 0.5], + {"boundary": True}, + [ + np.array([0.0, 0.5]), + np.array([0.5, 0.0]), + np.array([1.0, 0.5]), + np.array([0.5, 1.0]), + ], + id="Centre of the unit square", + ), + pytest.param( + "unit_line_in_x", + [0.5, 0.0], + {"boundary": True}, + [ + np.array([0.0, 0.0]), + np.array([1.0, 0.0]), + ], + id="Boundary of a line", + ), + ], +) +def test_nearest_point_to_tie_breaks( + region: BaseRegionOfInterest, + position: np.ndarray, + fn_kwargs: dict[str, Any], + possible_nearest_points: list[np.ndarray], + request, +) -> None: + """Check behaviour when points are tied for nearest. + + This can only occur when we have a Polygonal region, or a multi-line 1D + region. In this case, there may be multiple points in the region of + interest that are tied for closest. ``shapely`` does not actually document + how it breaks ties here, but we can at least check that it identifies one + of the possible correct points. + """ + if isinstance(region, str): + region = request.getfixturevalue(region) + if not isinstance(position, np.ndarray | xr.DataArray): + position = np.array(position) + + nearest_point_found = region.nearest_point_to(position, **fn_kwargs) + + sq_dist_to_nearest_pt = np.sum((nearest_point_found - position) ** 2) + + n_matches = 0 + for possibility in possible_nearest_points: + # All possibilities should be approximately the same distance away + # from the position + assert np.isclose( + np.sum((possibility - position) ** 2), sq_dist_to_nearest_pt + ) + # We should match at least one possibility, + # track to see if we do. + if np.isclose(nearest_point_found, possibility).all(): + n_matches += 1 + assert n_matches == 1 + + +@pytest.mark.parametrize( + ["region", "fn_kwargs", "expected_distances"], + [ + pytest.param( + "unit_square_with_hole", + {"boundary": True}, + np.array( + [ + 0.5, + 0.0, + 0.15, + 1.0, + 0.0, + 0.05, + np.sqrt((1.0 / 20.0) ** 2 + (1.0 / 100.0) ** 2), + ] + ), + id="Unit square w/ hole, boundary", + ), + pytest.param( + "unit_square_with_hole", + {}, + np.array([0.5, 0.0, 0.15, 1.0, 0.0, 0.0, 0.0]), + id="Unit square w/ hole, whole region", + ), + pytest.param( + "unit_line_in_x", + {}, + np.array( + [0.5 * np.sqrt(2.0), 0.5, 0.45, np.sqrt(2.0), 0.75, 0.9, 0.76] + ), + id="Unit line in x", + ), + pytest.param( + "unit_line_in_x", + {"boundary": True}, + np.array( + [ + 0.5 * np.sqrt(2.0), + 0.5, + np.sqrt(0.4**2 + 0.45**2), + np.sqrt(2.0), + np.sqrt(0.4**2 + 0.75**2), + np.sqrt(0.05**2 + 0.9**2), + np.sqrt(0.2**2 + 0.76**2), + ] + ), + id="Unit line in x, endpoints only", + ), + ], +) +def test_distance_to( + region: BaseRegionOfInterest, + points_of_interest: xr.DataArray, + fn_kwargs: dict[str, Any], + expected_distances: xr.DataArray, + request, +) -> None: + if isinstance(region, str): + region = request.getfixturevalue(region) + if isinstance(expected_distances, np.ndarray): + expected_distances = xr.DataArray( + data=expected_distances, dims=["time"] + ) + + computed_distances = region.distance_to(points_of_interest, **fn_kwargs) + + xr.testing.assert_allclose(computed_distances, expected_distances) From 78c9f06db6ff5ce617b08d4460bc7a1338670f61 Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Tue, 11 Feb 2025 13:52:39 +0000 Subject: [PATCH 03/39] Write tests for vector_to --- movement/roi/base.py | 6 +- .../test_unit/test_roi/test_nearest_points.py | 171 +++++++++++++----- 2 files changed, 125 insertions(+), 52 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index e5daefa46..cf0676c0f 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -14,7 +14,7 @@ from movement.utils.logging import log_error LineLike: TypeAlias = shapely.LinearRing | shapely.LineString -PointLike: TypeAlias = tuple[float, float] +PointLike: TypeAlias = list[float] | tuple[float, ...] PointLikeList: TypeAlias = Sequence[PointLike] RegionLike: TypeAlias = shapely.Polygon SupportedGeometry: TypeAlias = LineLike | RegionLike @@ -293,7 +293,7 @@ def vector_to( boundary: bool = False, direction: Literal[ "point to region", "region to point" - ] = "point to region", + ] = "region to point", unit: bool = True, ) -> np.ndarray: """Compute the vector from a point to the region. @@ -313,7 +313,7 @@ def vector_to( (See Notes). Default is False. direction : Literal["point to region", "region to point"] Which direction the returned vector should point in. Default is - "point to region". + "region to point". unit : bool If True, the unit vector in the appropriate direction is returned, otherwise the displacement vector is returned. Default is False. diff --git a/tests/test_unit/test_roi/test_nearest_points.py b/tests/test_unit/test_roi/test_nearest_points.py index cc816f301..8b11ded76 100644 --- a/tests/test_unit/test_roi/test_nearest_points.py +++ b/tests/test_unit/test_roi/test_nearest_points.py @@ -1,3 +1,4 @@ +import re from typing import Any import numpy as np @@ -32,6 +33,76 @@ def unit_line_in_x() -> LineOfInterest: return LineOfInterest([[0.0, 0.0], [1.0, 0.0]]) +@pytest.mark.parametrize( + ["region", "fn_kwargs", "expected_distances"], + [ + pytest.param( + "unit_square_with_hole", + {"boundary": True}, + np.array( + [ + 0.5, + 0.0, + 0.15, + 1.0, + 0.0, + 0.05, + np.sqrt((1.0 / 20.0) ** 2 + (1.0 / 100.0) ** 2), + ] + ), + id="Unit square w/ hole, boundary", + ), + pytest.param( + "unit_square_with_hole", + {}, + np.array([0.5, 0.0, 0.15, 1.0, 0.0, 0.0, 0.0]), + id="Unit square w/ hole, whole region", + ), + pytest.param( + "unit_line_in_x", + {}, + np.array( + [0.5 * np.sqrt(2.0), 0.5, 0.45, np.sqrt(2.0), 0.75, 0.9, 0.76] + ), + id="Unit line in x", + ), + pytest.param( + "unit_line_in_x", + {"boundary": True}, + np.array( + [ + 0.5 * np.sqrt(2.0), + 0.5, + np.sqrt(0.4**2 + 0.45**2), + np.sqrt(2.0), + np.sqrt(0.4**2 + 0.75**2), + np.sqrt(0.05**2 + 0.9**2), + np.sqrt(0.2**2 + 0.76**2), + ] + ), + id="Unit line in x, endpoints only", + ), + ], +) +def test_distance_to( + region: BaseRegionOfInterest, + points_of_interest: xr.DataArray, + fn_kwargs: dict[str, Any], + expected_distances: xr.DataArray, + request, +) -> None: + if isinstance(region, str): + region = request.getfixturevalue(region) + if isinstance(expected_distances, np.ndarray): + expected_distances = xr.DataArray( + data=expected_distances, dims=["time"] + ) + + computed_distances = region.distance_to(points_of_interest, **fn_kwargs) + + xr.testing.assert_allclose(computed_distances, expected_distances) + + @pytest.mark.parametrize( ["region", "other_fn_args", "expected_output"], [ @@ -142,8 +213,6 @@ def test_nearest_point_to( ) -> None: if isinstance(region, str): region = request.getfixturevalue(region) - if isinstance(points_of_interest, str): - points_of_interest = request.getfixturevalue(points_of_interest) if isinstance(expected_output, str): expected_output = request.get(expected_output) elif isinstance(expected_output, np.ndarray): @@ -152,7 +221,6 @@ def test_nearest_point_to( dims=["time", "nearest point"], ) - points_of_interest = points_of_interest nearest_points = region.nearest_point_to( points_of_interest, **other_fn_args ) @@ -226,70 +294,75 @@ def test_nearest_point_to_tie_breaks( @pytest.mark.parametrize( - ["region", "fn_kwargs", "expected_distances"], + ["region", "point", "other_fn_args", "expected_output"], [ pytest.param( - "unit_square_with_hole", - {"boundary": True}, - np.array( - [ - 0.5, - 0.0, - 0.15, - 1.0, - 0.0, - 0.05, - np.sqrt((1.0 / 20.0) ** 2 + (1.0 / 100.0) ** 2), - ] - ), - id="Unit square w/ hole, boundary", + "unit_square", + (-0.5, 0.0), + {}, + np.array([-1.0, 0.0]), + id="(-0.5, 0.0) -> unit square", ), pytest.param( - "unit_square_with_hole", + LineOfInterest([(0.0, 0.0), (1.0, 0.0)]), + (0.1, 0.5), {}, - np.array([0.5, 0.0, 0.15, 1.0, 0.0, 0.0, 0.0]), - id="Unit square w/ hole, whole region", + np.array([0.0, 1.0]), + id="(0.1, 0.5) -> +ve x ray", ), pytest.param( - "unit_line_in_x", + "unit_square", + (-0.5, 0.0), + {"unit": False}, + np.array([-0.5, 0.0]), + id="Don't normalise output", + ), + pytest.param( + "unit_square", + (0.5, 0.5), {}, - np.array( - [0.5 * np.sqrt(2.0), 0.5, 0.45, np.sqrt(2.0), 0.75, 0.9, 0.76] - ), - id="Unit line in x", + np.array([0.0, 0.0]), + id="Interior point returns 0 vector", ), pytest.param( - "unit_line_in_x", + "unit_square", + (0.25, 0.35), {"boundary": True}, - np.array( - [ - 0.5 * np.sqrt(2.0), - 0.5, - np.sqrt(0.4**2 + 0.45**2), - np.sqrt(2.0), - np.sqrt(0.4**2 + 0.75**2), - np.sqrt(0.05**2 + 0.9**2), - np.sqrt(0.2**2 + 0.76**2), - ] - ), - id="Unit line in x, endpoints only", + np.array([1.0, 0.0]), + id="Boundary, polygon", + ), + pytest.param( + LineOfInterest([(0.0, 0.0), (1.0, 0.0)]), + (0.1, 0.5), + {"boundary": True}, + np.array([0.1, 0.5]) / np.sqrt(0.1**2 + 0.5**2), + id="Boundary, line", ), ], ) -def test_distance_to( +def test_vector_to( region: BaseRegionOfInterest, - points_of_interest: xr.DataArray, - fn_kwargs: dict[str, Any], - expected_distances: xr.DataArray, + point: xr.DataArray, + other_fn_args: dict[str, Any], + expected_output: np.ndarray | Exception, request, ) -> None: if isinstance(region, str): region = request.getfixturevalue(region) - if isinstance(expected_distances, np.ndarray): - expected_distances = xr.DataArray( - data=expected_distances, dims=["time"] - ) - computed_distances = region.distance_to(points_of_interest, **fn_kwargs) + if isinstance(expected_output, Exception): + with pytest.raises( + type(expected_output), match=re.escape(str(expected_output)) + ): + vector_to = region.vector_to(point, **other_fn_args) + else: + vector_to = region.vector_to(point, **other_fn_args) + assert np.allclose(vector_to, expected_output) - xr.testing.assert_allclose(computed_distances, expected_distances) + # Check symmetry when reversing vector direction + if other_fn_args.get("direction", "region to point"): + other_fn_args["direction"] = "point to region" + else: + other_fn_args["direction"] = "region_to_point" + vector_to_reverse = region.vector_to(point, **other_fn_args) + assert np.allclose(-vector_to, vector_to_reverse) From f5e5d0f2fe290b09ada50af0554b2cae8433775b Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Wed, 12 Feb 2025 10:31:43 +0000 Subject: [PATCH 04/39] Write angle from forward method --- movement/kinematics.py | 9 +-- movement/roi/base.py | 111 +++++++++++++++++++++++++++++++++- movement/validators/arrays.py | 4 +- 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/movement/kinematics.py b/movement/kinematics.py index 2880a4888..af9864713 100644 --- a/movement/kinematics.py +++ b/movement/kinematics.py @@ -1,6 +1,7 @@ """Compute kinematic variables like velocity and acceleration.""" import itertools +from collections.abc import Hashable from typing import Literal import numpy as np @@ -205,8 +206,8 @@ def compute_speed(data: xr.DataArray) -> xr.DataArray: def compute_forward_vector( data: xr.DataArray, - left_keypoint: str, - right_keypoint: str, + left_keypoint: Hashable, + right_keypoint: Hashable, camera_view: Literal["top_down", "bottom_up"] = "top_down", ) -> xr.DataArray: """Compute a 2D forward vector given two left-right symmetric keypoints. @@ -357,8 +358,8 @@ def compute_head_direction_vector( def compute_forward_vector_angle( data: xr.DataArray, - left_keypoint: str, - right_keypoint: str, + left_keypoint: Hashable, + right_keypoint: Hashable, reference_vector: xr.DataArray | ArrayLike = (1, 0), camera_view: Literal["top_down", "bottom_up"] = "top_down", in_radians: bool = False, diff --git a/movement/roi/base.py b/movement/roi/base.py index cf0676c0f..60471fe0a 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -2,17 +2,21 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import Literal, TypeAlias +from collections.abc import Hashable, Sequence +from typing import TYPE_CHECKING, Literal, TypeAlias import numpy as np import shapely from numpy.typing import ArrayLike from shapely.coords import CoordinateSequence +from movement.kinematics import compute_forward_vector_angle from movement.utils.broadcasting import broadcastable_method from movement.utils.logging import log_error +if TYPE_CHECKING: + import xarray as xr + LineLike: TypeAlias = shapely.LinearRing | shapely.LineString PointLike: TypeAlias = list[float] | tuple[float, ...] PointLikeList: TypeAlias = Sequence[PointLike] @@ -349,3 +353,106 @@ def vector_to( if norm != 0.0: displacement_vector /= norm return displacement_vector + + def angle_from_forward( + self, + data: xr.DataArray, + left_keypoint: Hashable, + right_keypoint: Hashable, + angle_rotates: Literal[ + "approach to forward", "forward to approach" + ] = "approach to forward", + approach_direction: Literal[ + "point to region", "region to point" + ] = "point to region", + boundary: bool = False, + camera_view: Literal["top_down", "bottom_up"] = "top_down", + in_radians: bool = False, + keypoints_dimension: Hashable = "keypoints", + position_keypoint: Hashable | Sequence[Hashable] | None = None, + ) -> xr.DataArray: + """Compute the angle from the forward- to closest-approach direction. + + Specifically, compute the signed angle between the vector of closest + approach to the region, originating from the ``position_keypoint``, and + the forward vector (defined by the left and right keypoints provided). + + Parameters + ---------- + data : xarray.DataArray + `DataArray` of positions that has at least 3 dimensions; "time", + "space", and ``keypoints_dimension``. + left_keypoint : Hashable + The left keypoint defining the forward vector, as passed to + func:``compute_forward_vector_angle``. + right_keypoint : Hashable + The right keypoint defining the forward vector, as passed to + func:``compute_forward_vector_angle``. + position_keypoint : Hashable | Sequence[Hashable] + The keypoint defining the position of the individual. The vector of + closest approach is computed as the vector between these positions + and the corresponding closest point in the region. If provided as a + sequence, the average of all provided keypoints is used. By + default, the centroid of the left and right keypoints is used. + angle_rotates : Literal["approach to forward", "forward to approach"] + Direction of the signed angle returned. ``"approach to forward"`` + returns the signed angle between the vector of closest approach and + the forward direction. ``"forward to approach"`` returns the + opposite. Default is ``"approach to forward"``. + approach_direction : Literal["point to region", "region to point"] + Direction to use for the vector of closest approach. + ``"point to region"`` will direct this vector from the + ``position_keypoint`` to the region. ``"region to point"`` will do + the opposite. Default ``"point to region"``. + boundary : bool + Passed to ``vector_to``. If ``True``, the direction of closest + approach to the boundary of the region will be used, rather than + the direction of closest approach to the region (see Notes). + Default False. + camera_view : Literal["top_down", "bottom_up"] + Passed to func:``compute_forward_vector_angle``. Default + ``"top_down"``. + in_radians : bool + Passed to func:``compute_forward_vector_angle``. Default False. + keypoints_dimension : Hashable + The dimension of ``data`` along which the (left, right, and + position) keypoints are located. Default ``"keypoints"``. + vector_direction : + + See Also + -------- + func:``compute_forward_vector_angle`` : + + """ + # Default to centre of left and right keypoints for position, + # if not provided. + if position_keypoint is None: + position_keypoint = (left_keypoint, right_keypoint) + rotation_angle: Literal["ref to forward", "forward to ref"] = ( + angle_rotates.replace("approach", "ref") # type: ignore + ) + + # If we are given multiple position keypoints, we take the average of + # them all. + position_data = data.sel( + {keypoints_dimension: position_keypoint} + ).mean(dim=keypoints_dimension) + # Determine the vector from the position keypoint to the region, + # again at all time-points. + vector_to_region = self.vector_to( + position_data, + boundary=boundary, + direction=approach_direction, + unit=True, + ) + + # Then, compute signed angles at all time-points + return compute_forward_vector_angle( + data, + left_keypoint=left_keypoint, + right_keypoint=right_keypoint, + reference_vector=vector_to_region, + camera_view=camera_view, + in_radians=in_radians, + angle_rotates=rotation_angle, + ) diff --git a/movement/validators/arrays.py b/movement/validators/arrays.py index 24417c502..7cb8a4d1d 100644 --- a/movement/validators/arrays.py +++ b/movement/validators/arrays.py @@ -1,5 +1,7 @@ """Validators for data arrays.""" +from collections.abc import Hashable + import numpy as np import xarray as xr @@ -8,7 +10,7 @@ def validate_dims_coords( data: xr.DataArray, - required_dim_coords: dict[str, list[str]], + required_dim_coords: dict[str, list[str] | list[Hashable]], exact_coords: bool = False, ) -> None: """Validate dimensions and coordinates in a data array. From d96fc5fd1795b1fc36b71194746543e488f4dd79 Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Wed, 12 Feb 2025 14:07:30 +0000 Subject: [PATCH 05/39] Clarify some docstrings --- movement/kinematics.py | 2 +- movement/roi/base.py | 65 +++++++++++++++++++++++++++++------------- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/movement/kinematics.py b/movement/kinematics.py index af9864713..d00b4eba0 100644 --- a/movement/kinematics.py +++ b/movement/kinematics.py @@ -372,7 +372,7 @@ def compute_forward_vector_angle( Forward vector angle is the :func:`signed angle\ ` between the reference vector and the animal's :func:`forward vector\ - `). + `. The returned angles are in degrees, spanning the range :math:`(-180, 180]`, unless ``in_radians`` is set to ``True``. diff --git a/movement/roi/base.py b/movement/roi/base.py index 60471fe0a..f4ab8c1c6 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -300,10 +300,10 @@ def vector_to( ] = "region to point", unit: bool = True, ) -> np.ndarray: - """Compute the vector from a point to the region. + """Compute the vector from the region to a point. - Specifically, the vector is directed from the given ``point`` to the - nearest point within the region. Points within the region return the + Specifically, the vector is directed from the the nearest point within + the region to the given ``point``. Points within the region return the zero vector. Parameters @@ -354,7 +354,7 @@ def vector_to( displacement_vector /= norm return displacement_vector - def angle_from_forward( + def compute_angle_to_nearest_point( self, data: xr.DataArray, left_keypoint: Hashable, @@ -371,11 +371,21 @@ def angle_from_forward( keypoints_dimension: Hashable = "keypoints", position_keypoint: Hashable | Sequence[Hashable] | None = None, ) -> xr.DataArray: - """Compute the angle from the forward- to closest-approach direction. + """Compute the angle from the forward- to the approach-vector. Specifically, compute the signed angle between the vector of closest - approach to the region, originating from the ``position_keypoint``, and - the forward vector (defined by the left and right keypoints provided). + approach to the region, and a forward direction. ``angle_rotates`` can + be used to control the sign convention of the angle. + + The forward vector is determined by ``left_keypoint``, + ``right_keypoint``, and ``camera_view`` as per :func:`forward vector\ + `. + + The approach vector is the vector from ``position_keypoints`` to the + closest point within the region (or the closest point on the boundary + of the region if ``boundary`` is set to ``True``), as determined by + :func:`vector_to`. ``approach_direction`` can be used to reverse the + direction convention of the approach vector. Parameters ---------- @@ -388,12 +398,6 @@ def angle_from_forward( right_keypoint : Hashable The right keypoint defining the forward vector, as passed to func:``compute_forward_vector_angle``. - position_keypoint : Hashable | Sequence[Hashable] - The keypoint defining the position of the individual. The vector of - closest approach is computed as the vector between these positions - and the corresponding closest point in the region. If provided as a - sequence, the average of all provided keypoints is used. By - default, the centroid of the left and right keypoints is used. angle_rotates : Literal["approach to forward", "forward to approach"] Direction of the signed angle returned. ``"approach to forward"`` returns the signed angle between the vector of closest approach and @@ -405,9 +409,10 @@ def angle_from_forward( ``position_keypoint`` to the region. ``"region to point"`` will do the opposite. Default ``"point to region"``. boundary : bool - Passed to ``vector_to``. If ``True``, the direction of closest - approach to the boundary of the region will be used, rather than - the direction of closest approach to the region (see Notes). + Passed to ``vector_to``, which is used to compute the approach + vector. If ``True``, the approach vector to the closest point on + the boundary of the region will be used, rather than the direction + of closest approach to the region (see Notes). Default False. camera_view : Literal["top_down", "bottom_up"] Passed to func:``compute_forward_vector_angle``. Default @@ -417,17 +422,38 @@ def angle_from_forward( keypoints_dimension : Hashable The dimension of ``data`` along which the (left, right, and position) keypoints are located. Default ``"keypoints"``. - vector_direction : + position_keypoint : Hashable | Sequence[Hashable], optional + The keypoint defining the position of the individual. The approach + vector is computed as the vector between these positions + and the corresponding closest point in the region. If provided as a + sequence, the average of all provided keypoints is used. By + default, the centroid of ``left_keypoint`` and ``right_keypoint`` + is used. + + Notes + ----- + For a point ``p`` within (the interior of a) region of interest, the + approach vector to the region is the zero vector, since the closest + point to ``p`` inside the region is ``p`` itself. This behaviour may be + undesirable for 2D polygonal regions (though is usually desirable for + 1D line-like regions). Passing the ``boundary`` argument causes this + method to calculate the approach vector to the closest point on the + boundary of this region, so ``p`` will not be considered the "closest + point" to itself (unless of course, it is a point on the boundary). See Also -------- - func:``compute_forward_vector_angle`` : + ``compute_forward_vector_angle`` : The underlying function used + to compute the signed angle between the forward vector and the + approach vector. """ # Default to centre of left and right keypoints for position, # if not provided. if position_keypoint is None: position_keypoint = (left_keypoint, right_keypoint) + # Translate the more explicit convention used here into the convention + # used by our backend functions. rotation_angle: Literal["ref to forward", "forward to ref"] = ( angle_rotates.replace("approach", "ref") # type: ignore ) @@ -437,8 +463,7 @@ def angle_from_forward( position_data = data.sel( {keypoints_dimension: position_keypoint} ).mean(dim=keypoints_dimension) - # Determine the vector from the position keypoint to the region, - # again at all time-points. + # Determine the approach vector, for all time-points. vector_to_region = self.vector_to( position_data, boundary=boundary, From e9be4bc8636bbc895581cebefc037c6b38c446e7 Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Wed, 12 Feb 2025 14:09:46 +0000 Subject: [PATCH 06/39] Rename methods to use the movement 'compute_' convention --- movement/roi/base.py | 10 ++++++---- .../test_unit/test_roi/test_nearest_points.py | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index f4ab8c1c6..86a7a50a3 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -207,7 +207,9 @@ def contains_point( return point_is_inside @broadcastable_method(only_broadcastable_along="space") - def distance_to(self, point: ArrayLike, boundary: bool = False) -> float: + def compute_distance_to( + self, point: ArrayLike, boundary: bool = False + ) -> float: """Compute the distance from the region to a point. Parameters @@ -246,7 +248,7 @@ def distance_to(self, point: ArrayLike, boundary: bool = False) -> float: @broadcastable_method( only_broadcastable_along="space", new_dimension_name="nearest point" ) - def nearest_point_to( + def compute_nearest_point_to( self, /, position: ArrayLike, boundary: bool = False ) -> np.ndarray: """Compute the nearest point in the region to the ``position``. @@ -291,7 +293,7 @@ def nearest_point_to( @broadcastable_method( only_broadcastable_along="space", new_dimension_name="vector to" ) - def vector_to( + def compute_approach_vector( self, point: ArrayLike, boundary: bool = False, @@ -464,7 +466,7 @@ def compute_angle_to_nearest_point( {keypoints_dimension: position_keypoint} ).mean(dim=keypoints_dimension) # Determine the approach vector, for all time-points. - vector_to_region = self.vector_to( + vector_to_region = self.compute_approach_vector( position_data, boundary=boundary, direction=approach_direction, diff --git a/tests/test_unit/test_roi/test_nearest_points.py b/tests/test_unit/test_roi/test_nearest_points.py index 8b11ded76..fe7d45494 100644 --- a/tests/test_unit/test_roi/test_nearest_points.py +++ b/tests/test_unit/test_roi/test_nearest_points.py @@ -98,7 +98,9 @@ def test_distance_to( data=expected_distances, dims=["time"] ) - computed_distances = region.distance_to(points_of_interest, **fn_kwargs) + computed_distances = region.compute_distance_to( + points_of_interest, **fn_kwargs + ) xr.testing.assert_allclose(computed_distances, expected_distances) @@ -221,7 +223,7 @@ def test_nearest_point_to( dims=["time", "nearest point"], ) - nearest_points = region.nearest_point_to( + nearest_points = region.compute_nearest_point_to( points_of_interest, **other_fn_args ) @@ -275,7 +277,9 @@ def test_nearest_point_to_tie_breaks( if not isinstance(position, np.ndarray | xr.DataArray): position = np.array(position) - nearest_point_found = region.nearest_point_to(position, **fn_kwargs) + nearest_point_found = region.compute_nearest_point_to( + position, **fn_kwargs + ) sq_dist_to_nearest_pt = np.sum((nearest_point_found - position) ** 2) @@ -354,9 +358,9 @@ def test_vector_to( with pytest.raises( type(expected_output), match=re.escape(str(expected_output)) ): - vector_to = region.vector_to(point, **other_fn_args) + vector_to = region.compute_approach_vector(point, **other_fn_args) else: - vector_to = region.vector_to(point, **other_fn_args) + vector_to = region.compute_approach_vector(point, **other_fn_args) assert np.allclose(vector_to, expected_output) # Check symmetry when reversing vector direction @@ -364,5 +368,7 @@ def test_vector_to( other_fn_args["direction"] = "point to region" else: other_fn_args["direction"] = "region_to_point" - vector_to_reverse = region.vector_to(point, **other_fn_args) + vector_to_reverse = region.compute_approach_vector( + point, **other_fn_args + ) assert np.allclose(-vector_to, vector_to_reverse) From d1010a25ad9e7e6a81df9d1ad038fff963e233a0 Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Thu, 13 Feb 2025 09:33:13 +0000 Subject: [PATCH 07/39] Tidy up approach vector method and tests --- movement/roi/base.py | 18 +++++++++------- .../test_unit/test_roi/test_nearest_points.py | 21 +++++++++++-------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index 86a7a50a3..68b008aba 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -299,14 +299,15 @@ def compute_approach_vector( boundary: bool = False, direction: Literal[ "point to region", "region to point" - ] = "region to point", + ] = "point to region", unit: bool = True, ) -> np.ndarray: - """Compute the vector from the region to a point. + """Compute the approach vector a ``point`` to the region. - Specifically, the vector is directed from the the nearest point within - the region to the given ``point``. Points within the region return the - zero vector. + The approach vector is defined as the vector directed from the + ``point`` provided, to the closest point that belongs to the region. + If ``point`` is within the region, the zero vector is returned (see + Notes). Parameters ---------- @@ -319,10 +320,11 @@ def compute_approach_vector( (See Notes). Default is False. direction : Literal["point to region", "region to point"] Which direction the returned vector should point in. Default is - "region to point". + "point to region". unit : bool - If True, the unit vector in the appropriate direction is returned, - otherwise the displacement vector is returned. Default is False. + If ``True``, the unit vector in the appropriate direction is + returned, otherwise the displacement vector is returned. + Default is ``True``. Returns ------- diff --git a/tests/test_unit/test_roi/test_nearest_points.py b/tests/test_unit/test_roi/test_nearest_points.py index fe7d45494..be0ff2548 100644 --- a/tests/test_unit/test_roi/test_nearest_points.py +++ b/tests/test_unit/test_roi/test_nearest_points.py @@ -304,21 +304,21 @@ def test_nearest_point_to_tie_breaks( "unit_square", (-0.5, 0.0), {}, - np.array([-1.0, 0.0]), + np.array([1.0, 0.0]), id="(-0.5, 0.0) -> unit square", ), pytest.param( LineOfInterest([(0.0, 0.0), (1.0, 0.0)]), (0.1, 0.5), {}, - np.array([0.0, 1.0]), + np.array([0.0, -1.0]), id="(0.1, 0.5) -> +ve x ray", ), pytest.param( "unit_square", (-0.5, 0.0), {"unit": False}, - np.array([-0.5, 0.0]), + np.array([0.5, 0.0]), id="Don't normalise output", ), pytest.param( @@ -332,19 +332,19 @@ def test_nearest_point_to_tie_breaks( "unit_square", (0.25, 0.35), {"boundary": True}, - np.array([1.0, 0.0]), + np.array([-1.0, 0.0]), id="Boundary, polygon", ), pytest.param( LineOfInterest([(0.0, 0.0), (1.0, 0.0)]), (0.1, 0.5), {"boundary": True}, - np.array([0.1, 0.5]) / np.sqrt(0.1**2 + 0.5**2), + np.array([-0.1, -0.5]) / np.sqrt(0.1**2 + 0.5**2), id="Boundary, line", ), ], ) -def test_vector_to( +def test_approach_vector( region: BaseRegionOfInterest, point: xr.DataArray, other_fn_args: dict[str, Any], @@ -364,10 +364,13 @@ def test_vector_to( assert np.allclose(vector_to, expected_output) # Check symmetry when reversing vector direction - if other_fn_args.get("direction", "region to point"): - other_fn_args["direction"] = "point to region" + if ( + other_fn_args.get("direction", "point to region") + == "point to region" + ): + other_fn_args["direction"] = "region to point" else: - other_fn_args["direction"] = "region_to_point" + other_fn_args["direction"] = "point to region" vector_to_reverse = region.compute_approach_vector( point, **other_fn_args ) From fe3d3b1477fc681378d90e65e4ad81589562d44b Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Thu, 13 Feb 2025 10:15:00 +0000 Subject: [PATCH 08/39] Methods for ego- and allocentric angles --- movement/roi/base.py | 161 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 133 insertions(+), 28 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index 68b008aba..f1ce3daa9 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -13,6 +13,7 @@ from movement.kinematics import compute_forward_vector_angle from movement.utils.broadcasting import broadcastable_method from movement.utils.logging import log_error +from movement.utils.vector import compute_signed_angle_2d if TYPE_CHECKING: import xarray as xr @@ -358,7 +359,118 @@ def compute_approach_vector( displacement_vector /= norm return displacement_vector - def compute_angle_to_nearest_point( + def compute_allocentric_angle( + self, + data: xr.DataArray, + position_keypoint: Hashable | Sequence[Hashable], + angle_rotates: Literal[ + "approach to ref", "ref to approach" + ] = "approach to ref", + approach_direction: Literal[ + "point to region", "region to point" + ] = "point to region", + boundary: bool = False, + in_radians: bool = False, + keypoints_dimension: Hashable = "keypoints", + reference_vector: ArrayLike | xr.DataArray = (1.0, 0.0), + ) -> float: + """Compute the allocentric angle to the region. + + The allocentric angle is the :func:`signed angle\ + ` between the approach + vector (directed from a point to the region) and a given reference + vector. `angle_rotates`` can be used to reverse the sign convention of + the returned angle. + + The approach vector is the vector from ``position_keypoints`` to the + closest point within the region (or the closest point on the boundary + of the region if ``boundary`` is set to ``True``), as determined by + :func:`compute_approach_vector`. ``approach_direction`` can be used to + reverse the direction convention of the approach vector, if desired. + + Parameters + ---------- + data : xarray.DataArray + `DataArray` of positions that has at least 3 dimensions; "time", + "space", and ``keypoints_dimension``. + position_keypoint : Hashable | Sequence[Hashable] + The keypoint defining the origin of the approach vector. If + provided as a sequence, the average of all provided keypoints is + used. + angle_rotates : Literal["approach to ref", "ref to approach"] + Direction of the signed angle returned. Default is + ``"approach to ref"``. + approach_direction : Literal["point to region", "region to point"] + Direction to use for the vector of closest approach. Default + ``"point to region"``. + boundary : bool + Passed to ``compute_approach_vector`` (see Notes). Default + ``False``. + in_radians : bool + If ``True``, angles are returned in radians. Otherwise angles are + returned in degrees. Default ``False``. + keypoints_dimension : Hashable + The dimension of ``data`` along which the ``position_keypoint`` is + located. Default ``"keypoints"``. + reference_vector : ArrayLike | xr.DataArray + The reference vector to be used. Dimensions must be compatible with + the argument of the same name that is passed to + :func:`compute_signed_angle_2d`. Default ``(1., 0.)``. + + Notes + ----- + For a point ``p`` within (the interior of a) region of interest, the + approach vector to the region is the zero vector, since the closest + point to ``p`` inside the region is ``p`` itself. This behaviour may be + undesirable for 2D polygonal regions (though is usually desirable for + 1D line-like regions). Passing the ``boundary`` argument causes this + method to calculate the approach vector to the closest point on the + boundary of this region, so ``p`` will not be considered the "closest + point" to itself (unless of course, it is a point on the boundary). + + See Also + -------- + ``compute_signed_angle_2d`` : The underlying function used to compute + the signed angle between the approach vector and the reference vector. + ``compute_egocentric_angle`` : Related class method for computing the + egocentric angle to the region. + + """ + # Translate the more explicit convention used here into the convention + # used by our backend functions. + if angle_rotates == "ref to approach": + ref_as_left_opperand = True + elif angle_rotates == "approach to ref": + ref_as_left_opperand = False + else: + raise ValueError( + f"Cannot interpret angle convention: {angle_rotates}" + ) + + # If we are given multiple position keypoints, we take the average of + # them all. + position_data = data.sel( + {keypoints_dimension: position_keypoint} + ).mean(dim=keypoints_dimension) + # Determine the approach vector, for all time-points. + vector_to_region = self.compute_approach_vector( + position_data, + boundary=boundary, + direction=approach_direction, + unit=True, + ) + + # Then, compute signed angles at all time-points + angles = compute_signed_angle_2d( + vector_to_region, + reference_vector, + v_as_left_operand=ref_as_left_opperand, + ) + if not in_radians: + angles *= 180.0 / np.pi + return angles + + def compute_egocentric_angle( self, data: xr.DataArray, left_keypoint: Hashable, @@ -375,11 +487,12 @@ def compute_angle_to_nearest_point( keypoints_dimension: Hashable = "keypoints", position_keypoint: Hashable | Sequence[Hashable] | None = None, ) -> xr.DataArray: - """Compute the angle from the forward- to the approach-vector. + """Compute the egocentric angle to the region. - Specifically, compute the signed angle between the vector of closest - approach to the region, and a forward direction. ``angle_rotates`` can - be used to control the sign convention of the angle. + The egocentric angle is the signed angle between the approach vector + (directed from a point towards the region) a forward direction + (typically of a given individual or keypoint). ``angle_rotates`` can + be used to reverse the sign convention of the returned angle. The forward vector is determined by ``left_keypoint``, ``right_keypoint``, and ``camera_view`` as per :func:`forward vector\ @@ -388,8 +501,8 @@ def compute_angle_to_nearest_point( The approach vector is the vector from ``position_keypoints`` to the closest point within the region (or the closest point on the boundary of the region if ``boundary`` is set to ``True``), as determined by - :func:`vector_to`. ``approach_direction`` can be used to reverse the - direction convention of the approach vector. + :func:`compute_approach_vector`. ``approach_direction`` can be used to + reverse the direction convention of the approach vector, if desired. Parameters ---------- @@ -403,36 +516,28 @@ def compute_angle_to_nearest_point( The right keypoint defining the forward vector, as passed to func:``compute_forward_vector_angle``. angle_rotates : Literal["approach to forward", "forward to approach"] - Direction of the signed angle returned. ``"approach to forward"`` - returns the signed angle between the vector of closest approach and - the forward direction. ``"forward to approach"`` returns the - opposite. Default is ``"approach to forward"``. + Direction of the signed angle returned. Default is + ``"approach to forward"``. approach_direction : Literal["point to region", "region to point"] - Direction to use for the vector of closest approach. - ``"point to region"`` will direct this vector from the - ``position_keypoint`` to the region. ``"region to point"`` will do - the opposite. Default ``"point to region"``. + Direction to use for the vector of closest approach. Default + ``"point to region"``. boundary : bool - Passed to ``vector_to``, which is used to compute the approach - vector. If ``True``, the approach vector to the closest point on - the boundary of the region will be used, rather than the direction - of closest approach to the region (see Notes). - Default False. + Passed to ``compute_approach_vector`` (see Notes). Default + ``False``. camera_view : Literal["top_down", "bottom_up"] - Passed to func:``compute_forward_vector_angle``. Default + Passed to func:`compute_forward_vector_angle`. Default ``"top_down"``. in_radians : bool - Passed to func:``compute_forward_vector_angle``. Default False. + If ``True``, angles are returned in radians. Otherwise angles are + returned in degrees. Default ``False``. keypoints_dimension : Hashable The dimension of ``data`` along which the (left, right, and position) keypoints are located. Default ``"keypoints"``. position_keypoint : Hashable | Sequence[Hashable], optional - The keypoint defining the position of the individual. The approach - vector is computed as the vector between these positions - and the corresponding closest point in the region. If provided as a - sequence, the average of all provided keypoints is used. By - default, the centroid of ``left_keypoint`` and ``right_keypoint`` - is used. + The keypoint defining the origin of the approach vector. If + provided as a sequence, the average of all provided keypoints is + used. By default, the centroid of ``left_keypoint`` and + ``right_keypoint`` is used. Notes ----- From a23eae8f966280f1878a29c25e929ff7d27acc0b Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Thu, 13 Feb 2025 14:23:06 +0000 Subject: [PATCH 09/39] Tests for egocentric angles --- movement/roi/base.py | 52 +++--- tests/test_unit/conftest.py | 65 +++++++ tests/test_unit/test_kinematics.py | 49 +----- tests/test_unit/test_roi/test_angles.py | 222 ++++++++++++++++++++++++ 4 files changed, 321 insertions(+), 67 deletions(-) create mode 100644 tests/test_unit/conftest.py create mode 100644 tests/test_unit/test_roi/test_angles.py diff --git a/movement/roi/base.py b/movement/roi/base.py index f1ce3daa9..6555687d5 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -371,7 +371,6 @@ def compute_allocentric_angle( ] = "point to region", boundary: bool = False, in_radians: bool = False, - keypoints_dimension: Hashable = "keypoints", reference_vector: ArrayLike | xr.DataArray = (1.0, 0.0), ) -> float: """Compute the allocentric angle to the region. @@ -409,9 +408,6 @@ def compute_allocentric_angle( in_radians : bool If ``True``, angles are returned in radians. Otherwise angles are returned in degrees. Default ``False``. - keypoints_dimension : Hashable - The dimension of ``data`` along which the ``position_keypoint`` is - located. Default ``"keypoints"``. reference_vector : ArrayLike | xr.DataArray The reference vector to be used. Dimensions must be compatible with the argument of the same name that is passed to @@ -421,7 +417,8 @@ def compute_allocentric_angle( ----- For a point ``p`` within (the interior of a) region of interest, the approach vector to the region is the zero vector, since the closest - point to ``p`` inside the region is ``p`` itself. This behaviour may be + point to ``p`` inside the region is ``p`` itself. In this case, `nan` + is returned as the egocentric angle. This behaviour may be undesirable for 2D polygonal regions (though is usually desirable for 1D line-like regions). Passing the ``boundary`` argument causes this method to calculate the approach vector to the closest point on the @@ -449,9 +446,10 @@ def compute_allocentric_angle( # If we are given multiple position keypoints, we take the average of # them all. - position_data = data.sel( - {keypoints_dimension: position_keypoint} - ).mean(dim=keypoints_dimension) + position_data = data.sel(keypoints=position_keypoint, drop=True) + if "keypoints" in position_data.dims: + position_data = position_data.mean(dim="keypoints") + # Determine the approach vector, for all time-points. vector_to_region = self.compute_approach_vector( position_data, @@ -484,7 +482,6 @@ def compute_egocentric_angle( boundary: bool = False, camera_view: Literal["top_down", "bottom_up"] = "top_down", in_radians: bool = False, - keypoints_dimension: Hashable = "keypoints", position_keypoint: Hashable | Sequence[Hashable] | None = None, ) -> xr.DataArray: """Compute the egocentric angle to the region. @@ -530,9 +527,6 @@ def compute_egocentric_angle( in_radians : bool If ``True``, angles are returned in radians. Otherwise angles are returned in degrees. Default ``False``. - keypoints_dimension : Hashable - The dimension of ``data`` along which the (left, right, and - position) keypoints are located. Default ``"keypoints"``. position_keypoint : Hashable | Sequence[Hashable], optional The keypoint defining the origin of the approach vector. If provided as a sequence, the average of all provided keypoints is @@ -543,7 +537,8 @@ def compute_egocentric_angle( ----- For a point ``p`` within (the interior of a) region of interest, the approach vector to the region is the zero vector, since the closest - point to ``p`` inside the region is ``p`` itself. This behaviour may be + point to ``p`` inside the region is ``p`` itself. In this case, `nan` + is returned as the egocentric angle. This behaviour may be undesirable for 2D polygonal regions (though is usually desirable for 1D line-like regions). Passing the ``boundary`` argument causes this method to calculate the approach vector to the closest point on the @@ -560,24 +555,37 @@ def compute_egocentric_angle( # Default to centre of left and right keypoints for position, # if not provided. if position_keypoint is None: - position_keypoint = (left_keypoint, right_keypoint) + position_keypoint = [left_keypoint, right_keypoint] # Translate the more explicit convention used here into the convention # used by our backend functions. rotation_angle: Literal["ref to forward", "forward to ref"] = ( angle_rotates.replace("approach", "ref") # type: ignore ) + if rotation_angle not in ["ref to forward", "forward to ref"]: + raise ValueError(f"Unknown angle convention: {angle_rotates}") # If we are given multiple position keypoints, we take the average of # them all. - position_data = data.sel( - {keypoints_dimension: position_keypoint} - ).mean(dim=keypoints_dimension) + position_data = data.sel(keypoints=position_keypoint, drop=True) + if "keypoints" in position_data.dims: + position_data = position_data.mean(dim="keypoints") + # Determine the approach vector, for all time-points. - vector_to_region = self.compute_approach_vector( - position_data, - boundary=boundary, - direction=approach_direction, - unit=True, + vector_to_region = ( + self.compute_approach_vector( + position_data, + boundary=boundary, + direction=approach_direction, + unit=True, + ) + .rename({"vector to": "space"}) + .assign_coords( + { + "space": ["x", "y"] + if len(data["space"]) == 2 + else ["x", "y", "z"] + } + ) ) # Then, compute signed angles at all time-points diff --git a/tests/test_unit/conftest.py b/tests/test_unit/conftest.py new file mode 100644 index 000000000..abdae1517 --- /dev/null +++ b/tests/test_unit/conftest.py @@ -0,0 +1,65 @@ +from collections.abc import Callable + +import numpy as np +import pytest +import xarray as xr + + +@pytest.fixture(scope="session") +def push_into_range() -> Callable[ + [xr.DataArray | np.ndarray, float, float], xr.DataArray | np.ndarray +]: + """Translate values into the range (lower, upper]. + + The interval width is the value ``upper - lower``. + Each ``v`` in ``values`` that starts less than or equal to the + ``lower`` bound has multiples of the interval width added to it, + until the result lies in the desirable interval. + + Each ``v`` in ``values`` that starts greater than the ``upper`` + bound has multiples of the interval width subtracted from it, + until the result lies in the desired interval. + """ + + def push_into_range_inner( + numeric_values: xr.DataArray | np.ndarray, + lower: float = -180.0, + upper: float = 180.0, + ) -> xr.DataArray | np.ndarray: + """Translate values into the range (lower, upper]. + + The interval width is the value ``upper - lower``. + Each ``v`` in ``values`` that starts less than or equal to the + ``lower`` bound has multiples of the interval width added to it, + until the result lies in the desirable interval. + + Each ``v`` in ``values`` that starts greater than the ``upper`` + bound has multiples of the interval width subtracted from it, + until the result lies in the desired interval. + """ + translated_values = ( + numeric_values.values.copy() + if isinstance(numeric_values, xr.DataArray) + else numeric_values.copy() + ) + + interval_width = upper - lower + if interval_width <= 0: + raise ValueError( + f"Upper bound ({upper}) must be strictly " + f"greater than lower bound ({lower})" + ) + + while np.any( + (translated_values <= lower) | (translated_values > upper) + ): + translated_values[translated_values <= lower] += interval_width + translated_values[translated_values > upper] -= interval_width + + if isinstance(numeric_values, xr.DataArray): + translated_values = numeric_values.copy( + deep=True, data=translated_values + ) + return translated_values + + return push_into_range_inner diff --git a/tests/test_unit/test_kinematics.py b/tests/test_unit/test_kinematics.py index 57fd2479a..18200cd3e 100644 --- a/tests/test_unit/test_kinematics.py +++ b/tests/test_unit/test_kinematics.py @@ -707,48 +707,6 @@ class TestForwardVectorAngle: y_axis = np.array([0.0, 1.0]) sqrt_2 = np.sqrt(2.0) - @staticmethod - def push_into_range( - numeric_values: xr.DataArray | np.ndarray, - lower: float = -180.0, - upper: float = 180.0, - ) -> xr.DataArray | np.ndarray: - """Translate values into the range (lower, upper]. - - The interval width is the value ``upper - lower``. - Each ``v`` in ``values`` that starts less than or equal to the - ``lower`` bound has multiples of the interval width added to it, - until the result lies in the desirable interval. - - Each ``v`` in ``values`` that starts greater than the ``upper`` - bound has multiples of the interval width subtracted from it, - until the result lies in the desired interval. - """ - translated_values = ( - numeric_values.values.copy() - if isinstance(numeric_values, xr.DataArray) - else numeric_values.copy() - ) - - interval_width = upper - lower - if interval_width <= 0: - raise ValueError( - f"Upper bound ({upper}) must be strictly " - f"greater than lower bound ({lower})" - ) - - while np.any( - (translated_values <= lower) | (translated_values > upper) - ): - translated_values[translated_values <= lower] += interval_width - translated_values[translated_values > upper] -= interval_width - - if isinstance(numeric_values, xr.DataArray): - translated_values = numeric_values.copy( - deep=True, data=translated_values - ) - return translated_values - @pytest.fixture def spinning_on_the_spot(self) -> xr.DataArray: """Simulate data for an individual's head spinning on the spot. @@ -794,6 +752,7 @@ def spinning_on_the_spot(self) -> xr.DataArray: ) def test_antisymmetry_properties( self, + push_into_range, spinning_on_the_spot: xr.DataArray, swap_left_right: bool, swap_camera_view: bool, @@ -843,16 +802,16 @@ def test_antisymmetry_properties( expected_orientations = without_orientations_swapped.copy(deep=True) if swap_left_right: - expected_orientations = self.push_into_range( + expected_orientations = push_into_range( expected_orientations + 180.0 ) if swap_camera_view: - expected_orientations = self.push_into_range( + expected_orientations = push_into_range( expected_orientations + 180.0 ) if swap_angle_rotation: expected_orientations *= -1.0 - expected_orientations = self.push_into_range(expected_orientations) + expected_orientations = push_into_range(expected_orientations) xr.testing.assert_allclose( with_orientations_swapped, expected_orientations diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py new file mode 100644 index 000000000..95af01b02 --- /dev/null +++ b/tests/test_unit/test_roi/test_angles.py @@ -0,0 +1,222 @@ +import re +from typing import Any + +import numpy as np +import pytest +import xarray as xr + +from movement.roi.base import BaseRegionOfInterest + + +@pytest.fixture() +def points_in_the_plane() -> xr.DataArray: + """Define a collection of points, used to test the egocentric angle. + + The data has time, space, and keypoints dimensions. + + The keypoints are left, right, midpt (midpoint), and wild. + The midpt is the mean of the left and right keypoints; the wild keypoint + may be anywhere in the plane (it is used to test the ``position_keypoint`` + argument). + + time 1: + left @ (1.25, 0.), right @ (1., -0.25), wild @ (-0.25, -0.25) + + Fwd vector is (1, -1). + time 2: + left @ (-0.25, 0.5), right @ (-0.25, 0.25), wild @ (-0.5, 0.375) + + Fwd vector is (-1, 0). + time 3: + left @ (0.375, 0.375), right @ (0.5, 0.375), wild @ (1.25, 1.25) + + Fwd vector is (0, -1). + time 4: + left @ (1., -0.25), right @ (1.25, 0.), wild @ (-0.25, -0.25) + + This is time 1 but with left and right swapped. + Fwd vector is (-1, 1). + time 5: + left @ (0.25, 0.5), right @ (0.375, 0.25), wild @ (0.5, 0.65) + + Fwd vector is (-2, -1). + acos(2/sqrt(5)) is the expected angle for the midpt. + acos(1/sqrt(5)) should be that for the wild point IF going to the + boundary. + """ + points = np.zeros(shape=(5, 2, 4)) + points[:, :, 0] = [ + [1.25, 0.0], + [-0.25, 0.5], + [0.375, 0.375], + [1.0, -0.25], + [0.25, 0.5], + ] + points[:, :, 1] = [ + [1.0, -0.25], + [-0.25, 0.25], + [0.5, 0.375], + [1.25, 0.0], + [0.375, 0.25], + ] + points[:, :, 3] = [ + [-0.25, 1.25], + [-0.5, 0.375], + [1.25, 1.25], + [-0.25, -0.25], + [0.5, 0.65], + ] + points[:, :, 2] = np.mean(points[:, :, 0:2], axis=2) + return xr.DataArray( + data=points, + dims=["time", "space", "keypoints"], + coords={ + "space": ["x", "y"], + "keypoints": ["left", "right", "midpt", "wild"], + }, + ) + + +@pytest.mark.parametrize( + ["region", "data", "fn_args", "expected_output"], + [ + pytest.param( + "unit_square_with_hole", + "points_in_the_plane", + { + "left_keypoint": "left", + "right_keypoint": "right", + "angle_rotates": "elephant to region", + }, + ValueError("Unknown angle convention: elephant to region"), + id="Unknown angle convention (checked before other failures)", + ), + pytest.param( + "unit_square_with_hole", + "points_in_the_plane", + { + "left_keypoint": "left", + "right_keypoint": "right", + }, + np.array( + [ + 0.0, + 180.0, + 0.0, + 180.0, + np.rad2deg(np.arccos(2.0 / np.sqrt(5.0))), + ] + ), + id="Default args", + ), + pytest.param( + "unit_square_with_hole", + "points_in_the_plane", + { + "left_keypoint": "left", + "right_keypoint": "right", + "position_keypoint": "wild", + }, + np.array( + [ + 180.0, + 180.0, + 45.0, + -90.0, + np.rad2deg(np.pi / 2.0 + np.arcsin(1.0 / np.sqrt(5.0))), + ] + ), + id="Non-default position", + ), + pytest.param( + "unit_square", + "points_in_the_plane", + { + "left_keypoint": "left", + "right_keypoint": "right", + }, + np.array( + [ + 0.0, + 180.0, + float("nan"), + 180.0, + float("nan"), + ] + ), + id="0-approach vectors (nan returns)", + ), + pytest.param( + "unit_square", + "points_in_the_plane", + { + "left_keypoint": "left", + "right_keypoint": "right", + "boundary": True, + }, + np.array( + [ + 0.0, + 180.0, + 0.0, + 180.0, + np.rad2deg(np.arccos(2.0 / np.sqrt(5.0))), + ] + ), + id="Force boundary calculations", + ), + ], +) +def test_egocentric_angle( + push_into_range, + region: BaseRegionOfInterest, + data: xr.DataArray, + fn_args: dict[str, Any], + expected_output: xr.DataArray | Exception, + request, +) -> None: + """Test computation of the egocentric angle. + + Note, we only test functionality explicitly introduced in this method. + Input arguments that are just handed to other functions are not explicitly + tested here. + + Specifically; + + - ``approach_direction``, + - ``camera_view``, + - ``in_radians``, + + The ``angle_rotates`` argument is tested in all cases (signs should be + reversed when toggling the argument). + """ + if isinstance(region, str): + region = request.getfixturevalue(region) + if isinstance(data, str): + data = request.getfixturevalue(data) + if isinstance(expected_output, np.ndarray): + expected_output = xr.DataArray(data=expected_output, dims=["time"]) + + if isinstance(expected_output, Exception): + with pytest.raises( + type(expected_output), match=re.escape(str(expected_output)) + ): + region.compute_egocentric_angle(data, **fn_args) + else: + angles = region.compute_egocentric_angle(data, **fn_args) + + xr.testing.assert_allclose(angles, expected_output) + + # Check reversal of the angle convention + if ( + fn_args.get("angle_rotates", "approach to forward") + == "approach to forward" + ): + fn_args["angle_rotates"] = "forward to approach" + else: + fn_args["angle_rotates"] = "approach to forward" + reverse_angles = push_into_range( + region.compute_egocentric_angle(data, **fn_args) + ) + + xr.testing.assert_allclose(angles, push_into_range(-reverse_angles)) From 7ae970dc279805ab1f131b6c4de548c60381d7ae Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Thu, 13 Feb 2025 14:49:17 +0000 Subject: [PATCH 10/39] Tests for allocentric calculation --- movement/roi/base.py | 34 ++++--- tests/test_unit/test_roi/test_angles.py | 113 ++++++++++++++++++++---- 2 files changed, 117 insertions(+), 30 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index 6555687d5..c2d8f46d6 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -371,7 +371,7 @@ def compute_allocentric_angle( ] = "point to region", boundary: bool = False, in_radians: bool = False, - reference_vector: ArrayLike | xr.DataArray = (1.0, 0.0), + reference_vector: np.ndarray | xr.DataArray = None, ) -> float: """Compute the allocentric angle to the region. @@ -433,6 +433,8 @@ def compute_allocentric_angle( egocentric angle to the region. """ + if reference_vector is None: + reference_vector = np.array([[1.0, 0.0]]) # Translate the more explicit convention used here into the convention # used by our backend functions. if angle_rotates == "ref to approach": @@ -440,9 +442,7 @@ def compute_allocentric_angle( elif angle_rotates == "approach to ref": ref_as_left_opperand = False else: - raise ValueError( - f"Cannot interpret angle convention: {angle_rotates}" - ) + raise ValueError(f"Unknown angle convention: {angle_rotates}") # If we are given multiple position keypoints, we take the average of # them all. @@ -451,16 +451,26 @@ def compute_allocentric_angle( position_data = position_data.mean(dim="keypoints") # Determine the approach vector, for all time-points. - vector_to_region = self.compute_approach_vector( - position_data, - boundary=boundary, - direction=approach_direction, - unit=True, + approach_vector = ( + self.compute_approach_vector( + position_data, + boundary=boundary, + direction=approach_direction, + unit=True, + ) + .rename({"vector to": "space"}) + .assign_coords( + { + "space": ["x", "y"] + if len(data["space"]) == 2 + else ["x", "y", "z"] + } + ) ) # Then, compute signed angles at all time-points angles = compute_signed_angle_2d( - vector_to_region, + approach_vector, reference_vector, v_as_left_operand=ref_as_left_opperand, ) @@ -571,7 +581,7 @@ def compute_egocentric_angle( position_data = position_data.mean(dim="keypoints") # Determine the approach vector, for all time-points. - vector_to_region = ( + approach_vector = ( self.compute_approach_vector( position_data, boundary=boundary, @@ -593,7 +603,7 @@ def compute_egocentric_angle( data, left_keypoint=left_keypoint, right_keypoint=right_keypoint, - reference_vector=vector_to_region, + reference_vector=approach_vector, camera_view=camera_view, in_radians=in_radians, angle_rotates=rotation_angle, diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py index 95af01b02..757daf21b 100644 --- a/tests/test_unit/test_roi/test_angles.py +++ b/tests/test_unit/test_roi/test_angles.py @@ -1,5 +1,5 @@ import re -from typing import Any +from typing import Any, Literal import numpy as np import pytest @@ -78,7 +78,7 @@ def points_in_the_plane() -> xr.DataArray: @pytest.mark.parametrize( - ["region", "data", "fn_args", "expected_output"], + ["region", "data", "fn_args", "expected_output", "which_method"], [ pytest.param( "unit_square_with_hole", @@ -89,7 +89,19 @@ def points_in_the_plane() -> xr.DataArray: "angle_rotates": "elephant to region", }, ValueError("Unknown angle convention: elephant to region"), - id="Unknown angle convention (checked before other failures)", + "compute_egocentric_angle", + id="[E] Unknown angle convention", + ), + pytest.param( + "unit_square_with_hole", + "points_in_the_plane", + { + "position_keypoint": "midpt", + "angle_rotates": "elephant to region", + }, + ValueError("Unknown angle convention: elephant to region"), + "compute_allocentric_angle", + id="[A] Unknown angle convention", ), pytest.param( "unit_square_with_hole", @@ -107,7 +119,8 @@ def points_in_the_plane() -> xr.DataArray: np.rad2deg(np.arccos(2.0 / np.sqrt(5.0))), ] ), - id="Default args", + "compute_egocentric_angle", + id="[E] Default args", ), pytest.param( "unit_square_with_hole", @@ -126,7 +139,8 @@ def points_in_the_plane() -> xr.DataArray: np.rad2deg(np.pi / 2.0 + np.arcsin(1.0 / np.sqrt(5.0))), ] ), - id="Non-default position", + "compute_egocentric_angle", + id="[E] Non-default position", ), pytest.param( "unit_square", @@ -144,7 +158,8 @@ def points_in_the_plane() -> xr.DataArray: float("nan"), ] ), - id="0-approach vectors (nan returns)", + "compute_egocentric_angle", + id="[E] 0-approach vectors (nan returns)", ), pytest.param( "unit_square", @@ -163,16 +178,75 @@ def points_in_the_plane() -> xr.DataArray: np.rad2deg(np.arccos(2.0 / np.sqrt(5.0))), ] ), - id="Force boundary calculations", + "compute_egocentric_angle", + id="[E] Force boundary calculations", + ), + pytest.param( + "unit_square_with_hole", + "points_in_the_plane", + { + "position_keypoint": "midpt", + }, + np.array( + [ + -135.0, + 0.0, + 90.0, + -135.0, + 180.0, + ] + ), + "compute_allocentric_angle", + id="[A] Default args", + ), + pytest.param( + "unit_square", + "points_in_the_plane", + { + "position_keypoint": "midpt", + }, + np.array( + [ + -135.0, + 0.0, + float("nan"), + -135.0, + float("nan"), + ] + ), + "compute_allocentric_angle", + id="[A] 0-approach vectors", + ), + pytest.param( + "unit_square", + "points_in_the_plane", + { + "position_keypoint": "midpt", + "boundary": True, + }, + np.array( + [ + -135.0, + 0.0, + 90.0, + -135.0, + 180.0, + ] + ), + "compute_allocentric_angle", + id="[A] Force boundary calculation", ), ], ) -def test_egocentric_angle( +def test_centric_angle( push_into_range, region: BaseRegionOfInterest, data: xr.DataArray, fn_args: dict[str, Any], expected_output: xr.DataArray | Exception, + which_method: Literal[ + "compute_allocentric_angle", "compute_egocentric_angle" + ], request, ) -> None: """Test computation of the egocentric angle. @@ -197,26 +271,29 @@ def test_egocentric_angle( if isinstance(expected_output, np.ndarray): expected_output = xr.DataArray(data=expected_output, dims=["time"]) + method = getattr(region, which_method) + if which_method == "compute_egocentric_angle": + other_vector_name = "forward" + elif which_method == "compute_allocentric_angle": + other_vector_name = "ref" + if isinstance(expected_output, Exception): with pytest.raises( type(expected_output), match=re.escape(str(expected_output)) ): - region.compute_egocentric_angle(data, **fn_args) + method(data, **fn_args) else: - angles = region.compute_egocentric_angle(data, **fn_args) - + angles = method(data, **fn_args) xr.testing.assert_allclose(angles, expected_output) # Check reversal of the angle convention if ( - fn_args.get("angle_rotates", "approach to forward") - == "approach to forward" + fn_args.get("angle_rotates", f"approach to {other_vector_name}") + == f"approach to {other_vector_name}" ): - fn_args["angle_rotates"] = "forward to approach" + fn_args["angle_rotates"] = f"{other_vector_name} to approach" else: - fn_args["angle_rotates"] = "approach to forward" - reverse_angles = push_into_range( - region.compute_egocentric_angle(data, **fn_args) - ) + fn_args["angle_rotates"] = f"approach to {other_vector_name}" + reverse_angles = push_into_range(method(data, **fn_args)) xr.testing.assert_allclose(angles, push_into_range(-reverse_angles)) From 26388047b0330220af8ec36b9dbba6ef506e0206 Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Mon, 17 Feb 2025 11:41:46 +0000 Subject: [PATCH 11/39] CodeCov can't typecheck --- movement/roi/base.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index c2d8f46d6..38b68d617 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -3,10 +3,11 @@ from __future__ import annotations from collections.abc import Hashable, Sequence -from typing import TYPE_CHECKING, Literal, TypeAlias +from typing import Literal, TypeAlias import numpy as np import shapely +import xarray as xr from numpy.typing import ArrayLike from shapely.coords import CoordinateSequence @@ -15,9 +16,6 @@ from movement.utils.logging import log_error from movement.utils.vector import compute_signed_angle_2d -if TYPE_CHECKING: - import xarray as xr - LineLike: TypeAlias = shapely.LinearRing | shapely.LineString PointLike: TypeAlias = list[float] | tuple[float, ...] PointLikeList: TypeAlias = Sequence[PointLike] From d74866d5ae7f486c93a1b70097ef62acc070df56 Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Mon, 17 Feb 2025 11:50:48 +0000 Subject: [PATCH 12/39] Refactor computation of approach vector from keypoint centroid --- movement/roi/base.py | 85 ++++++++++++++++++++++---------------------- 1 file changed, 43 insertions(+), 42 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index 38b68d617..5a34050ea 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -165,6 +165,39 @@ def __str__(self) -> str: # noqa: D105 f"({n_points}{display_type})\n" ) + " -> ".join(f"({c[0]}, {c[1]})" for c in self.coords) + def _approach_from_keypoint_centroid( + self, + data: xr.DataArray, + position_keypoint: Hashable | Sequence[Hashable], + boundary: bool, + approach_direction: str, + ) -> xr.DataArray: + """Compute the approach vector from the centroid of some keypoints. + + Intended for internal use when calculating ego- and allocentric + boundary angles. See the corresponding methods for parameter details. + """ + position_data = data.sel(keypoints=position_keypoint, drop=True) + if "keypoints" in position_data.dims: + position_data = position_data.mean(dim="keypoints") + + return ( + self.compute_approach_vector( + position_data, + boundary=boundary, + direction=approach_direction, + unit=True, + ) + .rename({"vector to": "space"}) + .assign_coords( + { + "space": ["x", "y"] + if len(data["space"]) == 2 + else ["x", "y", "z"] + } + ) + ) + @broadcastable_method(only_broadcastable_along="space") def contains_point( self, @@ -442,28 +475,12 @@ def compute_allocentric_angle( else: raise ValueError(f"Unknown angle convention: {angle_rotates}") - # If we are given multiple position keypoints, we take the average of - # them all. - position_data = data.sel(keypoints=position_keypoint, drop=True) - if "keypoints" in position_data.dims: - position_data = position_data.mean(dim="keypoints") - # Determine the approach vector, for all time-points. - approach_vector = ( - self.compute_approach_vector( - position_data, - boundary=boundary, - direction=approach_direction, - unit=True, - ) - .rename({"vector to": "space"}) - .assign_coords( - { - "space": ["x", "y"] - if len(data["space"]) == 2 - else ["x", "y", "z"] - } - ) + approach_vector = self._approach_from_keypoint_centroid( + data, + position_keypoint=position_keypoint, + boundary=boundary, + approach_direction=approach_direction, ) # Then, compute signed angles at all time-points @@ -572,28 +589,12 @@ def compute_egocentric_angle( if rotation_angle not in ["ref to forward", "forward to ref"]: raise ValueError(f"Unknown angle convention: {angle_rotates}") - # If we are given multiple position keypoints, we take the average of - # them all. - position_data = data.sel(keypoints=position_keypoint, drop=True) - if "keypoints" in position_data.dims: - position_data = position_data.mean(dim="keypoints") - # Determine the approach vector, for all time-points. - approach_vector = ( - self.compute_approach_vector( - position_data, - boundary=boundary, - direction=approach_direction, - unit=True, - ) - .rename({"vector to": "space"}) - .assign_coords( - { - "space": ["x", "y"] - if len(data["space"]) == 2 - else ["x", "y", "z"] - } - ) + approach_vector = self._approach_from_keypoint_centroid( + data, + position_keypoint=position_keypoint, + boundary=boundary, + approach_direction=approach_direction, ) # Then, compute signed angles at all time-points From c412a6ca24d023168b3799f265a468b5cc895e3c Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Mon, 17 Feb 2025 12:00:16 +0000 Subject: [PATCH 13/39] Use literal instead of repeat value --- movement/roi/base.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index 5a34050ea..bc20cf566 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -16,6 +16,9 @@ from movement.utils.logging import log_error from movement.utils.vector import compute_signed_angle_2d +ApproachVectorDirections: TypeAlias = Literal[ + "region to point", "point to region" +] LineLike: TypeAlias = shapely.LinearRing | shapely.LineString PointLike: TypeAlias = list[float] | tuple[float, ...] PointLikeList: TypeAlias = Sequence[PointLike] @@ -186,7 +189,7 @@ def _approach_from_keypoint_centroid( position_data, boundary=boundary, direction=approach_direction, - unit=True, + unit=False, # avoid /0 converting valid points to NaNs ) .rename({"vector to": "space"}) .assign_coords( @@ -329,12 +332,10 @@ def compute_approach_vector( self, point: ArrayLike, boundary: bool = False, - direction: Literal[ - "point to region", "region to point" - ] = "point to region", + direction: ApproachVectorDirections = "point to region", unit: bool = True, ) -> np.ndarray: - """Compute the approach vector a ``point`` to the region. + """Compute the approach vector from a ``point`` to the region. The approach vector is defined as the vector directed from the ``point`` provided, to the closest point that belongs to the region. @@ -350,7 +351,7 @@ def compute_approach_vector( If True, finds the vector to the nearest point on the boundary of the region, instead of the nearest point within the region. (See Notes). Default is False. - direction : Literal["point to region", "region to point"] + direction : ApproachVectorDirections Which direction the returned vector should point in. Default is "point to region". unit : bool @@ -386,7 +387,7 @@ def compute_approach_vector( if unit: norm = np.sqrt(np.sum(displacement_vector**2)) # Cannot normalise the 0 vector - if norm != 0.0: + if not np.isclose(norm, 0.0): displacement_vector /= norm return displacement_vector @@ -397,9 +398,7 @@ def compute_allocentric_angle( angle_rotates: Literal[ "approach to ref", "ref to approach" ] = "approach to ref", - approach_direction: Literal[ - "point to region", "region to point" - ] = "point to region", + approach_direction: ApproachVectorDirections = "point to region", boundary: bool = False, in_radians: bool = False, reference_vector: np.ndarray | xr.DataArray = None, @@ -430,7 +429,7 @@ def compute_allocentric_angle( angle_rotates : Literal["approach to ref", "ref to approach"] Direction of the signed angle returned. Default is ``"approach to ref"``. - approach_direction : Literal["point to region", "region to point"] + approach_direction : ApproachVectorDirections Direction to use for the vector of closest approach. Default ``"point to region"``. boundary : bool @@ -501,9 +500,7 @@ def compute_egocentric_angle( angle_rotates: Literal[ "approach to forward", "forward to approach" ] = "approach to forward", - approach_direction: Literal[ - "point to region", "region to point" - ] = "point to region", + approach_direction: ApproachVectorDirections = "point to region", boundary: bool = False, camera_view: Literal["top_down", "bottom_up"] = "top_down", in_radians: bool = False, @@ -540,7 +537,7 @@ def compute_egocentric_angle( angle_rotates : Literal["approach to forward", "forward to approach"] Direction of the signed angle returned. Default is ``"approach to forward"``. - approach_direction : Literal["point to region", "region to point"] + approach_direction : ApproachVectorDirections Direction to use for the vector of closest approach. Default ``"point to region"``. boundary : bool From df846647a07ce24870d11ae60cf8f8e18a4981f7 Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Mon, 17 Feb 2025 12:11:14 +0000 Subject: [PATCH 14/39] OK, now I've literal-ed the literal. Happy SonarQube? --- movement/roi/base.py | 15 +++++++++------ tests/test_unit/test_roi/test_nearest_points.py | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index bc20cf566..21ee553aa 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -16,15 +16,18 @@ from movement.utils.logging import log_error from movement.utils.vector import compute_signed_angle_2d -ApproachVectorDirections: TypeAlias = Literal[ - "region to point", "point to region" -] LineLike: TypeAlias = shapely.LinearRing | shapely.LineString PointLike: TypeAlias = list[float] | tuple[float, ...] PointLikeList: TypeAlias = Sequence[PointLike] RegionLike: TypeAlias = shapely.Polygon SupportedGeometry: TypeAlias = LineLike | RegionLike +TowardsRegion = "point to region" +AwayFromRegion = "region to point" +ApproachVectorDirections: TypeAlias = Literal[ # type: ignore[valid-type] + f"{TowardsRegion}", f"{AwayFromRegion}" +] + class BaseRegionOfInterest: """Base class for representing regions of interest (RoIs). @@ -332,7 +335,7 @@ def compute_approach_vector( self, point: ArrayLike, boundary: bool = False, - direction: ApproachVectorDirections = "point to region", + direction: ApproachVectorDirections = TowardsRegion, unit: bool = True, ) -> np.ndarray: """Compute the approach vector from a ``point`` to the region. @@ -398,7 +401,7 @@ def compute_allocentric_angle( angle_rotates: Literal[ "approach to ref", "ref to approach" ] = "approach to ref", - approach_direction: ApproachVectorDirections = "point to region", + approach_direction: ApproachVectorDirections = TowardsRegion, boundary: bool = False, in_radians: bool = False, reference_vector: np.ndarray | xr.DataArray = None, @@ -500,7 +503,7 @@ def compute_egocentric_angle( angle_rotates: Literal[ "approach to forward", "forward to approach" ] = "approach to forward", - approach_direction: ApproachVectorDirections = "point to region", + approach_direction: ApproachVectorDirections = TowardsRegion, boundary: bool = False, camera_view: Literal["top_down", "bottom_up"] = "top_down", in_radians: bool = False, diff --git a/tests/test_unit/test_roi/test_nearest_points.py b/tests/test_unit/test_roi/test_nearest_points.py index be0ff2548..e13d1dec4 100644 --- a/tests/test_unit/test_roi/test_nearest_points.py +++ b/tests/test_unit/test_roi/test_nearest_points.py @@ -358,7 +358,7 @@ def test_approach_vector( with pytest.raises( type(expected_output), match=re.escape(str(expected_output)) ): - vector_to = region.compute_approach_vector(point, **other_fn_args) + region.compute_approach_vector(point, **other_fn_args) else: vector_to = region.compute_approach_vector(point, **other_fn_args) assert np.allclose(vector_to, expected_output) From 77c355415129f394a2db27329d04569ccd9449c8 Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Mon, 17 Feb 2025 12:23:03 +0000 Subject: [PATCH 15/39] Fix rebase barfs --- tests/test_unit/test_roi/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_unit/test_roi/conftest.py b/tests/test_unit/test_roi/conftest.py index b8eeb720c..149944bd1 100644 --- a/tests/test_unit/test_roi/conftest.py +++ b/tests/test_unit/test_roi/conftest.py @@ -20,7 +20,7 @@ def unit_square_pts() -> np.ndarray: @pytest.fixture() def unit_square_hole(unit_square_pts: np.ndarray) -> np.ndarray: - """Hole in the shape of a 0.25 side-length square centred on 0.5, 0.5.""" + """Hole in the shape of a 0.5 side-length square centred on (0.5, 0.5).""" return 0.25 + (unit_square_pts.copy() * 0.5) From 2672938673dfca2c0887f99c5ddd3351635d7be3 Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Mon, 17 Feb 2025 14:40:22 +0000 Subject: [PATCH 16/39] Write method for computing angle to segment support --- movement/roi/base.py | 52 ++++++--- movement/roi/line.py | 137 +++++++++++++++++++++++- tests/test_unit/test_roi/conftest.py | 7 ++ tests/test_unit/test_roi/test_angles.py | 67 ++++++++++++ tests/test_unit/test_roi/test_normal.py | 31 ++++++ 5 files changed, 278 insertions(+), 16 deletions(-) create mode 100644 tests/test_unit/test_roi/test_normal.py diff --git a/movement/roi/base.py b/movement/roi/base.py index 21ee553aa..ac0e97b17 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Hashable, Sequence -from typing import Literal, TypeAlias +from typing import Any, Literal, TypeAlias import numpy as np import shapely @@ -171,30 +171,50 @@ def __str__(self) -> str: # noqa: D105 f"({n_points}{display_type})\n" ) + " -> ".join(f"({c[0]}, {c[1]})" for c in self.coords) - def _approach_from_keypoint_centroid( + def _vector_from_keypoint_centroid( self, data: xr.DataArray, position_keypoint: Hashable | Sequence[Hashable], - boundary: bool, - approach_direction: str, + renamed_dimension: str = "vector to", + which_method: str = "compute_approach_vector", + **method_args: Any, ) -> xr.DataArray: - """Compute the approach vector from the centroid of some keypoints. + """Compute a vector from the centroid of some keypoints. Intended for internal use when calculating ego- and allocentric - boundary angles. See the corresponding methods for parameter details. + boundary angles. First, the position of the centroid of the given + keypoints is computed. Then, this value, along with the other keyword + arguments passed, is given to the method specified in ``which_method`` + in order to compute the necessary vectors. + + Parameters + ---------- + data : xarray.DataArray + DataArray of position data. + position_keypoint : Hashable | Sequence[Hashable] + Keypoints to compute centroid of, then compute vectors to/from. + renamed_dimension : str + The name of the new dimension created by ``which_method`` that + contains the corresponding vectors. This dimension will be renamed + to 'space' and given coordinates x, y [, z]. + which_method : str + Name of a class method, which will be used to compute the + appropriate vectors. + method_args : Any + Additional keyword arguments needed by the specified class method. + """ position_data = data.sel(keypoints=position_keypoint, drop=True) if "keypoints" in position_data.dims: position_data = position_data.mean(dim="keypoints") + method = getattr(self, which_method) return ( - self.compute_approach_vector( + method( position_data, - boundary=boundary, - direction=approach_direction, - unit=False, # avoid /0 converting valid points to NaNs + **method_args, ) - .rename({"vector to": "space"}) + .rename({renamed_dimension: "space"}) .assign_coords( { "space": ["x", "y"] @@ -478,11 +498,12 @@ def compute_allocentric_angle( raise ValueError(f"Unknown angle convention: {angle_rotates}") # Determine the approach vector, for all time-points. - approach_vector = self._approach_from_keypoint_centroid( + approach_vector = self._vector_from_keypoint_centroid( data, position_keypoint=position_keypoint, boundary=boundary, - approach_direction=approach_direction, + direction=approach_direction, + unit=False, ) # Then, compute signed angles at all time-points @@ -590,11 +611,12 @@ def compute_egocentric_angle( raise ValueError(f"Unknown angle convention: {angle_rotates}") # Determine the approach vector, for all time-points. - approach_vector = self._approach_from_keypoint_centroid( + approach_vector = self._vector_from_keypoint_centroid( data, position_keypoint=position_keypoint, boundary=boundary, - approach_direction=approach_direction, + direction=approach_direction, + unit=False, ) # Then, compute signed angles at all time-points diff --git a/movement/roi/line.py b/movement/roi/line.py index 5359c830e..e8c33a7d8 100644 --- a/movement/roi/line.py +++ b/movement/roi/line.py @@ -1,6 +1,21 @@ """1-dimensional lines of interest.""" -from movement.roi.base import BaseRegionOfInterest, PointLikeList +from collections.abc import Hashable, Sequence +from typing import Literal + +import numpy as np +import xarray as xr +from numpy.typing import ArrayLike + +from movement.kinematics import compute_forward_vector_angle +from movement.roi.base import ( + ApproachVectorDirections, + AwayFromRegion, + BaseRegionOfInterest, + PointLikeList, + TowardsRegion, +) +from movement.utils.broadcasting import broadcastable_method class LineOfInterest(BaseRegionOfInterest): @@ -56,3 +71,123 @@ def __init__( """ super().__init__(points, dimensions=1, closed=loop, name=name) + + @broadcastable_method( + only_broadcastable_along="space", new_dimension_name="normal" + ) + def normal(self, on_same_side_as: ArrayLike = (0.0, 0.0)) -> np.ndarray: + """Compute the unit normal to this line. + + There are always two normal vectors to chose from. The normal vector + that points to the same side of the segment as that which the point + ``on_same_side_as`` lies on, is the returned normal vector. + + Parameters + ---------- + on_same_side_as : ArrayLike + Point in the (x,y) plane to orientate the returned normal vector + towards. + + """ + on_same_side_as = np.array(on_same_side_as) + + parallel = np.array(self.region.coords[1]) - np.array( + self.region.coords[0] + ) + normal = np.array([parallel[1], -parallel[0]]) + normal /= np.sqrt(np.sum(normal**2)) + + if np.sum(on_same_side_as * (normal - self.region.coords[0])) < 0: + normal *= -1.0 + return normal + + def compute_angle_to_support_plane_of_segment( + self, + data: xr.DataArray, + left_keypoint: Hashable, + right_keypoint: Hashable, + angle_rotates: Literal[ + "forward to normal", "normal to forward" + ] = "normal to forward", + camera_view: Literal["top_down", "bottom_up"] = "top_down", + in_radians: bool = False, + normal_direction: ApproachVectorDirections = TowardsRegion, + position_keypoint: Hashable | Sequence[Hashable] | None = None, + ) -> xr.DataArray: + """Compute the signed angle between the normal and a forward vector. + + This method is identical to ``compute_egocentric_angle``, except that + rather than the angle between the approach vector and a forward vector, + the angle between the normal to the segment and the approach vector is + returned. + + For finite segments, the normal to the infinite extension of the + segment is used in the calculation. + + Parameters + ---------- + data : xarray.DataArray + `DataArray` of positions that has at least 3 dimensions; "time", + "space", and ``keypoints_dimension``. + left_keypoint : Hashable + The left keypoint defining the forward vector, as passed to + func:``compute_forward_vector_angle``. + right_keypoint : Hashable + The right keypoint defining the forward vector, as passed to + func:``compute_forward_vector_angle``. + angle_rotates : Literal["approach to forward", "forward to approach"] + Direction of the signed angle returned. Default is + ``"approach to forward"``. + camera_view : Literal["top_down", "bottom_up"] + Passed to func:`compute_forward_vector_angle`. Default + ``"top_down"``. + in_radians : bool + If ``True``, angles are returned in radians. Otherwise angles are + returned in degrees. Default ``False``. + normal_direction : ApproachVectorDirections + Direction to use for the normal vector. Default is + ``"point to region"``. + position_keypoint : Hashable | Sequence[Hashable], optional + The keypoint defining the origin of the approach vector. If + provided as a sequence, the average of all provided keypoints is + used. By default, the centroid of ``left_keypoint`` and + ``right_keypoint`` is used. + + """ + # Default to centre of left and right keypoints for position, + # if not provided. + if position_keypoint is None: + position_keypoint = [left_keypoint, right_keypoint] + + normal = self._vector_from_keypoint_centroid( + data, + position_keypoint=position_keypoint, + renamed_dimension="normal", + which_method="normal", + ) + # The normal from the point to the region is the same as the normal + # that points into the opposite side of the segment. + if normal_direction == TowardsRegion: + normal *= -1.0 + elif normal_direction != AwayFromRegion: + raise ValueError( + f"Unknown convention for normal vector: {normal_direction}" + ) + + # Translate the more explicit convention used here into the convention + # used by our backend functions. + rotation_angle: Literal["ref to forward", "forward to ref"] = ( + angle_rotates.replace("normal", "ref") # type: ignore + ) + if rotation_angle not in ["ref to forward", "forward to ref"]: + raise ValueError(f"Unknown angle convention: {angle_rotates}") + + return compute_forward_vector_angle( + data, + left_keypoint=left_keypoint, + right_keypoint=right_keypoint, + reference_vector=normal, + camera_view=camera_view, + in_radians=in_radians, + angle_rotates=rotation_angle, + ) diff --git a/tests/test_unit/test_roi/conftest.py b/tests/test_unit/test_roi/conftest.py index 149944bd1..abee5c92e 100644 --- a/tests/test_unit/test_roi/conftest.py +++ b/tests/test_unit/test_roi/conftest.py @@ -2,9 +2,16 @@ import pytest import xarray as xr +from movement.roi import LineOfInterest from movement.roi.polygon import PolygonOfInterest +@pytest.fixture +def segment_of_y_equals_x() -> LineOfInterest: + """Line segment from (0,0) to (1,1).""" + return LineOfInterest([(0, 0), (1, 1)]) + + @pytest.fixture() def unit_square_pts() -> np.ndarray: return np.array( diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py index 757daf21b..fe6da101b 100644 --- a/tests/test_unit/test_roi/test_angles.py +++ b/tests/test_unit/test_roi/test_angles.py @@ -5,6 +5,7 @@ import pytest import xarray as xr +from movement.roi import LineOfInterest from movement.roi.base import BaseRegionOfInterest @@ -297,3 +298,69 @@ def test_centric_angle( reverse_angles = push_into_range(method(data, **fn_args)) xr.testing.assert_allclose(angles, push_into_range(-reverse_angles)) + + +@pytest.fixture +def points_around_segment() -> xr.DataArray: + """Points around the segment_of_y_equals_x. + + Data has (time, space, keypoints) dimensions, shape (, 2, 2). + + Keypoints are "left" and "right". + + time 1: + left @ (0., 1.), right @ (0.05, 0.95). + Fwd vector is (-1, -1). + time 2: + left @ (1., 0.), right @ (0.95, 0.05). + Fwd vector is (-1, -1). + time 3: + left @ (1., 2.), right @ (1.05, 1.95). + Fwd vector is (-1, -1). + The egocentric angle will differ when using this point. + """ + points = np.zeros(shape=(3, 2, 2)) + points[:, :, 0] = [ + [0.0, 1.0], + [1.0, 0.0], + [1.0, 2.0], + ] + points[:, :, 1] = [ + [0.05, 0.95], + [0.95, 0.05], + [1.05, 1.95], + ] + return xr.DataArray( + data=points, + dims=["time", "space", "keypoints"], + coords={ + "space": ["x", "y"], + "keypoints": ["left", "right"], + }, + ) + + +def test_angle_to_support_plane( + segment_of_y_equals_x: LineOfInterest, + points_around_segment: xr.DataArray, +) -> None: + expected_output = xr.DataArray( + data=np.array([-90.0, -90.0, -90.0]), dims=["time"] + ) + should_be_same_as_egocentric = expected_output.copy( + data=[True, True, False], deep=True + ) + + angles_to_support = ( + segment_of_y_equals_x.compute_angle_to_support_plane_of_segment( + points_around_segment, left_keypoint="left", right_keypoint="right" + ) + ) + xr.testing.assert_allclose(expected_output, angles_to_support) + + egocentric_angles = segment_of_y_equals_x.compute_egocentric_angle( + points_around_segment, left_keypoint="left", right_keypoint="right" + ) + xr.testing.assert_equal( + should_be_same_as_egocentric, egocentric_angles == angles_to_support + ) diff --git a/tests/test_unit/test_roi/test_normal.py b/tests/test_unit/test_roi/test_normal.py new file mode 100644 index 000000000..e4fda448e --- /dev/null +++ b/tests/test_unit/test_roi/test_normal.py @@ -0,0 +1,31 @@ +import numpy as np +import pytest +from numpy.typing import ArrayLike + +from movement.roi import LineOfInterest + +SQRT_2 = np.sqrt(2.0) + + +@pytest.mark.parametrize( + ["point", "expected_normal"], + [ + pytest.param( + (0.0, 1.0), (-1.0 / SQRT_2, 1.0 / SQRT_2), id="Normal to (0, 1)" + ), + pytest.param( + (1.0, 0.0), (1.0 / SQRT_2, -1.0 / SQRT_2), id="Normal to (1, 0)" + ), + pytest.param( + (1.0, 2.0), (-1.0 / SQRT_2, 1.0 / SQRT_2), id="Normal to (1, 2)" + ), + ], +) +def test_normal( + segment_of_y_equals_x: LineOfInterest, + point: ArrayLike, + expected_normal: np.ndarray, +) -> None: + computed_normal = segment_of_y_equals_x.normal(point) + + assert np.allclose(computed_normal, expected_normal) From 13526e20bdb5bca28419974d594224309819b4ca Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Tue, 18 Feb 2025 11:48:22 +0000 Subject: [PATCH 17/39] Give internal method a more descriptive name --- movement/roi/base.py | 6 +++--- movement/roi/line.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index ac0e97b17..50432ec99 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -171,7 +171,7 @@ def __str__(self) -> str: # noqa: D105 f"({n_points}{display_type})\n" ) + " -> ".join(f"({c[0]}, {c[1]})" for c in self.coords) - def _vector_from_keypoint_centroid( + def _vector_from_centroid_of_keypoints( self, data: xr.DataArray, position_keypoint: Hashable | Sequence[Hashable], @@ -498,7 +498,7 @@ def compute_allocentric_angle( raise ValueError(f"Unknown angle convention: {angle_rotates}") # Determine the approach vector, for all time-points. - approach_vector = self._vector_from_keypoint_centroid( + approach_vector = self._vector_from_centroid_of_keypoints( data, position_keypoint=position_keypoint, boundary=boundary, @@ -611,7 +611,7 @@ def compute_egocentric_angle( raise ValueError(f"Unknown angle convention: {angle_rotates}") # Determine the approach vector, for all time-points. - approach_vector = self._vector_from_keypoint_centroid( + approach_vector = self._vector_from_centroid_of_keypoints( data, position_keypoint=position_keypoint, boundary=boundary, diff --git a/movement/roi/line.py b/movement/roi/line.py index e8c33a7d8..0947902b8 100644 --- a/movement/roi/line.py +++ b/movement/roi/line.py @@ -159,7 +159,7 @@ def compute_angle_to_support_plane_of_segment( if position_keypoint is None: position_keypoint = [left_keypoint, right_keypoint] - normal = self._vector_from_keypoint_centroid( + normal = self._vector_from_centroid_of_keypoints( data, position_keypoint=position_keypoint, renamed_dimension="normal", From de3f3b0e278ff407c1efabeb80108664c9634078 Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Tue, 18 Feb 2025 11:57:29 +0000 Subject: [PATCH 18/39] Avoid implicit float comparison --- tests/test_unit/test_roi/test_angles.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py index fe6da101b..c14d1cd1e 100644 --- a/tests/test_unit/test_roi/test_angles.py +++ b/tests/test_unit/test_roi/test_angles.py @@ -361,6 +361,7 @@ def test_angle_to_support_plane( egocentric_angles = segment_of_y_equals_x.compute_egocentric_angle( points_around_segment, left_keypoint="left", right_keypoint="right" ) - xr.testing.assert_equal( - should_be_same_as_egocentric, egocentric_angles == angles_to_support + values_are_close = egocentric_angles.copy( + data=np.isclose(egocentric_angles, angles_to_support), deep=True ) + xr.testing.assert_equal(should_be_same_as_egocentric, values_are_close) From 6d82bc13c180e0d6830dee8d6f89110d293361ee Mon Sep 17 00:00:00 2001 From: willGraham01 Date: Tue, 18 Feb 2025 12:09:03 +0000 Subject: [PATCH 19/39] Remove redundant error-catches that are checked in lower-level functions --- movement/roi/line.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/movement/roi/line.py b/movement/roi/line.py index 0947902b8..f21205328 100644 --- a/movement/roi/line.py +++ b/movement/roi/line.py @@ -10,7 +10,6 @@ from movement.kinematics import compute_forward_vector_angle from movement.roi.base import ( ApproachVectorDirections, - AwayFromRegion, BaseRegionOfInterest, PointLikeList, TowardsRegion, @@ -169,18 +168,12 @@ def compute_angle_to_support_plane_of_segment( # that points into the opposite side of the segment. if normal_direction == TowardsRegion: normal *= -1.0 - elif normal_direction != AwayFromRegion: - raise ValueError( - f"Unknown convention for normal vector: {normal_direction}" - ) # Translate the more explicit convention used here into the convention # used by our backend functions. rotation_angle: Literal["ref to forward", "forward to ref"] = ( angle_rotates.replace("normal", "ref") # type: ignore ) - if rotation_angle not in ["ref to forward", "forward to ref"]: - raise ValueError(f"Unknown angle convention: {angle_rotates}") return compute_forward_vector_angle( data, From 64d1cf0f4b88668a72bc385710a170a377415f94 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Fri, 21 Feb 2025 10:05:08 +0000 Subject: [PATCH 20/39] Implememnt minor suggestions for RoI class changes from review --- movement/roi/base.py | 40 ++++++++++++++++++---------------------- movement/roi/line.py | 21 +++++++++++++-------- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index 50432ec99..cb099b4d6 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -30,25 +30,21 @@ class BaseRegionOfInterest: - """Base class for representing regions of interest (RoIs). + """Base class for regions of interest (RoIs). Regions of interest can be either 1 or 2 dimensional, and are represented - by appropriate ``shapely.Geometry`` objects depending on which. Note that - there are a number of discussions concerning subclassing ``shapely`` - objects; - - - https://github.com/shapely/shapely/issues/1233. - - https://stackoverflow.com/questions/10788976/how-do-i-properly-inherit-from-a-superclass-that-has-a-new-method - - To avoid the complexities of subclassing ourselves, we simply elect to wrap - the appropriate ``shapely`` object in the ``_shapely_geometry`` attribute, - accessible via the property ``region``. This also has the benefit of - allowing us to 'forbid' certain operations (that ``shapely`` would - otherwise interpret in a set-theoretic sense, giving confusing answers to - users). - - This class is not designed to be instantiated directly. It can be - instantiated, however its primary purpose is to reduce code duplication. + by corresponding ``shapely.Geometry`` objects. + + To avoid the complexities of subclassing ``shapely`` objects (due to them + relying on ``__new__()`` methods, see + https://github.com/shapely/shapely/issues/1233), we simply wrap the + relevant ``shapely`` object in the ``_shapely_geometry`` attribute of the + class. This is accessible via the property ``region``. This also allows us + to forbid certain operations and make the manipulation of ``shapely`` + objects more user friendly. + + Although this class can be instantiated directly, it is not designed for + this. Its primary purpose is to reduce code duplication. """ __default_name: str = "Un-named region" @@ -179,7 +175,7 @@ def _vector_from_centroid_of_keypoints( which_method: str = "compute_approach_vector", **method_args: Any, ) -> xr.DataArray: - """Compute a vector from the centroid of some keypoints. + """Compute a vector from the centroid of some keypoints to a target. Intended for internal use when calculating ego- and allocentric boundary angles. First, the position of the centroid of the given @@ -379,7 +375,7 @@ def compute_approach_vector( "point to region". unit : bool If ``True``, the unit vector in the appropriate direction is - returned, otherwise the displacement vector is returned. + returned, otherwise the approach vector is returned un-normalised. Default is ``True``. Returns @@ -491,9 +487,9 @@ def compute_allocentric_angle( # Translate the more explicit convention used here into the convention # used by our backend functions. if angle_rotates == "ref to approach": - ref_as_left_opperand = True + ref_as_left_operand = True elif angle_rotates == "approach to ref": - ref_as_left_opperand = False + ref_as_left_operand = False else: raise ValueError(f"Unknown angle convention: {angle_rotates}") @@ -510,7 +506,7 @@ def compute_allocentric_angle( angles = compute_signed_angle_2d( approach_vector, reference_vector, - v_as_left_operand=ref_as_left_opperand, + v_as_left_operand=ref_as_left_operand, ) if not in_radians: angles *= 180.0 / np.pi diff --git a/movement/roi/line.py b/movement/roi/line.py index f21205328..547410175 100644 --- a/movement/roi/line.py +++ b/movement/roi/line.py @@ -77,26 +77,31 @@ def __init__( def normal(self, on_same_side_as: ArrayLike = (0.0, 0.0)) -> np.ndarray: """Compute the unit normal to this line. - There are always two normal vectors to chose from. The normal vector - that points to the same side of the segment as that which the point - ``on_same_side_as`` lies on, is the returned normal vector. + The unit normal is a vector perpendicular to the input line + whose norm is equal to 1. The direction of the normal vector + is not fully defined: the line divides the 2D plane in two + halves, and the normal could be pointing to either of the half-planes. + For example, an horizontal line divides the 2D plane in a + bottom and a top half-plane, and we can choose whether + the normal points "upwards" or "downwards". We use a sample + point to define the half-plane the normal vector points to. Parameters ---------- on_same_side_as : ArrayLike - Point in the (x,y) plane to orientate the returned normal vector - towards. + A sample point in the (x,y) plane the normal is in. By default, the + origin is used. """ on_same_side_as = np.array(on_same_side_as) - parallel = np.array(self.region.coords[1]) - np.array( + parallel_to_line = np.array(self.region.coords[1]) - np.array( self.region.coords[0] ) - normal = np.array([parallel[1], -parallel[0]]) + normal = np.array([parallel_to_line[1], -parallel_to_line[0]]) normal /= np.sqrt(np.sum(normal**2)) - if np.sum(on_same_side_as * (normal - self.region.coords[0])) < 0: + if np.dot((on_same_side_as - self.region.coords[0]), normal) < 0: normal *= -1.0 return normal From 57edda6a7bd7b5a23ef13504e580d630b8b3deac Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Fri, 21 Feb 2025 10:09:41 +0000 Subject: [PATCH 21/39] Move conftest to fixtures directory --- .../test_roi/conftest.py => fixtures/roi.py} | 11 +++++++++++ 1 file changed, 11 insertions(+) rename tests/{test_unit/test_roi/conftest.py => fixtures/roi.py} (72%) diff --git a/tests/test_unit/test_roi/conftest.py b/tests/fixtures/roi.py similarity index 72% rename from tests/test_unit/test_roi/conftest.py rename to tests/fixtures/roi.py index abee5c92e..0b72f7d38 100644 --- a/tests/test_unit/test_roi/conftest.py +++ b/tests/fixtures/roi.py @@ -14,6 +14,10 @@ def segment_of_y_equals_x() -> LineOfInterest: @pytest.fixture() def unit_square_pts() -> np.ndarray: + """Points that define the 4 corners of a unit-length square. + + The points have the lower-left corner positioned at (0,0). + """ return np.array( [ [0.0, 0.0], @@ -33,6 +37,7 @@ def unit_square_hole(unit_square_pts: np.ndarray) -> np.ndarray: @pytest.fixture def unit_square(unit_square_pts: xr.DataArray) -> PolygonOfInterest: + """Square of unit side-length centred on (0.5, 0.5).""" return PolygonOfInterest(unit_square_pts, name="Unit square") @@ -40,6 +45,12 @@ def unit_square(unit_square_pts: xr.DataArray) -> PolygonOfInterest: def unit_square_with_hole( unit_square_pts: xr.DataArray, unit_square_hole: xr.DataArray ) -> PolygonOfInterest: + """Square of unit side length with an internal hole. + + The "outer" square is centred on (0.5, 0.5) and has side length 1. + The "inner" square, or hole, is centred on (0.5, 0.5) and has side length + 0.5. + """ return PolygonOfInterest( unit_square_pts, holes=[unit_square_hole], name="Unit square with hole" ) From 44149246eb0d5301385cd67551f02ea344c2248b Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Fri, 21 Feb 2025 10:23:08 +0000 Subject: [PATCH 22/39] Patch up test files --- tests/test_unit/test_roi/test_angles.py | 26 ++++++++-------- .../test_unit/test_roi/test_nearest_points.py | 12 ++++---- tests/test_unit/test_roi/test_normal.py | 30 +++++++++++++++---- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py index c14d1cd1e..e5974f760 100644 --- a/tests/test_unit/test_roi/test_angles.py +++ b/tests/test_unit/test_roi/test_angles.py @@ -10,8 +10,8 @@ @pytest.fixture() -def points_in_the_plane() -> xr.DataArray: - """Define a collection of points, used to test the egocentric angle. +def sample_position_array() -> xr.DataArray: + """Return a simulated position array to test the egocentric angle. The data has time, space, and keypoints dimensions. @@ -83,7 +83,7 @@ def points_in_the_plane() -> xr.DataArray: [ pytest.param( "unit_square_with_hole", - "points_in_the_plane", + "sample_position_array", { "left_keypoint": "left", "right_keypoint": "right", @@ -95,7 +95,7 @@ def points_in_the_plane() -> xr.DataArray: ), pytest.param( "unit_square_with_hole", - "points_in_the_plane", + "sample_position_array", { "position_keypoint": "midpt", "angle_rotates": "elephant to region", @@ -106,7 +106,7 @@ def points_in_the_plane() -> xr.DataArray: ), pytest.param( "unit_square_with_hole", - "points_in_the_plane", + "sample_position_array", { "left_keypoint": "left", "right_keypoint": "right", @@ -125,7 +125,7 @@ def points_in_the_plane() -> xr.DataArray: ), pytest.param( "unit_square_with_hole", - "points_in_the_plane", + "sample_position_array", { "left_keypoint": "left", "right_keypoint": "right", @@ -145,7 +145,7 @@ def points_in_the_plane() -> xr.DataArray: ), pytest.param( "unit_square", - "points_in_the_plane", + "sample_position_array", { "left_keypoint": "left", "right_keypoint": "right", @@ -164,7 +164,7 @@ def points_in_the_plane() -> xr.DataArray: ), pytest.param( "unit_square", - "points_in_the_plane", + "sample_position_array", { "left_keypoint": "left", "right_keypoint": "right", @@ -184,7 +184,7 @@ def points_in_the_plane() -> xr.DataArray: ), pytest.param( "unit_square_with_hole", - "points_in_the_plane", + "sample_position_array", { "position_keypoint": "midpt", }, @@ -202,7 +202,7 @@ def points_in_the_plane() -> xr.DataArray: ), pytest.param( "unit_square", - "points_in_the_plane", + "sample_position_array", { "position_keypoint": "midpt", }, @@ -220,7 +220,7 @@ def points_in_the_plane() -> xr.DataArray: ), pytest.param( "unit_square", - "points_in_the_plane", + "sample_position_array", { "position_keypoint": "midpt", "boundary": True, @@ -239,7 +239,7 @@ def points_in_the_plane() -> xr.DataArray: ), ], ) -def test_centric_angle( +def test_ego_and_allocentric_angle_to_region( push_into_range, region: BaseRegionOfInterest, data: xr.DataArray, @@ -250,7 +250,7 @@ def test_centric_angle( ], request, ) -> None: - """Test computation of the egocentric angle. + """Test computation of the egocentric and allocentric angle. Note, we only test functionality explicitly introduced in this method. Input arguments that are just handed to other functions are not explicitly diff --git a/tests/test_unit/test_roi/test_nearest_points.py b/tests/test_unit/test_roi/test_nearest_points.py index e13d1dec4..4466bb92e 100644 --- a/tests/test_unit/test_roi/test_nearest_points.py +++ b/tests/test_unit/test_roi/test_nearest_points.py @@ -10,7 +10,7 @@ @pytest.fixture -def points_of_interest() -> dict[str, np.ndarray]: +def sample_target_points() -> dict[str, np.ndarray]: return xr.DataArray( np.array( [ @@ -84,9 +84,9 @@ def unit_line_in_x() -> LineOfInterest: ), ], ) -def test_distance_to( +def test_distance_point_to_region( region: BaseRegionOfInterest, - points_of_interest: xr.DataArray, + sample_target_points: xr.DataArray, fn_kwargs: dict[str, Any], expected_distances: xr.DataArray, request, @@ -99,7 +99,7 @@ def test_distance_to( ) computed_distances = region.compute_distance_to( - points_of_interest, **fn_kwargs + sample_target_points, **fn_kwargs ) xr.testing.assert_allclose(computed_distances, expected_distances) @@ -208,7 +208,7 @@ def test_distance_to( ) def test_nearest_point_to( region: BaseRegionOfInterest, - points_of_interest: xr.DataArray, + sample_target_points: xr.DataArray, other_fn_args: dict[str, Any], expected_output: xr.DataArray, request, @@ -224,7 +224,7 @@ def test_nearest_point_to( ) nearest_points = region.compute_nearest_point_to( - points_of_interest, **other_fn_args + sample_target_points, **other_fn_args ) xr.testing.assert_allclose(nearest_points, expected_output) diff --git a/tests/test_unit/test_roi/test_normal.py b/tests/test_unit/test_roi/test_normal.py index e4fda448e..d777ab4c5 100644 --- a/tests/test_unit/test_roi/test_normal.py +++ b/tests/test_unit/test_roi/test_normal.py @@ -8,24 +8,42 @@ @pytest.mark.parametrize( - ["point", "expected_normal"], + ["segment", "point", "expected_normal"], [ pytest.param( - (0.0, 1.0), (-1.0 / SQRT_2, 1.0 / SQRT_2), id="Normal to (0, 1)" + "segment_of_y_equals_x", + (0.0, 1.0), + (-1.0 / SQRT_2, 1.0 / SQRT_2), + id="Normal pointing to half-plane with point (0, 1)", ), pytest.param( - (1.0, 0.0), (1.0 / SQRT_2, -1.0 / SQRT_2), id="Normal to (1, 0)" + "segment_of_y_equals_x", + (1.0, 0.0), + (1.0 / SQRT_2, -1.0 / SQRT_2), + id="Normal pointing to half-plane with point (1, 0)", ), pytest.param( - (1.0, 2.0), (-1.0 / SQRT_2, 1.0 / SQRT_2), id="Normal to (1, 2)" + LineOfInterest([(0.5, 0.5), (1.0, 1.0)]), + (1.0, 0.0), + (1.0 / SQRT_2, -1.0 / SQRT_2), + id="Segment does not start at origin", + ), + pytest.param( + "segment_of_y_equals_x", + (1.0, 2.0), + (-1.0 / SQRT_2, 1.0 / SQRT_2), + id="Necessary to extend segment to compute normal.", ), ], ) def test_normal( - segment_of_y_equals_x: LineOfInterest, + segment: LineOfInterest, point: ArrayLike, expected_normal: np.ndarray, + request, ) -> None: - computed_normal = segment_of_y_equals_x.normal(point) + if isinstance(segment, str): + segment = request.getfixturevalue(segment) + computed_normal = segment.normal(point) assert np.allclose(computed_normal, expected_normal) From aca4c5f7a1ae7571d732d050b5502f9e836edaec Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Fri, 21 Feb 2025 10:31:29 +0000 Subject: [PATCH 23/39] Tidy up factory fixture --- tests/test_unit/conftest.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/tests/test_unit/conftest.py b/tests/test_unit/conftest.py index abdae1517..f1cabffb9 100644 --- a/tests/test_unit/conftest.py +++ b/tests/test_unit/conftest.py @@ -9,31 +9,28 @@ def push_into_range() -> Callable[ [xr.DataArray | np.ndarray, float, float], xr.DataArray | np.ndarray ]: - """Translate values into the range (lower, upper]. + """Return a function for wrapping angles. - The interval width is the value ``upper - lower``. - Each ``v`` in ``values`` that starts less than or equal to the - ``lower`` bound has multiples of the interval width added to it, - until the result lies in the desirable interval. - - Each ``v`` in ``values`` that starts greater than the ``upper`` - bound has multiples of the interval width subtracted from it, - until the result lies in the desired interval. + This is a factory fixture that returns a method for wrapping angles + into a user-specified range. """ - def push_into_range_inner( + def _push_into_range( numeric_values: xr.DataArray | np.ndarray, lower: float = -180.0, upper: float = 180.0, ) -> xr.DataArray | np.ndarray: - """Translate values into the range (lower, upper]. + """Coerce values into the range (lower, upper]. + + Primarily used to wrap returned angles into a particular range, + such as (-pi, pi]. The interval width is the value ``upper - lower``. - Each ``v`` in ``values`` that starts less than or equal to the + Each element in ``values`` that starts less than or equal to the ``lower`` bound has multiples of the interval width added to it, until the result lies in the desirable interval. - Each ``v`` in ``values`` that starts greater than the ``upper`` + Each element in ``values`` that starts greater than the ``upper`` bound has multiples of the interval width subtracted from it, until the result lies in the desired interval. """ @@ -62,4 +59,4 @@ def push_into_range_inner( ) return translated_values - return push_into_range_inner + return _push_into_range From 96f5e16fe76ff9f927ad728c074ed793d9df180b Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Fri, 21 Feb 2025 11:03:11 +0000 Subject: [PATCH 24/39] Remove the choice of approach vector direction --- movement/roi/base.py | 28 ++----------------- movement/roi/line.py | 17 +++-------- tests/test_unit/test_roi/test_angles.py | 1 - .../test_unit/test_roi/test_nearest_points.py | 13 --------- 4 files changed, 6 insertions(+), 53 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index cb099b4d6..d47352d13 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -22,12 +22,6 @@ RegionLike: TypeAlias = shapely.Polygon SupportedGeometry: TypeAlias = LineLike | RegionLike -TowardsRegion = "point to region" -AwayFromRegion = "region to point" -ApproachVectorDirections: TypeAlias = Literal[ # type: ignore[valid-type] - f"{TowardsRegion}", f"{AwayFromRegion}" -] - class BaseRegionOfInterest: """Base class for regions of interest (RoIs). @@ -351,7 +345,6 @@ def compute_approach_vector( self, point: ArrayLike, boundary: bool = False, - direction: ApproachVectorDirections = TowardsRegion, unit: bool = True, ) -> np.ndarray: """Compute the approach vector from a ``point`` to the region. @@ -370,9 +363,6 @@ def compute_approach_vector( If True, finds the vector to the nearest point on the boundary of the region, instead of the nearest point within the region. (See Notes). Default is False. - direction : ApproachVectorDirections - Which direction the returned vector should point in. Default is - "point to region". unit : bool If ``True``, the unit vector in the appropriate direction is returned, otherwise the approach vector is returned un-normalised. @@ -401,8 +391,6 @@ def compute_approach_vector( displacement_vector = np.array(directed_line.coords[1]) - np.array( directed_line.coords[0] ) - if direction == "region to point": - displacement_vector *= -1.0 if unit: norm = np.sqrt(np.sum(displacement_vector**2)) # Cannot normalise the 0 vector @@ -417,7 +405,6 @@ def compute_allocentric_angle( angle_rotates: Literal[ "approach to ref", "ref to approach" ] = "approach to ref", - approach_direction: ApproachVectorDirections = TowardsRegion, boundary: bool = False, in_radians: bool = False, reference_vector: np.ndarray | xr.DataArray = None, @@ -433,8 +420,7 @@ def compute_allocentric_angle( The approach vector is the vector from ``position_keypoints`` to the closest point within the region (or the closest point on the boundary of the region if ``boundary`` is set to ``True``), as determined by - :func:`compute_approach_vector`. ``approach_direction`` can be used to - reverse the direction convention of the approach vector, if desired. + :func:`compute_approach_vector`. Parameters ---------- @@ -448,9 +434,6 @@ def compute_allocentric_angle( angle_rotates : Literal["approach to ref", "ref to approach"] Direction of the signed angle returned. Default is ``"approach to ref"``. - approach_direction : ApproachVectorDirections - Direction to use for the vector of closest approach. Default - ``"point to region"``. boundary : bool Passed to ``compute_approach_vector`` (see Notes). Default ``False``. @@ -498,7 +481,6 @@ def compute_allocentric_angle( data, position_keypoint=position_keypoint, boundary=boundary, - direction=approach_direction, unit=False, ) @@ -520,7 +502,6 @@ def compute_egocentric_angle( angle_rotates: Literal[ "approach to forward", "forward to approach" ] = "approach to forward", - approach_direction: ApproachVectorDirections = TowardsRegion, boundary: bool = False, camera_view: Literal["top_down", "bottom_up"] = "top_down", in_radians: bool = False, @@ -540,8 +521,7 @@ def compute_egocentric_angle( The approach vector is the vector from ``position_keypoints`` to the closest point within the region (or the closest point on the boundary of the region if ``boundary`` is set to ``True``), as determined by - :func:`compute_approach_vector`. ``approach_direction`` can be used to - reverse the direction convention of the approach vector, if desired. + :func:`compute_approach_vector`. Parameters ---------- @@ -557,9 +537,6 @@ def compute_egocentric_angle( angle_rotates : Literal["approach to forward", "forward to approach"] Direction of the signed angle returned. Default is ``"approach to forward"``. - approach_direction : ApproachVectorDirections - Direction to use for the vector of closest approach. Default - ``"point to region"``. boundary : bool Passed to ``compute_approach_vector`` (see Notes). Default ``False``. @@ -611,7 +588,6 @@ def compute_egocentric_angle( data, position_keypoint=position_keypoint, boundary=boundary, - direction=approach_direction, unit=False, ) diff --git a/movement/roi/line.py b/movement/roi/line.py index 547410175..0028980ad 100644 --- a/movement/roi/line.py +++ b/movement/roi/line.py @@ -9,10 +9,8 @@ from movement.kinematics import compute_forward_vector_angle from movement.roi.base import ( - ApproachVectorDirections, BaseRegionOfInterest, PointLikeList, - TowardsRegion, ) from movement.utils.broadcasting import broadcastable_method @@ -115,15 +113,14 @@ def compute_angle_to_support_plane_of_segment( ] = "normal to forward", camera_view: Literal["top_down", "bottom_up"] = "top_down", in_radians: bool = False, - normal_direction: ApproachVectorDirections = TowardsRegion, position_keypoint: Hashable | Sequence[Hashable] | None = None, ) -> xr.DataArray: """Compute the signed angle between the normal and a forward vector. This method is identical to ``compute_egocentric_angle``, except that rather than the angle between the approach vector and a forward vector, - the angle between the normal to the segment and the approach vector is - returned. + the angle between the normal directed toward the segment and the + forward vector is returned. For finite segments, the normal to the infinite extension of the segment is used in the calculation. @@ -148,9 +145,6 @@ def compute_angle_to_support_plane_of_segment( in_radians : bool If ``True``, angles are returned in radians. Otherwise angles are returned in degrees. Default ``False``. - normal_direction : ApproachVectorDirections - Direction to use for the normal vector. Default is - ``"point to region"``. position_keypoint : Hashable | Sequence[Hashable], optional The keypoint defining the origin of the approach vector. If provided as a sequence, the average of all provided keypoints is @@ -163,16 +157,13 @@ def compute_angle_to_support_plane_of_segment( if position_keypoint is None: position_keypoint = [left_keypoint, right_keypoint] - normal = self._vector_from_centroid_of_keypoints( + # Normal from position to segment is the reverse of what normal returns + normal = -1.0 * self._vector_from_centroid_of_keypoints( data, position_keypoint=position_keypoint, renamed_dimension="normal", which_method="normal", ) - # The normal from the point to the region is the same as the normal - # that points into the opposite side of the segment. - if normal_direction == TowardsRegion: - normal *= -1.0 # Translate the more explicit convention used here into the convention # used by our backend functions. diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py index e5974f760..09167526d 100644 --- a/tests/test_unit/test_roi/test_angles.py +++ b/tests/test_unit/test_roi/test_angles.py @@ -258,7 +258,6 @@ def test_ego_and_allocentric_angle_to_region( Specifically; - - ``approach_direction``, - ``camera_view``, - ``in_radians``, diff --git a/tests/test_unit/test_roi/test_nearest_points.py b/tests/test_unit/test_roi/test_nearest_points.py index 4466bb92e..549806972 100644 --- a/tests/test_unit/test_roi/test_nearest_points.py +++ b/tests/test_unit/test_roi/test_nearest_points.py @@ -362,16 +362,3 @@ def test_approach_vector( else: vector_to = region.compute_approach_vector(point, **other_fn_args) assert np.allclose(vector_to, expected_output) - - # Check symmetry when reversing vector direction - if ( - other_fn_args.get("direction", "point to region") - == "point to region" - ): - other_fn_args["direction"] = "region to point" - else: - other_fn_args["direction"] = "point to region" - vector_to_reverse = region.compute_approach_vector( - point, **other_fn_args - ) - assert np.allclose(-vector_to, vector_to_reverse) From db57f2efd6219f49ce7667d439976f39eb4d5f87 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Fri, 21 Feb 2025 13:36:56 +0000 Subject: [PATCH 25/39] Approach vector is by default not normalised --- movement/roi/base.py | 9 +++------ tests/test_unit/test_roi/test_nearest_points.py | 10 +++++----- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index d47352d13..13423a149 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -345,14 +345,12 @@ def compute_approach_vector( self, point: ArrayLike, boundary: bool = False, - unit: bool = True, + unit: bool = False, ) -> np.ndarray: """Compute the approach vector from a ``point`` to the region. The approach vector is defined as the vector directed from the ``point`` provided, to the closest point that belongs to the region. - If ``point`` is within the region, the zero vector is returned (see - Notes). Parameters ---------- @@ -364,9 +362,8 @@ def compute_approach_vector( the region, instead of the nearest point within the region. (See Notes). Default is False. unit : bool - If ``True``, the unit vector in the appropriate direction is - returned, otherwise the approach vector is returned un-normalised. - Default is ``True``. + If ``True``, the approach vector is returned normalised, otherwise + it is not normalised. Default is ``True``. Returns ------- diff --git a/tests/test_unit/test_roi/test_nearest_points.py b/tests/test_unit/test_roi/test_nearest_points.py index 549806972..7ae63b235 100644 --- a/tests/test_unit/test_roi/test_nearest_points.py +++ b/tests/test_unit/test_roi/test_nearest_points.py @@ -303,14 +303,14 @@ def test_nearest_point_to_tie_breaks( pytest.param( "unit_square", (-0.5, 0.0), - {}, + {"unit": True}, np.array([1.0, 0.0]), id="(-0.5, 0.0) -> unit square", ), pytest.param( LineOfInterest([(0.0, 0.0), (1.0, 0.0)]), (0.1, 0.5), - {}, + {"unit": True}, np.array([0.0, -1.0]), id="(0.1, 0.5) -> +ve x ray", ), @@ -324,21 +324,21 @@ def test_nearest_point_to_tie_breaks( pytest.param( "unit_square", (0.5, 0.5), - {}, + {"unit": True}, np.array([0.0, 0.0]), id="Interior point returns 0 vector", ), pytest.param( "unit_square", (0.25, 0.35), - {"boundary": True}, + {"boundary": True, "unit": True}, np.array([-1.0, 0.0]), id="Boundary, polygon", ), pytest.param( LineOfInterest([(0.0, 0.0), (1.0, 0.0)]), (0.1, 0.5), - {"boundary": True}, + {"boundary": True, "unit": True}, np.array([-0.1, -0.5]) / np.sqrt(0.1**2 + 0.5**2), id="Boundary, line", ), From de7768d93650d14cbc3ad66c09ea4e0cf1e663b4 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Fri, 21 Feb 2025 13:56:45 +0000 Subject: [PATCH 26/39] Standardise treatment of the boundary, and the boundary keyword argument --- movement/roi/base.py | 119 ++++++------------ tests/test_unit/test_roi/test_angles.py | 4 +- .../test_unit/test_roi/test_nearest_points.py | 18 +-- 3 files changed, 52 insertions(+), 89 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index 13423a149..b9ba3b3c9 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -39,6 +39,23 @@ class BaseRegionOfInterest: Although this class can be instantiated directly, it is not designed for this. Its primary purpose is to reduce code duplication. + + Notes + ----- + A region of interest includes the points that make up its boundary and the + points contained in its interior. This convention means that points inside + the region will be treated as having zero distance to the region, and the + approach vector from these points to the region will be the null vector. + + This may be undesirable in certain situations, when we explicitly want to + find the distance of a point to the boundary of a region, for example. To + accommodate this, most methods of this class accept a keyword argument that + forces the method to perform all computations using only the boundary of + the region, rather than the region itself. For polygons, this will force + the method in question to only consider distances or closest points on the + segments that form the (interior and exterior) boundaries. For segments, + the boundary is considered to be just the two endpoints of the segment. + """ __default_name: str = "Un-named region" @@ -256,7 +273,7 @@ def contains_point( @broadcastable_method(only_broadcastable_along="space") def compute_distance_to( - self, point: ArrayLike, boundary: bool = False + self, point: ArrayLike, boundary_only: bool = False ) -> float: """Compute the distance from the region to a point. @@ -265,10 +282,10 @@ def compute_distance_to( point : ArrayLike Coordinates of a point, from which to find the nearest point in the region defined by ``self``. - boundary : bool, optional - If True, compute the distance from ``point`` to the boundary of - the region. Otherwise, the distance returned may be 0 for interior - points (see Notes). + boundary_only : bool, optional + If ``True``, compute the distance from ``point`` to the boundary of + the region, rather than the closest point belonging to the region. + Default ``False``. Returns ------- @@ -276,38 +293,28 @@ def compute_distance_to( Euclidean distance from the ``point`` to the (closest point on the) region. - Notes - ----- - A point within the interior of a region is considered to be a distance - 0 from the region. This is desirable for 1-dimensional regions, but may - not be desirable for 2D regions. As such, passing the ``boundary`` - argument as ``True`` makes the method compute the distance from the - ``point`` to the boundary of the region, even if ``point`` is in the - interior of the region. - See Also -------- shapely.distance : Underlying used to compute the nearest point. """ - from_where = self.region.boundary if boundary else self.region + from_where = self.region.boundary if boundary_only else self.region return shapely.distance(from_where, shapely.Point(point)) @broadcastable_method( only_broadcastable_along="space", new_dimension_name="nearest point" ) def compute_nearest_point_to( - self, /, position: ArrayLike, boundary: bool = False + self, /, position: ArrayLike, boundary_only: bool = False ) -> np.ndarray: - """Compute the nearest point in the region to the ``position``. + """Compute a nearest point in the region to the ``position``. position : ArrayLike Coordinates of a point, from which to find the nearest point in the region defined by ``self``. - boundary : bool, optional - If True, compute the nearest point to ``position`` that is on the - boundary of ``self``. Otherwise, the nearest point returned may be - inside ``self`` (see Notes). + boundary_only : bool, optional + If ``True``, compute the nearest point to ``position`` that is on + the boundary of ``self``. Default ``False``. Returns ------- @@ -315,21 +322,12 @@ def compute_nearest_point_to( Coordinates of the point on ``self`` that is closest to ``position``. - Notes - ----- - This function computes the nearest point to ``position`` in the region - defined by ``self``. This means that, given a ``position`` inside the - region, ``position`` itself will be returned. To find the nearest point - to ``position`` on the boundary of a region, pass the ``boundary` - argument as ``True`` to this method. Take care though - the boundary of - a line is considered to be just its endpoints. - See Also -------- shapely.shortest_line : Underlying used to compute the nearest point. """ - from_where = self.region.boundary if boundary else self.region + from_where = self.region.boundary if boundary_only else self.region # shortest_line returns a line from 1st arg to 2nd arg, # therefore the point on self is the 0th coordinate return np.array( @@ -344,7 +342,7 @@ def compute_nearest_point_to( def compute_approach_vector( self, point: ArrayLike, - boundary: bool = False, + boundary_only: bool = False, unit: bool = False, ) -> np.ndarray: """Compute the approach vector from a ``point`` to the region. @@ -357,10 +355,9 @@ def compute_approach_vector( point : ArrayLike Coordinates of a point to compute the vector to (or from) the region. - boundary : bool - If True, finds the vector to the nearest point on the boundary of - the region, instead of the nearest point within the region. - (See Notes). Default is False. + boundary_only : bool + If ``True``, the approach vector to the boundary of the region is + computed. Default ``False``. unit : bool If ``True``, the approach vector is returned normalised, otherwise it is not normalised. Default is ``True``. @@ -370,17 +367,8 @@ def compute_approach_vector( np.ndarray Vector directed between the point and the region. - Notes - ----- - If given a ``point`` in the interior of the region, the vector from - this ``point`` to the region is treated as the zero vector. The - ``boundary`` argument can be used to force the method to find the - distance from the ``point`` to the nearest point on the boundary of the - region, if so desired. Note that a ``point`` on the boundary still - returns the zero vector. - """ - from_where = self.region.boundary if boundary else self.region + from_where = self.region.boundary if boundary_only else self.region # "point to region" by virtue of order of arguments to shapely call directed_line = shapely.shortest_line(shapely.Point(point), from_where) @@ -402,7 +390,7 @@ def compute_allocentric_angle( angle_rotates: Literal[ "approach to ref", "ref to approach" ] = "approach to ref", - boundary: bool = False, + boundary_only: bool = False, in_radians: bool = False, reference_vector: np.ndarray | xr.DataArray = None, ) -> float: @@ -431,9 +419,8 @@ def compute_allocentric_angle( angle_rotates : Literal["approach to ref", "ref to approach"] Direction of the signed angle returned. Default is ``"approach to ref"``. - boundary : bool - Passed to ``compute_approach_vector`` (see Notes). Default - ``False``. + boundary_only : bool + Passed to ``compute_approach_vector``. Default ``False``. in_radians : bool If ``True``, angles are returned in radians. Otherwise angles are returned in degrees. Default ``False``. @@ -442,18 +429,6 @@ def compute_allocentric_angle( the argument of the same name that is passed to :func:`compute_signed_angle_2d`. Default ``(1., 0.)``. - Notes - ----- - For a point ``p`` within (the interior of a) region of interest, the - approach vector to the region is the zero vector, since the closest - point to ``p`` inside the region is ``p`` itself. In this case, `nan` - is returned as the egocentric angle. This behaviour may be - undesirable for 2D polygonal regions (though is usually desirable for - 1D line-like regions). Passing the ``boundary`` argument causes this - method to calculate the approach vector to the closest point on the - boundary of this region, so ``p`` will not be considered the "closest - point" to itself (unless of course, it is a point on the boundary). - See Also -------- ``compute_signed_angle_2d`` : The underlying function used to compute @@ -477,7 +452,7 @@ def compute_allocentric_angle( approach_vector = self._vector_from_centroid_of_keypoints( data, position_keypoint=position_keypoint, - boundary=boundary, + boundary_only=boundary_only, unit=False, ) @@ -499,7 +474,7 @@ def compute_egocentric_angle( angle_rotates: Literal[ "approach to forward", "forward to approach" ] = "approach to forward", - boundary: bool = False, + boundary_only: bool = False, camera_view: Literal["top_down", "bottom_up"] = "top_down", in_radians: bool = False, position_keypoint: Hashable | Sequence[Hashable] | None = None, @@ -534,7 +509,7 @@ def compute_egocentric_angle( angle_rotates : Literal["approach to forward", "forward to approach"] Direction of the signed angle returned. Default is ``"approach to forward"``. - boundary : bool + boundary_only : bool Passed to ``compute_approach_vector`` (see Notes). Default ``False``. camera_view : Literal["top_down", "bottom_up"] @@ -549,18 +524,6 @@ def compute_egocentric_angle( used. By default, the centroid of ``left_keypoint`` and ``right_keypoint`` is used. - Notes - ----- - For a point ``p`` within (the interior of a) region of interest, the - approach vector to the region is the zero vector, since the closest - point to ``p`` inside the region is ``p`` itself. In this case, `nan` - is returned as the egocentric angle. This behaviour may be - undesirable for 2D polygonal regions (though is usually desirable for - 1D line-like regions). Passing the ``boundary`` argument causes this - method to calculate the approach vector to the closest point on the - boundary of this region, so ``p`` will not be considered the "closest - point" to itself (unless of course, it is a point on the boundary). - See Also -------- ``compute_forward_vector_angle`` : The underlying function used @@ -584,7 +547,7 @@ def compute_egocentric_angle( approach_vector = self._vector_from_centroid_of_keypoints( data, position_keypoint=position_keypoint, - boundary=boundary, + boundary_only=boundary_only, unit=False, ) diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py index 09167526d..9fbc82bff 100644 --- a/tests/test_unit/test_roi/test_angles.py +++ b/tests/test_unit/test_roi/test_angles.py @@ -168,7 +168,7 @@ def sample_position_array() -> xr.DataArray: { "left_keypoint": "left", "right_keypoint": "right", - "boundary": True, + "boundary_only": True, }, np.array( [ @@ -223,7 +223,7 @@ def sample_position_array() -> xr.DataArray: "sample_position_array", { "position_keypoint": "midpt", - "boundary": True, + "boundary_only": True, }, np.array( [ diff --git a/tests/test_unit/test_roi/test_nearest_points.py b/tests/test_unit/test_roi/test_nearest_points.py index 7ae63b235..8b4798d5a 100644 --- a/tests/test_unit/test_roi/test_nearest_points.py +++ b/tests/test_unit/test_roi/test_nearest_points.py @@ -38,7 +38,7 @@ def unit_line_in_x() -> LineOfInterest: [ pytest.param( "unit_square_with_hole", - {"boundary": True}, + {"boundary_only": True}, np.array( [ 0.5, @@ -68,7 +68,7 @@ def unit_line_in_x() -> LineOfInterest: ), pytest.param( "unit_line_in_x", - {"boundary": True}, + {"boundary_only": True}, np.array( [ 0.5 * np.sqrt(2.0), @@ -110,7 +110,7 @@ def test_distance_point_to_region( [ pytest.param( "unit_square", - {"boundary": True}, + {"boundary_only": True}, np.array( [ [0.00, 0.50], @@ -142,7 +142,7 @@ def test_distance_point_to_region( ), pytest.param( "unit_square_with_hole", - {"boundary": True}, + {"boundary_only": True}, np.array( [ [0.00, 0.50], @@ -190,7 +190,7 @@ def test_distance_point_to_region( ), pytest.param( "unit_line_in_x", - {"boundary": True}, + {"boundary_only": True}, np.array( [ [0.00, 0.00], @@ -236,7 +236,7 @@ def test_nearest_point_to( pytest.param( "unit_square", [0.5, 0.5], - {"boundary": True}, + {"boundary_only": True}, [ np.array([0.0, 0.5]), np.array([0.5, 0.0]), @@ -248,7 +248,7 @@ def test_nearest_point_to( pytest.param( "unit_line_in_x", [0.5, 0.0], - {"boundary": True}, + {"boundary_only": True}, [ np.array([0.0, 0.0]), np.array([1.0, 0.0]), @@ -331,14 +331,14 @@ def test_nearest_point_to_tie_breaks( pytest.param( "unit_square", (0.25, 0.35), - {"boundary": True, "unit": True}, + {"boundary_only": True, "unit": True}, np.array([-1.0, 0.0]), id="Boundary, polygon", ), pytest.param( LineOfInterest([(0.0, 0.0), (1.0, 0.0)]), (0.1, 0.5), - {"boundary": True, "unit": True}, + {"boundary_only": True, "unit": True}, np.array([-0.1, -0.5]) / np.sqrt(0.1**2 + 0.5**2), id="Boundary, line", ), From cc20ba3f18675450bf6841be36d6b80157285d30 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Fri, 21 Feb 2025 14:07:05 +0000 Subject: [PATCH 27/39] Variable name review for code readability --- movement/roi/base.py | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index b9ba3b3c9..aedfcc958 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -298,8 +298,10 @@ def compute_distance_to( shapely.distance : Underlying used to compute the nearest point. """ - from_where = self.region.boundary if boundary_only else self.region - return shapely.distance(from_where, shapely.Point(point)) + region_to_consider = ( + self.region.boundary if boundary_only else self.region + ) + return shapely.distance(region_to_consider, shapely.Point(point)) @broadcastable_method( only_broadcastable_along="space", new_dimension_name="nearest point" @@ -307,11 +309,11 @@ def compute_distance_to( def compute_nearest_point_to( self, /, position: ArrayLike, boundary_only: bool = False ) -> np.ndarray: - """Compute a nearest point in the region to the ``position``. + """Compute (one of) the nearest point(s) in the region to ``position``. position : ArrayLike Coordinates of a point, from which to find the nearest point in the - region defined by ``self``. + region. boundary_only : bool, optional If ``True``, compute the nearest point to ``position`` that is on the boundary of ``self``. Default ``False``. @@ -327,13 +329,15 @@ def compute_nearest_point_to( shapely.shortest_line : Underlying used to compute the nearest point. """ - from_where = self.region.boundary if boundary_only else self.region + region_to_consider = ( + self.region.boundary if boundary_only else self.region + ) # shortest_line returns a line from 1st arg to 2nd arg, # therefore the point on self is the 0th coordinate return np.array( - shapely.shortest_line(from_where, shapely.Point(position)).coords[ - 0 - ] + shapely.shortest_line( + region_to_consider, shapely.Point(position) + ).coords[0] ) @broadcastable_method( @@ -360,28 +364,32 @@ def compute_approach_vector( computed. Default ``False``. unit : bool If ``True``, the approach vector is returned normalised, otherwise - it is not normalised. Default is ``True``. + it is not normalised. Default is ``False``. Returns ------- np.ndarray - Vector directed between the point and the region. + Approach vector from the point to the region. """ - from_where = self.region.boundary if boundary_only else self.region + region_to_consider = ( + self.region.boundary if boundary_only else self.region + ) # "point to region" by virtue of order of arguments to shapely call - directed_line = shapely.shortest_line(shapely.Point(point), from_where) + directed_line = shapely.shortest_line( + shapely.Point(point), region_to_consider + ) - displacement_vector = np.array(directed_line.coords[1]) - np.array( + approach_vector = np.array(directed_line.coords[1]) - np.array( directed_line.coords[0] ) if unit: - norm = np.sqrt(np.sum(displacement_vector**2)) + norm = np.sqrt(np.sum(approach_vector**2)) # Cannot normalise the 0 vector if not np.isclose(norm, 0.0): - displacement_vector /= norm - return displacement_vector + approach_vector /= norm + return approach_vector def compute_allocentric_angle( self, From 591f2c5c2d8b8c4192c8ce6487a95ed813f91e80 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Fri, 21 Feb 2025 14:46:10 +0000 Subject: [PATCH 28/39] Rework allocentric computation, following code recommendations --- movement/roi/base.py | 69 ++++++---- tests/test_unit/test_roi/test_angles.py | 168 ++++++++++++++---------- 2 files changed, 135 insertions(+), 102 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index aedfcc958..4df3ebe55 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -107,6 +107,21 @@ def region(self) -> SupportedGeometry: """``shapely.Geometry`` representation of the region.""" return self._shapely_geometry + @staticmethod + def _reassign_space_dim( + da: xr.DataArray, + old_dimension: Hashable, + new_dimension: Hashable = "space", + ) -> xr.DataArray: + """""" + return da.rename({old_dimension: new_dimension}).assign_coords( + { + "space": ["x", "y"] + if len(da[old_dimension]) == 2 + else ["x", "y", "z"] + } + ) + def __init__( self, points: PointLikeList, @@ -391,15 +406,14 @@ def compute_approach_vector( approach_vector /= norm return approach_vector - def compute_allocentric_angle( + def compute_allocentric_angle_to_nearest_point( self, - data: xr.DataArray, - position_keypoint: Hashable | Sequence[Hashable], + position: xr.DataArray, angle_rotates: Literal[ "approach to ref", "ref to approach" ] = "approach to ref", boundary_only: bool = False, - in_radians: bool = False, + in_degrees: bool = False, reference_vector: np.ndarray | xr.DataArray = None, ) -> float: """Compute the allocentric angle to the region. @@ -417,20 +431,16 @@ def compute_allocentric_angle( Parameters ---------- - data : xarray.DataArray - `DataArray` of positions that has at least 3 dimensions; "time", - "space", and ``keypoints_dimension``. - position_keypoint : Hashable | Sequence[Hashable] - The keypoint defining the origin of the approach vector. If - provided as a sequence, the average of all provided keypoints is - used. + position : xarray.DataArray + ``DataArray`` of spatial positions. angle_rotates : Literal["approach to ref", "ref to approach"] Direction of the signed angle returned. Default is ``"approach to ref"``. boundary_only : bool - Passed to ``compute_approach_vector``. Default ``False``. - in_radians : bool - If ``True``, angles are returned in radians. Otherwise angles are + If ``True``, the allocentric angle to the closest boundary point of + the region is computed. Default ``False``. + in_degrees : bool + If ``True``, angles are returned in degrees. Otherwise angles are returned in degrees. Default ``False``. reference_vector : ArrayLike | xr.DataArray The reference vector to be used. Dimensions must be compatible with @@ -440,9 +450,10 @@ def compute_allocentric_angle( See Also -------- ``compute_signed_angle_2d`` : The underlying function used to compute - the signed angle between the approach vector and the reference vector. - ``compute_egocentric_angle`` : Related class method for computing the - egocentric angle to the region. + the signed angle between the approach vector and the reference + vector. + ``compute_egocentric_angle_to_nearest_point`` : Related class method + for computing the egocentric angle to the region. """ if reference_vector is None: @@ -457,11 +468,11 @@ def compute_allocentric_angle( raise ValueError(f"Unknown angle convention: {angle_rotates}") # Determine the approach vector, for all time-points. - approach_vector = self._vector_from_centroid_of_keypoints( - data, - position_keypoint=position_keypoint, - boundary_only=boundary_only, - unit=False, + approach_vector = self.compute_approach_vector( + position, boundary_only=boundary_only + ) + approach_vector = self._reassign_space_dim( + approach_vector, "vector to" ) # Then, compute signed angles at all time-points @@ -470,8 +481,8 @@ def compute_allocentric_angle( reference_vector, v_as_left_operand=ref_as_left_operand, ) - if not in_radians: - angles *= 180.0 / np.pi + if in_degrees: + angles = np.rad2deg(angles) return angles def compute_egocentric_angle( @@ -484,7 +495,7 @@ def compute_egocentric_angle( ] = "approach to forward", boundary_only: bool = False, camera_view: Literal["top_down", "bottom_up"] = "top_down", - in_radians: bool = False, + in_degrees: bool = False, position_keypoint: Hashable | Sequence[Hashable] | None = None, ) -> xr.DataArray: """Compute the egocentric angle to the region. @@ -523,9 +534,9 @@ def compute_egocentric_angle( camera_view : Literal["top_down", "bottom_up"] Passed to func:`compute_forward_vector_angle`. Default ``"top_down"``. - in_radians : bool - If ``True``, angles are returned in radians. Otherwise angles are - returned in degrees. Default ``False``. + in_degrees : bool + If ``True``, angles are returned in degrees. Otherwise angles are + returned in radians. Default ``False``. position_keypoint : Hashable | Sequence[Hashable], optional The keypoint defining the origin of the approach vector. If provided as a sequence, the average of all provided keypoints is @@ -566,6 +577,6 @@ def compute_egocentric_angle( right_keypoint=right_keypoint, reference_vector=approach_vector, camera_view=camera_view, - in_radians=in_radians, + in_radians=not in_degrees, angle_rotates=rotation_angle, ) diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py index 9fbc82bff..3988c5b58 100644 --- a/tests/test_unit/test_roi/test_angles.py +++ b/tests/test_unit/test_roi/test_angles.py @@ -1,5 +1,6 @@ import re -from typing import Any, Literal +from collections.abc import Iterable +from typing import Any import numpy as np import pytest @@ -9,7 +10,6 @@ from movement.roi.base import BaseRegionOfInterest -@pytest.fixture() def sample_position_array() -> xr.DataArray: """Return a simulated position array to test the egocentric angle. @@ -79,37 +79,37 @@ def sample_position_array() -> xr.DataArray: @pytest.mark.parametrize( - ["region", "data", "fn_args", "expected_output", "which_method"], + ["region", "fn_args", "fn_kwargs", "expected_output", "egocentric"], [ pytest.param( "unit_square_with_hole", - "sample_position_array", + [sample_position_array()], { "left_keypoint": "left", "right_keypoint": "right", "angle_rotates": "elephant to region", }, ValueError("Unknown angle convention: elephant to region"), - "compute_egocentric_angle", + True, id="[E] Unknown angle convention", ), pytest.param( "unit_square_with_hole", - "sample_position_array", + [sample_position_array()], { - "position_keypoint": "midpt", "angle_rotates": "elephant to region", }, ValueError("Unknown angle convention: elephant to region"), - "compute_allocentric_angle", + False, id="[A] Unknown angle convention", ), pytest.param( "unit_square_with_hole", - "sample_position_array", + [sample_position_array()], { "left_keypoint": "left", "right_keypoint": "right", + "in_degrees": True, }, np.array( [ @@ -120,16 +120,17 @@ def sample_position_array() -> xr.DataArray: np.rad2deg(np.arccos(2.0 / np.sqrt(5.0))), ] ), - "compute_egocentric_angle", + True, id="[E] Default args", ), pytest.param( "unit_square_with_hole", - "sample_position_array", + [sample_position_array()], { "left_keypoint": "left", "right_keypoint": "right", "position_keypoint": "wild", + "in_degrees": True, }, np.array( [ @@ -140,15 +141,16 @@ def sample_position_array() -> xr.DataArray: np.rad2deg(np.pi / 2.0 + np.arcsin(1.0 / np.sqrt(5.0))), ] ), - "compute_egocentric_angle", + True, id="[E] Non-default position", ), pytest.param( "unit_square", - "sample_position_array", + [sample_position_array()], { "left_keypoint": "left", "right_keypoint": "right", + "in_degrees": True, }, np.array( [ @@ -159,16 +161,17 @@ def sample_position_array() -> xr.DataArray: float("nan"), ] ), - "compute_egocentric_angle", + True, id="[E] 0-approach vectors (nan returns)", ), pytest.param( "unit_square", - "sample_position_array", + [sample_position_array()], { "left_keypoint": "left", "right_keypoint": "right", "boundary_only": True, + "in_degrees": True, }, np.array( [ @@ -179,62 +182,73 @@ def sample_position_array() -> xr.DataArray: np.rad2deg(np.arccos(2.0 / np.sqrt(5.0))), ] ), - "compute_egocentric_angle", + True, id="[E] Force boundary calculations", ), pytest.param( "unit_square_with_hole", - "sample_position_array", - { - "position_keypoint": "midpt", - }, - np.array( - [ - -135.0, - 0.0, - 90.0, - -135.0, - 180.0, - ] + [ + sample_position_array() + .sel(keypoints="midpt") + .drop_vars("keypoints") + ], + {}, + np.deg2rad( + np.array( + [ + -135.0, + 0.0, + 90.0, + -135.0, + 180.0, + ] + ) ), - "compute_allocentric_angle", + False, id="[A] Default args", ), pytest.param( "unit_square", - "sample_position_array", - { - "position_keypoint": "midpt", - }, - np.array( - [ - -135.0, - 0.0, - float("nan"), - -135.0, - float("nan"), - ] + [ + sample_position_array() + .sel(keypoints="midpt") + .drop_vars("keypoints") + ], + {}, + np.deg2rad( + np.array( + [ + -135.0, + 0.0, + float("nan"), + -135.0, + float("nan"), + ] + ) ), - "compute_allocentric_angle", + False, id="[A] 0-approach vectors", ), pytest.param( "unit_square", - "sample_position_array", - { - "position_keypoint": "midpt", - "boundary_only": True, - }, - np.array( - [ - -135.0, - 0.0, - 90.0, - -135.0, - 180.0, - ] + [ + sample_position_array() + .sel(keypoints="midpt") + .drop_vars("keypoints") + ], + {"boundary_only": True}, + np.deg2rad( + np.array( + [ + -135.0, + 0.0, + 90.0, + -135.0, + 180.0, + ] + ) ), - "compute_allocentric_angle", + False, id="[A] Force boundary calculation", ), ], @@ -242,12 +256,10 @@ def sample_position_array() -> xr.DataArray: def test_ego_and_allocentric_angle_to_region( push_into_range, region: BaseRegionOfInterest, - data: xr.DataArray, - fn_args: dict[str, Any], + fn_args: Iterable[Any], + fn_kwargs: dict[str, Any], expected_output: xr.DataArray | Exception, - which_method: Literal[ - "compute_allocentric_angle", "compute_egocentric_angle" - ], + egocentric: bool, request, ) -> None: """Test computation of the egocentric and allocentric angle. @@ -266,37 +278,47 @@ def test_ego_and_allocentric_angle_to_region( """ if isinstance(region, str): region = request.getfixturevalue(region) - if isinstance(data, str): - data = request.getfixturevalue(data) if isinstance(expected_output, np.ndarray): expected_output = xr.DataArray(data=expected_output, dims=["time"]) - method = getattr(region, which_method) - if which_method == "compute_egocentric_angle": + if egocentric: + which_method = "compute_egocentric_angle" other_vector_name = "forward" - elif which_method == "compute_allocentric_angle": + else: + which_method = "compute_allocentric_angle_to_nearest_point" other_vector_name = "ref" + method = getattr(region, which_method) if isinstance(expected_output, Exception): with pytest.raises( type(expected_output), match=re.escape(str(expected_output)) ): - method(data, **fn_args) + method(*fn_args, **fn_kwargs) else: - angles = method(data, **fn_args) + angles = method(*fn_args, **fn_kwargs) xr.testing.assert_allclose(angles, expected_output) # Check reversal of the angle convention + if fn_kwargs.get("in_degrees", False): + lower = -180.0 + upper = 180.0 + else: + lower = -np.pi + upper = np.pi if ( - fn_args.get("angle_rotates", f"approach to {other_vector_name}") + fn_kwargs.get("angle_rotates", f"approach to {other_vector_name}") == f"approach to {other_vector_name}" ): - fn_args["angle_rotates"] = f"{other_vector_name} to approach" + fn_kwargs["angle_rotates"] = f"{other_vector_name} to approach" else: - fn_args["angle_rotates"] = f"approach to {other_vector_name}" + fn_kwargs["angle_rotates"] = f"approach to {other_vector_name}" - reverse_angles = push_into_range(method(data, **fn_args)) - xr.testing.assert_allclose(angles, push_into_range(-reverse_angles)) + reverse_angles = push_into_range( + method(*fn_args, **fn_kwargs), lower=lower, upper=upper + ) + xr.testing.assert_allclose( + angles, push_into_range(-reverse_angles, lower=lower, upper=upper) + ) @pytest.fixture From 3cf6e8c37a10bd07350211380b413854fdeb859b Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Fri, 21 Feb 2025 15:12:23 +0000 Subject: [PATCH 29/39] Rework egocentric vector computation --- movement/roi/base.py | 181 ++++++++---------------- tests/test_unit/test_roi/test_angles.py | 85 ++++++----- 2 files changed, 107 insertions(+), 159 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index 4df3ebe55..3312f854b 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Hashable, Sequence -from typing import Any, Literal, TypeAlias +from typing import Literal, TypeAlias import numpy as np import shapely @@ -11,7 +11,6 @@ from numpy.typing import ArrayLike from shapely.coords import CoordinateSequence -from movement.kinematics import compute_forward_vector_angle from movement.utils.broadcasting import broadcastable_method from movement.utils.logging import log_error from movement.utils.vector import compute_signed_angle_2d @@ -193,59 +192,6 @@ def __str__(self) -> str: # noqa: D105 f"({n_points}{display_type})\n" ) + " -> ".join(f"({c[0]}, {c[1]})" for c in self.coords) - def _vector_from_centroid_of_keypoints( - self, - data: xr.DataArray, - position_keypoint: Hashable | Sequence[Hashable], - renamed_dimension: str = "vector to", - which_method: str = "compute_approach_vector", - **method_args: Any, - ) -> xr.DataArray: - """Compute a vector from the centroid of some keypoints to a target. - - Intended for internal use when calculating ego- and allocentric - boundary angles. First, the position of the centroid of the given - keypoints is computed. Then, this value, along with the other keyword - arguments passed, is given to the method specified in ``which_method`` - in order to compute the necessary vectors. - - Parameters - ---------- - data : xarray.DataArray - DataArray of position data. - position_keypoint : Hashable | Sequence[Hashable] - Keypoints to compute centroid of, then compute vectors to/from. - renamed_dimension : str - The name of the new dimension created by ``which_method`` that - contains the corresponding vectors. This dimension will be renamed - to 'space' and given coordinates x, y [, z]. - which_method : str - Name of a class method, which will be used to compute the - appropriate vectors. - method_args : Any - Additional keyword arguments needed by the specified class method. - - """ - position_data = data.sel(keypoints=position_keypoint, drop=True) - if "keypoints" in position_data.dims: - position_data = position_data.mean(dim="keypoints") - - method = getattr(self, which_method) - return ( - method( - position_data, - **method_args, - ) - .rename({renamed_dimension: "space"}) - .assign_coords( - { - "space": ["x", "y"] - if len(data["space"]) == 2 - else ["x", "y", "z"] - } - ) - ) - @broadcastable_method(only_broadcastable_along="space") def contains_point( self, @@ -386,6 +332,13 @@ def compute_approach_vector( np.ndarray Approach vector from the point to the region. + See Also + -------- + ``compute_allocentric_angle_to_nearest_point`` : Relies on this method + to compute the approach vector. + ``compute_egocentric_angle_to_nearest_point`` : Relies on this method + to compute the approach vector. + """ region_to_consider = ( self.region.boundary if boundary_only else self.region @@ -416,18 +369,17 @@ def compute_allocentric_angle_to_nearest_point( in_degrees: bool = False, reference_vector: np.ndarray | xr.DataArray = None, ) -> float: - """Compute the allocentric angle to the region. + """Compute the allocentric angle to the nearest point in the region. + With the term "allocentric" , we indicate that we are measuring angles + with respect to a reference frame that is fixed relative to the + experimental/camera setup. By default, we assume this is the positive + x-axis of the coordinate system in which position is. + The allocentric angle is the :func:`signed angle\ ` between the approach - vector (directed from a point to the region) and a given reference - vector. `angle_rotates`` can be used to reverse the sign convention of - the returned angle. - - The approach vector is the vector from ``position_keypoints`` to the - closest point within the region (or the closest point on the boundary - of the region if ``boundary`` is set to ``True``), as determined by - :func:`compute_approach_vector`. + vector and a given reference vector. ``angle_rotates`` can be used to + select the sign convention of the returned angle. Parameters ---------- @@ -449,17 +401,17 @@ def compute_allocentric_angle_to_nearest_point( See Also -------- - ``compute_signed_angle_2d`` : The underlying function used to compute - the signed angle between the approach vector and the reference + ``compute_approach_vector`` : The method used to compute the approach vector. ``compute_egocentric_angle_to_nearest_point`` : Related class method for computing the egocentric angle to the region. + ``compute_signed_angle_2d`` : The underlying function used to compute + the signed angle between the approach vector and the reference + vector. """ if reference_vector is None: reference_vector = np.array([[1.0, 0.0]]) - # Translate the more explicit convention used here into the convention - # used by our backend functions. if angle_rotates == "ref to approach": ref_as_left_operand = True elif angle_rotates == "approach to ref": @@ -467,15 +419,13 @@ def compute_allocentric_angle_to_nearest_point( else: raise ValueError(f"Unknown angle convention: {angle_rotates}") - # Determine the approach vector, for all time-points. - approach_vector = self.compute_approach_vector( - position, boundary_only=boundary_only - ) approach_vector = self._reassign_space_dim( - approach_vector, "vector to" + self.compute_approach_vector( + position, boundary_only=boundary_only, unit=False + ), + "vector to", ) - # Then, compute signed angles at all time-points angles = compute_signed_angle_2d( approach_vector, reference_vector, @@ -485,18 +435,15 @@ def compute_allocentric_angle_to_nearest_point( angles = np.rad2deg(angles) return angles - def compute_egocentric_angle( + def compute_egocentric_angle_to_nearest_point( self, - data: xr.DataArray, - left_keypoint: Hashable, - right_keypoint: Hashable, + forward_vector: xr.DataArray, + position: xr.DataArray, angle_rotates: Literal[ "approach to forward", "forward to approach" ] = "approach to forward", boundary_only: bool = False, - camera_view: Literal["top_down", "bottom_up"] = "top_down", in_degrees: bool = False, - position_keypoint: Hashable | Sequence[Hashable] | None = None, ) -> xr.DataArray: """Compute the egocentric angle to the region. @@ -516,67 +463,51 @@ def compute_egocentric_angle( Parameters ---------- - data : xarray.DataArray - `DataArray` of positions that has at least 3 dimensions; "time", - "space", and ``keypoints_dimension``. - left_keypoint : Hashable - The left keypoint defining the forward vector, as passed to - func:``compute_forward_vector_angle``. - right_keypoint : Hashable - The right keypoint defining the forward vector, as passed to - func:``compute_forward_vector_angle``. + forward_vector : xarray.DataArray + Forward vector(s) to use in calculation. + position : xarray.DataArray + `DataArray` of spatial positions, considered the origin of the + ``forward_vector``. angle_rotates : Literal["approach to forward", "forward to approach"] Direction of the signed angle returned. Default is ``"approach to forward"``. boundary_only : bool Passed to ``compute_approach_vector`` (see Notes). Default ``False``. - camera_view : Literal["top_down", "bottom_up"] - Passed to func:`compute_forward_vector_angle`. Default - ``"top_down"``. in_degrees : bool If ``True``, angles are returned in degrees. Otherwise angles are returned in radians. Default ``False``. - position_keypoint : Hashable | Sequence[Hashable], optional - The keypoint defining the origin of the approach vector. If - provided as a sequence, the average of all provided keypoints is - used. By default, the centroid of ``left_keypoint`` and - ``right_keypoint`` is used. See Also -------- - ``compute_forward_vector_angle`` : The underlying function used - to compute the signed angle between the forward vector and the - approach vector. + ``compute_allocentric_angle_to_nearest_point`` : Related class method + for computing the egocentric angle to the region. + ``compute_approach_vector`` : The method used to compute the approach + vector. + ``compute_signed_angle_2d`` : The underlying function used to compute + the signed angle between the approach vector and the reference + vector. """ - # Default to centre of left and right keypoints for position, - # if not provided. - if position_keypoint is None: - position_keypoint = [left_keypoint, right_keypoint] - # Translate the more explicit convention used here into the convention - # used by our backend functions. - rotation_angle: Literal["ref to forward", "forward to ref"] = ( - angle_rotates.replace("approach", "ref") # type: ignore - ) - if rotation_angle not in ["ref to forward", "forward to ref"]: + if angle_rotates == "approach to forward": + forward_as_left_operand = False + elif angle_rotates == "forward to approach": + forward_as_left_operand = True + else: raise ValueError(f"Unknown angle convention: {angle_rotates}") - # Determine the approach vector, for all time-points. - approach_vector = self._vector_from_centroid_of_keypoints( - data, - position_keypoint=position_keypoint, - boundary_only=boundary_only, - unit=False, + approach_vector = self._reassign_space_dim( + self.compute_approach_vector( + position, boundary_only=boundary_only, unit=False + ), + "vector to", ) - # Then, compute signed angles at all time-points - return compute_forward_vector_angle( - data, - left_keypoint=left_keypoint, - right_keypoint=right_keypoint, - reference_vector=approach_vector, - camera_view=camera_view, - in_radians=not in_degrees, - angle_rotates=rotation_angle, + angles = compute_signed_angle_2d( + approach_vector, + forward_vector, + v_as_left_operand=forward_as_left_operand, ) + if in_degrees: + angles = np.rad2deg(angles) + return angles diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py index 3988c5b58..d12f294b0 100644 --- a/tests/test_unit/test_roi/test_angles.py +++ b/tests/test_unit/test_roi/test_angles.py @@ -6,6 +6,7 @@ import pytest import xarray as xr +from movement.kinematics import compute_forward_vector from movement.roi import LineOfInterest from movement.roi.base import BaseRegionOfInterest @@ -83,12 +84,15 @@ def sample_position_array() -> xr.DataArray: [ pytest.param( "unit_square_with_hole", - [sample_position_array()], - { - "left_keypoint": "left", - "right_keypoint": "right", - "angle_rotates": "elephant to region", - }, + [ + compute_forward_vector( + sample_position_array(), "left", "right" + ), + sample_position_array() + .sel(keypoints="midpt") + .drop_vars("keypoints"), + ], + {"angle_rotates": "elephant to region"}, ValueError("Unknown angle convention: elephant to region"), True, id="[E] Unknown angle convention", @@ -96,21 +100,22 @@ def sample_position_array() -> xr.DataArray: pytest.param( "unit_square_with_hole", [sample_position_array()], - { - "angle_rotates": "elephant to region", - }, + {"angle_rotates": "elephant to region"}, ValueError("Unknown angle convention: elephant to region"), False, id="[A] Unknown angle convention", ), pytest.param( "unit_square_with_hole", - [sample_position_array()], - { - "left_keypoint": "left", - "right_keypoint": "right", - "in_degrees": True, - }, + [ + compute_forward_vector( + sample_position_array(), "left", "right" + ), + sample_position_array() + .sel(keypoints="midpt") + .drop_vars("keypoints"), + ], + {"in_degrees": True}, np.array( [ 0.0, @@ -125,13 +130,15 @@ def sample_position_array() -> xr.DataArray: ), pytest.param( "unit_square_with_hole", - [sample_position_array()], - { - "left_keypoint": "left", - "right_keypoint": "right", - "position_keypoint": "wild", - "in_degrees": True, - }, + [ + compute_forward_vector( + sample_position_array(), "left", "right" + ), + sample_position_array() + .sel(keypoints="wild") + .drop_vars("keypoints"), + ], + {"in_degrees": True}, np.array( [ 180.0, @@ -146,12 +153,15 @@ def sample_position_array() -> xr.DataArray: ), pytest.param( "unit_square", - [sample_position_array()], - { - "left_keypoint": "left", - "right_keypoint": "right", - "in_degrees": True, - }, + [ + compute_forward_vector( + sample_position_array(), "left", "right" + ), + sample_position_array() + .sel(keypoints="midpt") + .drop_vars("keypoints"), + ], + {"in_degrees": True}, np.array( [ 0.0, @@ -166,10 +176,15 @@ def sample_position_array() -> xr.DataArray: ), pytest.param( "unit_square", - [sample_position_array()], + [ + compute_forward_vector( + sample_position_array(), "left", "right" + ), + sample_position_array() + .sel(keypoints="midpt") + .drop_vars("keypoints"), + ], { - "left_keypoint": "left", - "right_keypoint": "right", "boundary_only": True, "in_degrees": True, }, @@ -282,7 +297,7 @@ def test_ego_and_allocentric_angle_to_region( expected_output = xr.DataArray(data=expected_output, dims=["time"]) if egocentric: - which_method = "compute_egocentric_angle" + which_method = "compute_egocentric_angle_to_nearest_point" other_vector_name = "forward" else: which_method = "compute_allocentric_angle_to_nearest_point" @@ -379,8 +394,10 @@ def test_angle_to_support_plane( ) xr.testing.assert_allclose(expected_output, angles_to_support) - egocentric_angles = segment_of_y_equals_x.compute_egocentric_angle( - points_around_segment, left_keypoint="left", right_keypoint="right" + egocentric_angles = ( + segment_of_y_equals_x.compute_egocentric_angle_to_nearest_point( + points_around_segment, left_keypoint="left", right_keypoint="right" + ) ) values_are_close = egocentric_angles.copy( data=np.isclose(egocentric_angles, angles_to_support), deep=True From c8f88f89a8fd7cdd46837c6429d5eb8e39ca0181 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Fri, 21 Feb 2025 15:25:00 +0000 Subject: [PATCH 30/39] Rework line support case --- movement/roi/base.py | 2 +- movement/roi/line.py | 94 +++++++++---------------- tests/test_unit/test_roi/test_angles.py | 12 ++-- 3 files changed, 40 insertions(+), 68 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index 3312f854b..0072c2512 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -375,7 +375,7 @@ def compute_allocentric_angle_to_nearest_point( with respect to a reference frame that is fixed relative to the experimental/camera setup. By default, we assume this is the positive x-axis of the coordinate system in which position is. - + The allocentric angle is the :func:`signed angle\ ` between the approach vector and a given reference vector. ``angle_rotates`` can be used to diff --git a/movement/roi/line.py b/movement/roi/line.py index 0028980ad..493a01977 100644 --- a/movement/roi/line.py +++ b/movement/roi/line.py @@ -1,18 +1,17 @@ """1-dimensional lines of interest.""" -from collections.abc import Hashable, Sequence from typing import Literal import numpy as np import xarray as xr from numpy.typing import ArrayLike -from movement.kinematics import compute_forward_vector_angle from movement.roi.base import ( BaseRegionOfInterest, PointLikeList, ) from movement.utils.broadcasting import broadcastable_method +from movement.utils.vector import compute_signed_angle_2d class LineOfInterest(BaseRegionOfInterest): @@ -103,80 +102,53 @@ def normal(self, on_same_side_as: ArrayLike = (0.0, 0.0)) -> np.ndarray: normal *= -1.0 return normal - def compute_angle_to_support_plane_of_segment( + def compute_angle_to_support_plane( self, - data: xr.DataArray, - left_keypoint: Hashable, - right_keypoint: Hashable, + forward_vector: xr.DataArray, + position: xr.DataArray, angle_rotates: Literal[ "forward to normal", "normal to forward" ] = "normal to forward", - camera_view: Literal["top_down", "bottom_up"] = "top_down", - in_radians: bool = False, - position_keypoint: Hashable | Sequence[Hashable] | None = None, + in_degrees: bool = False, ) -> xr.DataArray: - """Compute the signed angle between the normal and a forward vector. + """Compute the signed angle between the normal and the forward vector. This method is identical to ``compute_egocentric_angle``, except that - rather than the angle between the approach vector and a forward vector, - the angle between the normal directed toward the segment and the - forward vector is returned. - - For finite segments, the normal to the infinite extension of the - segment is used in the calculation. + rather than the angle between the approach vector and the forward + vector, the angle between the normal directed toward the segment and + the forward vector is returned. Parameters ---------- - data : xarray.DataArray - `DataArray` of positions that has at least 3 dimensions; "time", - "space", and ``keypoints_dimension``. - left_keypoint : Hashable - The left keypoint defining the forward vector, as passed to - func:``compute_forward_vector_angle``. - right_keypoint : Hashable - The right keypoint defining the forward vector, as passed to - func:``compute_forward_vector_angle``. - angle_rotates : Literal["approach to forward", "forward to approach"] - Direction of the signed angle returned. Default is - ``"approach to forward"``. - camera_view : Literal["top_down", "bottom_up"] - Passed to func:`compute_forward_vector_angle`. Default - ``"top_down"``. - in_radians : bool - If ``True``, angles are returned in radians. Otherwise angles are - returned in degrees. Default ``False``. - position_keypoint : Hashable | Sequence[Hashable], optional - The keypoint defining the origin of the approach vector. If - provided as a sequence, the average of all provided keypoints is - used. By default, the centroid of ``left_keypoint`` and - ``right_keypoint`` is used. + forward_vector : xarray.DataArray + Forward vectors to take angle with. + position : xr.DataArray + Spatial positions, considered the origin of the ``forward_vector``. + angle_rotates : Literal["forward to normal", "normal to forward"] + Sign convention of the angle returned. Default is + ``"normal to forward"``. + in_degrees : bool + If ``True``, angles are returned in degrees. Otherwise angles are + returned in radians. Default ``False``. """ - # Default to centre of left and right keypoints for position, - # if not provided. - if position_keypoint is None: - position_keypoint = [left_keypoint, right_keypoint] - # Normal from position to segment is the reverse of what normal returns - normal = -1.0 * self._vector_from_centroid_of_keypoints( - data, - position_keypoint=position_keypoint, - renamed_dimension="normal", - which_method="normal", + normal = self._reassign_space_dim( + -1.0 * self.normal(position), "normal" ) # Translate the more explicit convention used here into the convention # used by our backend functions. - rotation_angle: Literal["ref to forward", "forward to ref"] = ( - angle_rotates.replace("normal", "ref") # type: ignore - ) - - return compute_forward_vector_angle( - data, - left_keypoint=left_keypoint, - right_keypoint=right_keypoint, - reference_vector=normal, - camera_view=camera_view, - in_radians=in_radians, - angle_rotates=rotation_angle, + if angle_rotates == "normal to forward": + forward_as_left_operand = False + elif angle_rotates == "forward to normal": + forward_as_left_operand = True + else: + raise ValueError(f"Unknown angle convention: {angle_rotates}") + + angles = compute_signed_angle_2d( + normal, forward_vector, v_as_left_operand=forward_as_left_operand ) + if in_degrees: + angles = np.rad2deg(angles) + return angles diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py index d12f294b0..9b4a875f9 100644 --- a/tests/test_unit/test_roi/test_angles.py +++ b/tests/test_unit/test_roi/test_angles.py @@ -381,22 +381,22 @@ def test_angle_to_support_plane( points_around_segment: xr.DataArray, ) -> None: expected_output = xr.DataArray( - data=np.array([-90.0, -90.0, -90.0]), dims=["time"] + data=np.deg2rad([-90.0, -90.0, -90.0]), dims=["time"] ) should_be_same_as_egocentric = expected_output.copy( data=[True, True, False], deep=True ) - angles_to_support = ( - segment_of_y_equals_x.compute_angle_to_support_plane_of_segment( - points_around_segment, left_keypoint="left", right_keypoint="right" - ) + fwd_vector = compute_forward_vector(points_around_segment, "left", "right") + positions = points_around_segment.mean(dim="keypoints") + angles_to_support = segment_of_y_equals_x.compute_angle_to_support_plane( + fwd_vector, positions ) xr.testing.assert_allclose(expected_output, angles_to_support) egocentric_angles = ( segment_of_y_equals_x.compute_egocentric_angle_to_nearest_point( - points_around_segment, left_keypoint="left", right_keypoint="right" + fwd_vector, positions ) ) values_are_close = egocentric_angles.copy( From d824bbb93bf8aea3d2633f2ac86322471320bd10 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Fri, 21 Feb 2025 15:56:35 +0000 Subject: [PATCH 31/39] Refactor common boundary angle methodology --- movement/roi/base.py | 151 +++++++++++++++++++++++++++++++------------ movement/roi/line.py | 30 +++------ 2 files changed, 118 insertions(+), 63 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index 0072c2512..bb98f787e 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Hashable, Sequence +from collections.abc import Callable, Hashable, Sequence from typing import Literal, TypeAlias import numpy as np @@ -106,14 +106,96 @@ def region(self) -> SupportedGeometry: """``shapely.Geometry`` representation of the region.""" return self._shapely_geometry + @staticmethod + def _boundary_angle_computation( + position: xr.DataArray, + reference_vector: xr.DataArray | np.ndarray, + how_to_compute_vector_to_region: Callable[ + [xr.DataArray], xr.DataArray + ], + angle_rotates: Literal["vec to ref", "ref to vec"] = "vec to ref", + in_degrees: bool = False, + ) -> xr.DataArray: + """Perform a boundary angle computation. + + Intended for internal use when conducting boundary angle computations, + to reduce code duplication. All boundary angle computations involve two + parts: + + - From some given spatial position data, compute the "vector towards + the region". This is typically the approach vector, but might also be + the normal vector if we are dealing with a segment or the plane + supported by a segment. + - Compute the signed angle between the "vector towards the region" and + some given reference vector, which may be constant or varying in time + (such as an animal's heading or forward vector). + + As such, we generalise the process into this internal method, and + provide more explicit wrappers to the user to make working with the + methods easier. + + Parameters + ---------- + position : xarray.DataArray + Spatial position data, that is passed to + ``how_to_compute_approach`` and used to compute the "vector to the + region". + reference_vector : xarray.DataArray | np.ndarray + Constant or time-varying vector to take signed angle with the + "vector to the region". + how_to_compute_vector_to_region : Callable + How to compute the "vector to the region" from ``position``. + angle_rotates : Literal["vec to ref", "ref to vec"] + Convention for the returned signed angle. ``"vec to ref"`` returns + the signed angle between the "vector to the region" and the + ``reference_vector``. ``"ref to vec"`` returns the opposite. + Default is ``"vec to ref"`` + in_degrees : bool + If ``True``, angles are returned in degrees. Otherwise angles are + returned in radians. Default ``False``. + + """ + if angle_rotates == "vec to ref": + ref_is_left_operand = False + elif angle_rotates == "ref to vec": + ref_is_left_operand = True + else: + raise ValueError(f"Unknown angle convention: {angle_rotates}") + + vec_to_segment = how_to_compute_vector_to_region(position) + + angles = compute_signed_angle_2d( + vec_to_segment, + reference_vector, + v_as_left_operand=ref_is_left_operand, + ) + if in_degrees: + angles = np.rad2deg(angles) + return angles + @staticmethod def _reassign_space_dim( da: xr.DataArray, old_dimension: Hashable, - new_dimension: Hashable = "space", ) -> xr.DataArray: - """""" - return da.rename({old_dimension: new_dimension}).assign_coords( + """Rename a computed dimension 'space' and assign coordinates. + + Intended for internal use when chaining ``DataArray``-broadcastable + operations together. In some instances, the outputs drop the spatial + coordinates, or the "space" axis is returned under a different name. + This needs to be corrected before further computations can be + performed. + + Parameters + ---------- + da : xarray.DataArray + ``DataArray`` lacking a "space" dimension, that is to be assigned. + old_dimension : Hashable + The dimension that should be renamed to "space", and reassigned + coordinates. + + """ + return da.rename({old_dimension: "space"}).assign_coords( { "space": ["x", "y"] if len(da[old_dimension]) == 2 @@ -412,28 +494,19 @@ def compute_allocentric_angle_to_nearest_point( """ if reference_vector is None: reference_vector = np.array([[1.0, 0.0]]) - if angle_rotates == "ref to approach": - ref_as_left_operand = True - elif angle_rotates == "approach to ref": - ref_as_left_operand = False - else: - raise ValueError(f"Unknown angle convention: {angle_rotates}") - approach_vector = self._reassign_space_dim( - self.compute_approach_vector( - position, boundary_only=boundary_only, unit=False + return self._boundary_angle_computation( + position=position, + reference_vector=reference_vector, + how_to_compute_vector_to_region=lambda p: self._reassign_space_dim( + self.compute_approach_vector( + p, boundary_only=boundary_only, unit=False + ), + "vector to", ), - "vector to", - ) - - angles = compute_signed_angle_2d( - approach_vector, - reference_vector, - v_as_left_operand=ref_as_left_operand, + angle_rotates=angle_rotates.replace("approach", "vec"), # type: ignore + in_degrees=in_degrees, ) - if in_degrees: - angles = np.rad2deg(angles) - return angles def compute_egocentric_angle_to_nearest_point( self, @@ -489,25 +562,17 @@ def compute_egocentric_angle_to_nearest_point( vector. """ - if angle_rotates == "approach to forward": - forward_as_left_operand = False - elif angle_rotates == "forward to approach": - forward_as_left_operand = True - else: - raise ValueError(f"Unknown angle convention: {angle_rotates}") - - approach_vector = self._reassign_space_dim( - self.compute_approach_vector( - position, boundary_only=boundary_only, unit=False + return self._boundary_angle_computation( + position=position, + reference_vector=forward_vector, + how_to_compute_vector_to_region=lambda p: self._reassign_space_dim( + self.compute_approach_vector( + p, boundary_only=boundary_only, unit=False + ), + "vector to", ), - "vector to", - ) - - angles = compute_signed_angle_2d( - approach_vector, - forward_vector, - v_as_left_operand=forward_as_left_operand, + angle_rotates=angle_rotates.replace("approach", "vec").replace( # type: ignore + "forward", "ref" + ), + in_degrees=in_degrees, ) - if in_degrees: - angles = np.rad2deg(angles) - return angles diff --git a/movement/roi/line.py b/movement/roi/line.py index 493a01977..8ce48e542 100644 --- a/movement/roi/line.py +++ b/movement/roi/line.py @@ -11,7 +11,6 @@ PointLikeList, ) from movement.utils.broadcasting import broadcastable_method -from movement.utils.vector import compute_signed_angle_2d class LineOfInterest(BaseRegionOfInterest): @@ -132,23 +131,14 @@ def compute_angle_to_support_plane( returned in radians. Default ``False``. """ - # Normal from position to segment is the reverse of what normal returns - normal = self._reassign_space_dim( - -1.0 * self.normal(position), "normal" + return self._boundary_angle_computation( + position=position, + reference_vector=forward_vector, + how_to_compute_vector_to_region=lambda p: self._reassign_space_dim( + -1.0 * self.normal(p), "normal" + ), + angle_rotates=angle_rotates.replace("forward", "ref").replace( # type: ignore + "normal", "vec" + ), + in_degrees=in_degrees, ) - - # Translate the more explicit convention used here into the convention - # used by our backend functions. - if angle_rotates == "normal to forward": - forward_as_left_operand = False - elif angle_rotates == "forward to normal": - forward_as_left_operand = True - else: - raise ValueError(f"Unknown angle convention: {angle_rotates}") - - angles = compute_signed_angle_2d( - normal, forward_vector, v_as_left_operand=forward_as_left_operand - ) - if in_degrees: - angles = np.rad2deg(angles) - return angles From 6f45187f639846b1795f8840b89ccd2b6cc31c40 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Fri, 21 Feb 2025 16:02:58 +0000 Subject: [PATCH 32/39] Pass over the docstrings again --- movement/roi/base.py | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index bb98f787e..71b68f915 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -453,15 +453,14 @@ def compute_allocentric_angle_to_nearest_point( ) -> float: """Compute the allocentric angle to the nearest point in the region. - With the term "allocentric" , we indicate that we are measuring angles + With the term "allocentric", we indicate that we are measuring angles with respect to a reference frame that is fixed relative to the experimental/camera setup. By default, we assume this is the positive x-axis of the coordinate system in which position is. The allocentric angle is the :func:`signed angle\ ` between the approach - vector and a given reference vector. ``angle_rotates`` can be used to - select the sign convention of the returned angle. + vector and a given reference vector. Parameters ---------- @@ -520,19 +519,14 @@ def compute_egocentric_angle_to_nearest_point( ) -> xr.DataArray: """Compute the egocentric angle to the region. + With the term "egocentric", we indicate that we are measuring angles + with respect to a reference frame that is varying in time relative to + the experimental/camera setup. An example may be the forward direction + of an individual. + The egocentric angle is the signed angle between the approach vector - (directed from a point towards the region) a forward direction - (typically of a given individual or keypoint). ``angle_rotates`` can - be used to reverse the sign convention of the returned angle. - - The forward vector is determined by ``left_keypoint``, - ``right_keypoint``, and ``camera_view`` as per :func:`forward vector\ - `. - - The approach vector is the vector from ``position_keypoints`` to the - closest point within the region (or the closest point on the boundary - of the region if ``boundary`` is set to ``True``), as determined by - :func:`compute_approach_vector`. + and a forward direction (typically the forward vector of a given + individual or keypoint). Parameters ---------- From 27a74b1d38412f24bab10e33d8610735d0f64716 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Tue, 25 Feb 2025 09:56:04 +0000 Subject: [PATCH 33/39] Suggestions from code review number 1 --- movement/roi/base.py | 58 +++++++++++------------ movement/roi/line.py | 7 +-- tests/test_unit/test_roi/test_angles.py | 63 ++++++++----------------- 3 files changed, 52 insertions(+), 76 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index 71b68f915..0217806fd 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -138,8 +138,8 @@ def _boundary_angle_computation( ---------- position : xarray.DataArray Spatial position data, that is passed to - ``how_to_compute_approach`` and used to compute the "vector to the - region". + ``how_to_compute_vector_to_region`` and used to compute the + "vector to the region". reference_vector : xarray.DataArray | np.ndarray Constant or time-varying vector to take signed angle with the "vector to the region". @@ -336,10 +336,6 @@ def compute_distance_to( Euclidean distance from the ``point`` to the (closest point on the) region. - See Also - -------- - shapely.distance : Underlying used to compute the nearest point. - """ region_to_consider = ( self.region.boundary if boundary_only else self.region @@ -354,6 +350,10 @@ def compute_nearest_point_to( ) -> np.ndarray: """Compute (one of) the nearest point(s) in the region to ``position``. + If there are multiple equidistant points, one of them is returned. + + Parameters + ---------- position : ArrayLike Coordinates of a point, from which to find the nearest point in the region. @@ -367,10 +367,6 @@ def compute_nearest_point_to( Coordinates of the point on ``self`` that is closest to ``position``. - See Also - -------- - shapely.shortest_line : Underlying used to compute the nearest point. - """ region_to_consider = ( self.region.boundary if boundary_only else self.region @@ -416,10 +412,10 @@ def compute_approach_vector( See Also -------- - ``compute_allocentric_angle_to_nearest_point`` : Relies on this method - to compute the approach vector. - ``compute_egocentric_angle_to_nearest_point`` : Relies on this method - to compute the approach vector. + compute_allocentric_angle_to_nearest_point : + Relies on this method to compute the approach vector. + compute_egocentric_angle_to_nearest_point : + Relies on this method to compute the approach vector. """ region_to_consider = ( @@ -456,7 +452,7 @@ def compute_allocentric_angle_to_nearest_point( With the term "allocentric", we indicate that we are measuring angles with respect to a reference frame that is fixed relative to the experimental/camera setup. By default, we assume this is the positive - x-axis of the coordinate system in which position is. + x-axis of the coordinate system in which ``position`` is. The allocentric angle is the :func:`signed angle\ ` between the approach @@ -474,7 +470,7 @@ def compute_allocentric_angle_to_nearest_point( the region is computed. Default ``False``. in_degrees : bool If ``True``, angles are returned in degrees. Otherwise angles are - returned in degrees. Default ``False``. + returned in radians. Default ``False``. reference_vector : ArrayLike | xr.DataArray The reference vector to be used. Dimensions must be compatible with the argument of the same name that is passed to @@ -482,13 +478,14 @@ def compute_allocentric_angle_to_nearest_point( See Also -------- - ``compute_approach_vector`` : The method used to compute the approach - vector. - ``compute_egocentric_angle_to_nearest_point`` : Related class method - for computing the egocentric angle to the region. - ``compute_signed_angle_2d`` : The underlying function used to compute - the signed angle between the approach vector and the reference - vector. + compute_approach_vector : + The method used to compute the approach vector. + compute_egocentric_angle_to_nearest_point : + Related class method for computing the egocentric angle to the + region. + movement.utils.vector.compute_signed_angle_2d : + The underlying function used to compute the signed angle between + the approach vector and the reference vector. """ if reference_vector is None: @@ -547,13 +544,14 @@ def compute_egocentric_angle_to_nearest_point( See Also -------- - ``compute_allocentric_angle_to_nearest_point`` : Related class method - for computing the egocentric angle to the region. - ``compute_approach_vector`` : The method used to compute the approach - vector. - ``compute_signed_angle_2d`` : The underlying function used to compute - the signed angle between the approach vector and the reference - vector. + compute_allocentric_angle_to_nearest_point : + Related class method for computing the egocentric angle to the + region. + compute_approach_vector : + The method used to compute the approach vector. + movement.utils.vector.compute_signed_angle_2d : + The underlying function used to compute the signed angle between + the approach vector and the reference vector. """ return self._boundary_angle_computation( diff --git a/movement/roi/line.py b/movement/roi/line.py index 8ce48e542..09301d332 100644 --- a/movement/roi/line.py +++ b/movement/roi/line.py @@ -77,7 +77,7 @@ def normal(self, on_same_side_as: ArrayLike = (0.0, 0.0)) -> np.ndarray: whose norm is equal to 1. The direction of the normal vector is not fully defined: the line divides the 2D plane in two halves, and the normal could be pointing to either of the half-planes. - For example, an horizontal line divides the 2D plane in a + For example, a horizontal line divides the 2D plane in a bottom and a top half-plane, and we can choose whether the normal points "upwards" or "downwards". We use a sample point to define the half-plane the normal vector points to. @@ -110,9 +110,10 @@ def compute_angle_to_support_plane( ] = "normal to forward", in_degrees: bool = False, ) -> xr.DataArray: - """Compute the signed angle between the normal and the forward vector. + """Compute the angle between the support plane and the forward vector. - This method is identical to ``compute_egocentric_angle``, except that + This method is identical to + ``compute_egocentric_angle_to_nearest_point``, except that rather than the angle between the approach vector and the forward vector, the angle between the normal directed toward the segment and the forward vector is returned. diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py index 9b4a875f9..a446777cc 100644 --- a/tests/test_unit/test_roi/test_angles.py +++ b/tests/test_unit/test_roi/test_angles.py @@ -18,8 +18,8 @@ def sample_position_array() -> xr.DataArray: The keypoints are left, right, midpt (midpoint), and wild. The midpt is the mean of the left and right keypoints; the wild keypoint - may be anywhere in the plane (it is used to test the ``position_keypoint`` - argument). + may be anywhere in the plane (it is used to test that the function respects + the origin of the forward vector that the user provides). time 1: left @ (1.25, 0.), right @ (1., -0.25), wild @ (-0.25, -0.25) @@ -88,14 +88,12 @@ def sample_position_array() -> xr.DataArray: compute_forward_vector( sample_position_array(), "left", "right" ), - sample_position_array() - .sel(keypoints="midpt") - .drop_vars("keypoints"), + sample_position_array().sel(keypoints="midpt", drop=True), ], {"angle_rotates": "elephant to region"}, ValueError("Unknown angle convention: elephant to region"), True, - id="[E] Unknown angle convention", + id="[Egocentric] Unknown angle convention", ), pytest.param( "unit_square_with_hole", @@ -103,7 +101,7 @@ def sample_position_array() -> xr.DataArray: {"angle_rotates": "elephant to region"}, ValueError("Unknown angle convention: elephant to region"), False, - id="[A] Unknown angle convention", + id="[Allocentric] Unknown angle convention", ), pytest.param( "unit_square_with_hole", @@ -111,9 +109,7 @@ def sample_position_array() -> xr.DataArray: compute_forward_vector( sample_position_array(), "left", "right" ), - sample_position_array() - .sel(keypoints="midpt") - .drop_vars("keypoints"), + sample_position_array().sel(keypoints="midpt", drop=True), ], {"in_degrees": True}, np.array( @@ -126,7 +122,7 @@ def sample_position_array() -> xr.DataArray: ] ), True, - id="[E] Default args", + id="[Egocentric] Default args", ), pytest.param( "unit_square_with_hole", @@ -134,9 +130,7 @@ def sample_position_array() -> xr.DataArray: compute_forward_vector( sample_position_array(), "left", "right" ), - sample_position_array() - .sel(keypoints="wild") - .drop_vars("keypoints"), + sample_position_array().sel(keypoints="wild", drop=True), ], {"in_degrees": True}, np.array( @@ -149,7 +143,7 @@ def sample_position_array() -> xr.DataArray: ] ), True, - id="[E] Non-default position", + id="[Egocentric] Non-default position", ), pytest.param( "unit_square", @@ -157,9 +151,7 @@ def sample_position_array() -> xr.DataArray: compute_forward_vector( sample_position_array(), "left", "right" ), - sample_position_array() - .sel(keypoints="midpt") - .drop_vars("keypoints"), + sample_position_array().sel(keypoints="midpt", drop=True), ], {"in_degrees": True}, np.array( @@ -172,7 +164,7 @@ def sample_position_array() -> xr.DataArray: ] ), True, - id="[E] 0-approach vectors (nan returns)", + id="[Egocentric] 0-approach vectors (nan returns)", ), pytest.param( "unit_square", @@ -180,9 +172,7 @@ def sample_position_array() -> xr.DataArray: compute_forward_vector( sample_position_array(), "left", "right" ), - sample_position_array() - .sel(keypoints="midpt") - .drop_vars("keypoints"), + sample_position_array().sel(keypoints="midpt", drop=True), ], { "boundary_only": True, @@ -198,15 +188,11 @@ def sample_position_array() -> xr.DataArray: ] ), True, - id="[E] Force boundary calculations", + id="[Egocentric] Force boundary calculations", ), pytest.param( "unit_square_with_hole", - [ - sample_position_array() - .sel(keypoints="midpt") - .drop_vars("keypoints") - ], + [sample_position_array().sel(keypoints="midpt", drop=True)], {}, np.deg2rad( np.array( @@ -220,15 +206,11 @@ def sample_position_array() -> xr.DataArray: ) ), False, - id="[A] Default args", + id="[Allocentric] Default args", ), pytest.param( "unit_square", - [ - sample_position_array() - .sel(keypoints="midpt") - .drop_vars("keypoints") - ], + [sample_position_array().sel(keypoints="midpt", drop=True)], {}, np.deg2rad( np.array( @@ -242,15 +224,11 @@ def sample_position_array() -> xr.DataArray: ) ), False, - id="[A] 0-approach vectors", + id="[Allocentric] 0-approach vectors", ), pytest.param( "unit_square", - [ - sample_position_array() - .sel(keypoints="midpt") - .drop_vars("keypoints") - ], + [sample_position_array().sel(keypoints="midpt", drop=True)], {"boundary_only": True}, np.deg2rad( np.array( @@ -264,7 +242,7 @@ def sample_position_array() -> xr.DataArray: ) ), False, - id="[A] Force boundary calculation", + id="[Allocentric] Force boundary calculation", ), ], ) @@ -285,8 +263,7 @@ def test_ego_and_allocentric_angle_to_region( Specifically; - - ``camera_view``, - - ``in_radians``, + - ``in_degrees``, The ``angle_rotates`` argument is tested in all cases (signs should be reversed when toggling the argument). From cbf74d2afd5adf8e9251fc45c453c501ec76351a Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Tue, 25 Feb 2025 10:31:28 +0000 Subject: [PATCH 34/39] Code review comments 2 --- movement/kinematics.py | 8 ++++---- movement/validators/arrays.py | 2 +- tests/test_unit/test_roi/test_angles.py | 4 +++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/movement/kinematics.py b/movement/kinematics.py index d00b4eba0..77af99e22 100644 --- a/movement/kinematics.py +++ b/movement/kinematics.py @@ -224,9 +224,9 @@ def compute_forward_vector( 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 + left_keypoint : Hashable Name of the left keypoint, e.g., "left_ear" - right_keypoint : str + right_keypoint : Hashable 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 @@ -382,10 +382,10 @@ def compute_forward_vector_angle( 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 + left_keypoint : Hashable Name of the left keypoint, e.g., "left_ear", used to compute the forward vector. - right_keypoint : str + right_keypoint : Hashable Name of the right keypoint, e.g., "right_ear", used to compute the forward vector. reference_vector : xr.DataArray | ArrayLike, optional diff --git a/movement/validators/arrays.py b/movement/validators/arrays.py index 7cb8a4d1d..20e09df13 100644 --- a/movement/validators/arrays.py +++ b/movement/validators/arrays.py @@ -25,7 +25,7 @@ def validate_dims_coords( ---------- data : xarray.DataArray The input data array to validate. - required_dim_coords : dict of {str: list of str} + required_dim_coords : dict of {str: list of str | list of Hashable} A dictionary mapping required dimensions to a list of required coordinate values along each dimension. exact_coords : bool, optional diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py index a446777cc..27d4fcef8 100644 --- a/tests/test_unit/test_roi/test_angles.py +++ b/tests/test_unit/test_roi/test_angles.py @@ -290,7 +290,9 @@ def test_ego_and_allocentric_angle_to_region( angles = method(*fn_args, **fn_kwargs) xr.testing.assert_allclose(angles, expected_output) - # Check reversal of the angle convention + # Check reversal of the angle convention, + # which should just reverse the sign of the angles, + # except for 180-degrees -> 180-degrees. if fn_kwargs.get("in_degrees", False): lower = -180.0 upper = 180.0 From 1841322a62a20a729140c0ba9235211fa0ce8a47 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Tue, 25 Feb 2025 10:47:18 +0000 Subject: [PATCH 35/39] Restrict normal to single-segment lines --- movement/roi/line.py | 44 ++++++++++++++++--------- tests/test_unit/test_roi/test_angles.py | 12 ++++++- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/movement/roi/line.py b/movement/roi/line.py index 09301d332..dc57deddc 100644 --- a/movement/roi/line.py +++ b/movement/roi/line.py @@ -82,13 +82,25 @@ def normal(self, on_same_side_as: ArrayLike = (0.0, 0.0)) -> np.ndarray: the normal points "upwards" or "downwards". We use a sample point to define the half-plane the normal vector points to. + If this is a multi-segment line, the method raises an error. + Parameters ---------- on_same_side_as : ArrayLike - A sample point in the (x,y) plane the normal is in. By default, the - origin is used. + A sample point in the (x,y) plane the normal is in. If multiple + points are given, one normal vector is returned for each point + given. By default, the origin is used. + + Raises + ------ + ValueError : When the normal is requested for a multi-segment geometry. """ + if len(self.coords) > 2: + raise ValueError( + "Normal is not defined for multi-segment geometries." + ) + on_same_side_as = np.array(on_same_side_as) parallel_to_line = np.array(self.region.coords[1]) - np.array( @@ -101,32 +113,32 @@ def normal(self, on_same_side_as: ArrayLike = (0.0, 0.0)) -> np.ndarray: normal *= -1.0 return normal - def compute_angle_to_support_plane( + def compute_angle_to_plane_normal( self, - forward_vector: xr.DataArray, + direction: xr.DataArray, position: xr.DataArray, angle_rotates: Literal[ - "forward to normal", "normal to forward" - ] = "normal to forward", + "direction to normal", "normal to direction" + ] = "normal to direction", in_degrees: bool = False, ) -> xr.DataArray: - """Compute the angle between the support plane and the forward vector. + """Compute the angle between the normal to the segment and a direction. This method is identical to ``compute_egocentric_angle_to_nearest_point``, except that - rather than the angle between the approach vector and the forward - vector, the angle between the normal directed toward the segment and - the forward vector is returned. + rather than the angle between the approach vector and ``direction``, + the angle between the normal directed toward the segment and + ``direction`` is returned. Parameters ---------- - forward_vector : xarray.DataArray + direction : xarray.DataArray Forward vectors to take angle with. position : xr.DataArray - Spatial positions, considered the origin of the ``forward_vector``. - angle_rotates : Literal["forward to normal", "normal to forward"] + Spatial positions, considered the origin of the ``direction``. + angle_rotates : Literal["direction to normal", "normal to direction"] Sign convention of the angle returned. Default is - ``"normal to forward"``. + ``"normal to direction"``. in_degrees : bool If ``True``, angles are returned in degrees. Otherwise angles are returned in radians. Default ``False``. @@ -134,11 +146,11 @@ def compute_angle_to_support_plane( """ return self._boundary_angle_computation( position=position, - reference_vector=forward_vector, + reference_vector=direction, how_to_compute_vector_to_region=lambda p: self._reassign_space_dim( -1.0 * self.normal(p), "normal" ), - angle_rotates=angle_rotates.replace("forward", "ref").replace( # type: ignore + angle_rotates=angle_rotates.replace("direction", "ref").replace( # type: ignore "normal", "vec" ), in_degrees=in_degrees, diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py index 27d4fcef8..bf91f3943 100644 --- a/tests/test_unit/test_roi/test_angles.py +++ b/tests/test_unit/test_roi/test_angles.py @@ -359,6 +359,16 @@ def test_angle_to_support_plane( segment_of_y_equals_x: LineOfInterest, points_around_segment: xr.DataArray, ) -> None: + """Test the angle_to_support_plane method. + + This method checks two things: + + - The angle_to_support_plane returns the correct angle, and + - The method agrees with the egocentric angle computation, in the cases + that the two calculations should return the same value (IE when the + approach vector is the normal to the segment). And that the + returned angles are different otherwise. + """ expected_output = xr.DataArray( data=np.deg2rad([-90.0, -90.0, -90.0]), dims=["time"] ) @@ -368,7 +378,7 @@ def test_angle_to_support_plane( fwd_vector = compute_forward_vector(points_around_segment, "left", "right") positions = points_around_segment.mean(dim="keypoints") - angles_to_support = segment_of_y_equals_x.compute_angle_to_support_plane( + angles_to_support = segment_of_y_equals_x.compute_angle_to_plane_normal( fwd_vector, positions ) xr.testing.assert_allclose(expected_output, angles_to_support) From ec67f68c5fe4d4bc17ae78c1aeff62a358eb94a5 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Tue, 25 Feb 2025 10:54:17 +0000 Subject: [PATCH 36/39] Write test for multi-segment normals being forbidden --- movement/roi/line.py | 1 + tests/test_unit/test_roi/test_normal.py | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/movement/roi/line.py b/movement/roi/line.py index dc57deddc..ce25d1748 100644 --- a/movement/roi/line.py +++ b/movement/roi/line.py @@ -96,6 +96,7 @@ def normal(self, on_same_side_as: ArrayLike = (0.0, 0.0)) -> np.ndarray: ValueError : When the normal is requested for a multi-segment geometry. """ + # A multi-segment geometry always has at least 3 coordinates. if len(self.coords) > 2: raise ValueError( "Normal is not defined for multi-segment geometries." diff --git a/tests/test_unit/test_roi/test_normal.py b/tests/test_unit/test_roi/test_normal.py index d777ab4c5..ec789a75c 100644 --- a/tests/test_unit/test_roi/test_normal.py +++ b/tests/test_unit/test_roi/test_normal.py @@ -1,3 +1,5 @@ +import re + import numpy as np import pytest from numpy.typing import ArrayLike @@ -34,16 +36,28 @@ (-1.0 / SQRT_2, 1.0 / SQRT_2), id="Necessary to extend segment to compute normal.", ), + pytest.param( + LineOfInterest([(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)]), + (0.5, 0.5), + ValueError("Normal is not defined for multi-segment geometries."), + id="Multi-segment lines do not have normals.", + ), ], ) def test_normal( segment: LineOfInterest, point: ArrayLike, - expected_normal: np.ndarray, + expected_normal: np.ndarray | Exception, request, ) -> None: if isinstance(segment, str): segment = request.getfixturevalue(segment) - computed_normal = segment.normal(point) - assert np.allclose(computed_normal, expected_normal) + if isinstance(expected_normal, Exception): + with pytest.raises( + type(expected_normal), match=re.escape(str(expected_normal)) + ): + segment.normal(point) + else: + computed_normal = segment.normal(point) + assert np.allclose(computed_normal, expected_normal) From a41247812751d809324be6da25be1a21f95f07e4 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Tue, 25 Feb 2025 11:03:40 +0000 Subject: [PATCH 37/39] Generalise forward_vector to direction --- movement/roi/base.py | 27 ++++++++++++------------- tests/test_unit/test_roi/test_angles.py | 2 +- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index 0217806fd..45b8391bd 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -506,35 +506,34 @@ def compute_allocentric_angle_to_nearest_point( def compute_egocentric_angle_to_nearest_point( self, - forward_vector: xr.DataArray, + direction: xr.DataArray, position: xr.DataArray, angle_rotates: Literal[ - "approach to forward", "forward to approach" - ] = "approach to forward", + "approach to direction", "direction to approach" + ] = "approach to direction", boundary_only: bool = False, in_degrees: bool = False, ) -> xr.DataArray: - """Compute the egocentric angle to the region. + """Compute the egocentric angle to the nearest point in the region. With the term "egocentric", we indicate that we are measuring angles with respect to a reference frame that is varying in time relative to - the experimental/camera setup. An example may be the forward direction - of an individual. + the experimental/camera setup. The egocentric angle is the signed angle between the approach vector - and a forward direction (typically the forward vector of a given - individual or keypoint). + and a ``direction`` vector (examples include the forward vector of a + given individual or keypoint). Parameters ---------- - forward_vector : xarray.DataArray + direction : xarray.DataArray Forward vector(s) to use in calculation. position : xarray.DataArray `DataArray` of spatial positions, considered the origin of the - ``forward_vector``. - angle_rotates : Literal["approach to forward", "forward to approach"] + ``direction`` vector. + angle_rotates : {"approach to direction", "direction to approach"} Direction of the signed angle returned. Default is - ``"approach to forward"``. + ``"approach to direction"``. boundary_only : bool Passed to ``compute_approach_vector`` (see Notes). Default ``False``. @@ -556,7 +555,7 @@ def compute_egocentric_angle_to_nearest_point( """ return self._boundary_angle_computation( position=position, - reference_vector=forward_vector, + reference_vector=direction, how_to_compute_vector_to_region=lambda p: self._reassign_space_dim( self.compute_approach_vector( p, boundary_only=boundary_only, unit=False @@ -564,7 +563,7 @@ def compute_egocentric_angle_to_nearest_point( "vector to", ), angle_rotates=angle_rotates.replace("approach", "vec").replace( # type: ignore - "forward", "ref" + "direction", "ref" ), in_degrees=in_degrees, ) diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py index bf91f3943..fabb86f1d 100644 --- a/tests/test_unit/test_roi/test_angles.py +++ b/tests/test_unit/test_roi/test_angles.py @@ -275,7 +275,7 @@ def test_ego_and_allocentric_angle_to_region( if egocentric: which_method = "compute_egocentric_angle_to_nearest_point" - other_vector_name = "forward" + other_vector_name = "direction" else: which_method = "compute_allocentric_angle_to_nearest_point" other_vector_name = "ref" From ce4003ee02250a56317192870bbbb79e21234ab6 Mon Sep 17 00:00:00 2001 From: Will Graham <32364977+willGraham01@users.noreply.github.com> Date: Tue, 25 Feb 2025 14:51:11 +0000 Subject: [PATCH 38/39] Apply suggestions from code review Co-authored-by: Niko Sirmpilatze --- movement/roi/base.py | 7 ++++--- movement/roi/line.py | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/movement/roi/base.py b/movement/roi/base.py index 45b8391bd..4bfe139d7 100644 --- a/movement/roi/base.py +++ b/movement/roi/base.py @@ -521,13 +521,14 @@ def compute_egocentric_angle_to_nearest_point( the experimental/camera setup. The egocentric angle is the signed angle between the approach vector - and a ``direction`` vector (examples include the forward vector of a - given individual or keypoint). + and a ``direction`` vector (examples include the forward vector of + a given individual, or the velocity vector of a given point). Parameters ---------- direction : xarray.DataArray - Forward vector(s) to use in calculation. + An array of vectors representing a given direction, + e.g., the forward vector(s). position : xarray.DataArray `DataArray` of spatial positions, considered the origin of the ``direction`` vector. diff --git a/movement/roi/line.py b/movement/roi/line.py index ce25d1748..a58da83e4 100644 --- a/movement/roi/line.py +++ b/movement/roi/line.py @@ -134,7 +134,8 @@ def compute_angle_to_plane_normal( Parameters ---------- direction : xarray.DataArray - Forward vectors to take angle with. + An array of vectors representing a given direction, + e.g., the forward vector(s). position : xr.DataArray Spatial positions, considered the origin of the ``direction``. angle_rotates : Literal["direction to normal", "normal to direction"] From 57bf48ed631eaaeb94110437a11a485316a095b0 Mon Sep 17 00:00:00 2001 From: willGraham01 <1willgraham@gmail.com> Date: Wed, 26 Feb 2025 08:59:56 +0000 Subject: [PATCH 39/39] Apply suggestions from @sfmig code review --- movement/roi/line.py | 8 +------ tests/test_unit/test_roi/test_angles.py | 23 ++++++++----------- .../test_unit/test_roi/test_nearest_points.py | 1 + 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/movement/roi/line.py b/movement/roi/line.py index a58da83e4..64553fb84 100644 --- a/movement/roi/line.py +++ b/movement/roi/line.py @@ -114,7 +114,7 @@ def normal(self, on_same_side_as: ArrayLike = (0.0, 0.0)) -> np.ndarray: normal *= -1.0 return normal - def compute_angle_to_plane_normal( + def compute_angle_to_normal( self, direction: xr.DataArray, position: xr.DataArray, @@ -125,12 +125,6 @@ def compute_angle_to_plane_normal( ) -> xr.DataArray: """Compute the angle between the normal to the segment and a direction. - This method is identical to - ``compute_egocentric_angle_to_nearest_point``, except that - rather than the angle between the approach vector and ``direction``, - the angle between the normal directed toward the segment and - ``direction`` is returned. - Parameters ---------- direction : xarray.DataArray diff --git a/tests/test_unit/test_roi/test_angles.py b/tests/test_unit/test_roi/test_angles.py index fabb86f1d..3ee41e8a8 100644 --- a/tests/test_unit/test_roi/test_angles.py +++ b/tests/test_unit/test_roi/test_angles.py @@ -17,9 +17,10 @@ def sample_position_array() -> xr.DataArray: The data has time, space, and keypoints dimensions. The keypoints are left, right, midpt (midpoint), and wild. - The midpt is the mean of the left and right keypoints; the wild keypoint - may be anywhere in the plane (it is used to test that the function respects - the origin of the forward vector that the user provides). + The midpt is the mean of the left and right keypoints. + The wild keypoint is a point in the plane distinct from the midpt, and is + used to test that the function respects the origin of the forward vector + that the user provides (even if it is physically non-nonsensical). time 1: left @ (1.25, 0.), right @ (1., -0.25), wild @ (-0.25, -0.25) @@ -258,12 +259,8 @@ def test_ego_and_allocentric_angle_to_region( """Test computation of the egocentric and allocentric angle. Note, we only test functionality explicitly introduced in this method. - Input arguments that are just handed to other functions are not explicitly - tested here. - - Specifically; - - - ``in_degrees``, + Input arguments that are just handed to other functions (i.e., + ``in_degrees``), are not explicitly tested here. The ``angle_rotates`` argument is tested in all cases (signs should be reversed when toggling the argument). @@ -317,7 +314,7 @@ def test_ego_and_allocentric_angle_to_region( @pytest.fixture def points_around_segment() -> xr.DataArray: - """Points around the segment_of_y_equals_x. + """Sample points on either side of the segment y=x. Data has (time, space, keypoints) dimensions, shape (, 2, 2). @@ -355,7 +352,7 @@ def points_around_segment() -> xr.DataArray: ) -def test_angle_to_support_plane( +def test_angle_to_normal( segment_of_y_equals_x: LineOfInterest, points_around_segment: xr.DataArray, ) -> None: @@ -363,7 +360,7 @@ def test_angle_to_support_plane( This method checks two things: - - The angle_to_support_plane returns the correct angle, and + - The compute_angle_to_normal method returns the correct angle, and - The method agrees with the egocentric angle computation, in the cases that the two calculations should return the same value (IE when the approach vector is the normal to the segment). And that the @@ -378,7 +375,7 @@ def test_angle_to_support_plane( fwd_vector = compute_forward_vector(points_around_segment, "left", "right") positions = points_around_segment.mean(dim="keypoints") - angles_to_support = segment_of_y_equals_x.compute_angle_to_plane_normal( + angles_to_support = segment_of_y_equals_x.compute_angle_to_normal( fwd_vector, positions ) xr.testing.assert_allclose(expected_output, angles_to_support) diff --git a/tests/test_unit/test_roi/test_nearest_points.py b/tests/test_unit/test_roi/test_nearest_points.py index 8b4798d5a..35ce28495 100644 --- a/tests/test_unit/test_roi/test_nearest_points.py +++ b/tests/test_unit/test_roi/test_nearest_points.py @@ -11,6 +11,7 @@ @pytest.fixture def sample_target_points() -> dict[str, np.ndarray]: + """Sample 2D trajectory data.""" return xr.DataArray( np.array( [