Source code for movement.roi.base

"""Class for representing 1- or 2-dimensional regions of interest (RoIs)."""

from __future__ import annotations

from abc import ABC, abstractmethod
from collections.abc import Callable, Hashable, Sequence
from typing import TYPE_CHECKING, Any, Generic, TypeAlias, TypeVar, cast

import matplotlib.pyplot as plt
import numpy as np
import shapely
from shapely.coords import CoordinateSequence

from movement.utils.broadcasting import broadcastable_method
from movement.utils.vector import compute_signed_angle_2d

if TYPE_CHECKING:
    import xarray as xr
    from matplotlib.axes import Axes
    from matplotlib.figure import Figure, SubFigure
    from numpy.typing import ArrayLike

FloatSequence: TypeAlias = Sequence[float]
PointLikeList: TypeAlias = Sequence[float] | np.ndarray | CoordinateSequence
LineLike: TypeAlias = shapely.LinearRing | shapely.LineString
RegionLike: TypeAlias = shapely.Polygon
SupportedGeometry: TypeAlias = LineLike | RegionLike
TGeometry_co = TypeVar("TGeometry_co", bound=SupportedGeometry, covariant=True)


[docs] class BaseRegionOfInterest(ABC, Generic[TGeometry_co]): """Abstract base class for regions of interest (RoIs). This class cannot be instantiated directly. Instead, use one of its subclasses, such as :class:`LineOfInterest<movement.roi.LineOfInterest>` or :class:`PolygonOfInterest<movement.roi.PolygonOfInterest>`. Regions of interest can be either 1 or 2 dimensional, and are represented 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. 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" _name: str | None _shapely_geometry: TGeometry_co @property def _default_plot_args(self) -> dict[str, Any]: """Define default plotting arguments used when drawing the region. This argument is used inside ``self.plot``, which is implemented in the base class. This is implemented as a property for two reasons; - To ensure that the defaults can be set in a single place within the class definition, - To allow for easy overwriting in subclasses, which will be necessary given lines and polygons must be plotted differently, - In future, allows us to customise the defaults on a per-region basis (e.g., default labels can inherit ``self.name``). """ kwargs = {} if self.name: kwargs["label"] = self.name return kwargs @property def coords(self) -> CoordinateSequence: """Coordinates of the points that define the region. These are the points passed to the constructor argument ``points``. Note that for Polygonal regions, these are the coordinates of the exterior boundary, interior boundaries must be accessed via ``self.region.interior.coords``. """ return ( self.region.coords if isinstance(self.region, LineLike) else self.region.exterior.coords ) @property def dimensions(self) -> int: """Dimensionality of the region.""" return int(shapely.get_dimensions(self.region)) @property def is_closed(self) -> bool: """Return True if the region is closed. A closed region is either: - A polygon (2D RoI). - A 1D LoI whose final point connects back to its first. """ return self.dimensions > 1 or ( self.dimensions == 1 and self.region.coords[0] == self.region.coords[-1] ) @property def name(self) -> str: """Name of the instance.""" return self._name if self._name else self.__default_name @property def region(self) -> TGeometry_co: """``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 ], 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_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". how_to_compute_vector_to_region : Callable How to compute the "vector to the region" from ``position``. in_degrees : bool If ``True``, angles are returned in degrees. Otherwise angles are returned in radians. Default ``False``. """ vec_to_segment = how_to_compute_vector_to_region(position) angles = compute_signed_angle_2d(vec_to_segment, reference_vector) if in_degrees: angles = cast("xr.DataArray", np.rad2deg(angles)) return angles @staticmethod def _reassign_space_dim( da: xr.DataArray, old_dimension: Hashable, ) -> xr.DataArray: """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 else ["x", "y", "z"] } ) def __init__( self, geometry: TGeometry_co, name: str | None = None, ) -> None: """Initialise a region of interest. Parameters ---------- geometry : shapely.Geometry The ``shapely`` geometry that defines the region of interest. name : str, default None Human-readable name to assign to the given region, for user-friendliness. Default name given is 'Un-named region' if no explicit name is provided. """ self._name = name self._shapely_geometry = geometry def __repr__(self) -> str: # noqa: D105 return str(self) def __str__(self) -> str: # noqa: D105 display_type = "-gon" if self.dimensions > 1 else " line segment(s)" n_points = len(self.coords) - 1 return ( f"{self.__class__.__name__} {self.name} " f"({n_points}{display_type})\n" ) + " -> ".join(f"({c[0]}, {c[1]})" for c in self.coords) @abstractmethod def _plot( self, fig: Figure | SubFigure, ax: Axes, **matplotlib_kwargs ) -> tuple[Figure | SubFigure, Axes]: """Plot the region. Must be implemented in subclasses."""
[docs] @broadcastable_method(only_broadcastable_along="space") def contains_point( self, position: ArrayLike, /, include_boundary: bool = True, ) -> bool: """Determine if a position is inside the region of interest. Parameters ---------- position : ArrayLike Spatial coordinates [x, y, [z]] to check as being inside the region. include_boundary : bool Whether to treat a position on the region's boundary as inside the region (True) or outside the region (False). Default True. Returns ------- bool True if the ``position`` provided is within the region of interest. False otherwise. """ point = shapely.Point(cast("FloatSequence", position)) region = self.region return ( region.covers(point) if include_boundary else region.contains(point) )
[docs] @broadcastable_method(only_broadcastable_along="space") def compute_distance_to( self, point: ArrayLike, boundary_only: 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_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 ------- float Euclidean distance from the ``point`` to the (closest point on the) region. """ region_to_consider = ( self.region.boundary if boundary_only else self.region ) return shapely.distance( region_to_consider, shapely.Point(cast("FloatSequence", point)) )
[docs] @broadcastable_method( only_broadcastable_along="space", new_dimension_name="nearest point" ) def compute_nearest_point_to( self, position: ArrayLike, /, boundary_only: bool = False ) -> 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. boundary_only : bool, optional If ``True``, compute the nearest point to ``position`` that is on the boundary of ``self``. Default ``False``. Returns ------- np.ndarray Coordinates of the point on ``self`` that is closest to ``position``. """ 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( region_to_consider, shapely.Point(cast("FloatSequence", position)), ).coords[0] )
[docs] @broadcastable_method( only_broadcastable_along="space", new_dimension_name="vector to" ) def compute_approach_vector( self, point: ArrayLike, boundary_only: bool = False, 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. Parameters ---------- point : ArrayLike Coordinates of a point to compute the vector to (or from) the region. 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 ``False``. Returns ------- 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 ) # "point to region" by virtue of order of arguments to shapely call directed_line = shapely.shortest_line( shapely.Point(cast("FloatSequence", point)), region_to_consider ) approach_vector = np.array(directed_line.coords[1]) - np.array( directed_line.coords[0] ) if unit: norm = np.sqrt(np.sum(approach_vector**2)) # Cannot normalise the 0 vector if not np.isclose(norm, 0.0): approach_vector /= norm return approach_vector
[docs] def compute_allocentric_angle_to_nearest_point( self, position: xr.DataArray, boundary_only: bool = False, in_degrees: bool = False, reference_vector: np.ndarray | xr.DataArray | None = None, ) -> xr.DataArray: """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\ <movement.utils.vector.compute_signed_angle_2d>` between the approach vector and a given reference vector. Parameters ---------- position : xarray.DataArray ``DataArray`` of spatial positions. boundary_only : bool 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 radians. Default ``False``. reference_vector : np.ndarray or xarray.DataArray or None 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.)``. 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. 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: reference_vector = np.array([[1.0, 0.0]]) 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", ), in_degrees=in_degrees, )
[docs] def compute_egocentric_angle_to_nearest_point( self, direction: xr.DataArray, position: xr.DataArray, boundary_only: bool = False, in_degrees: bool = False, ) -> xr.DataArray: """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. 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 the velocity vector of a given point). Parameters ---------- direction : xarray.DataArray 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. boundary_only : bool Passed to :func:`compute_approach_vector` (see Notes). Default ``False``. in_degrees : bool If ``True``, angles are returned in degrees. Otherwise angles are returned in radians. Default ``False``. 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. 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( position=position, 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 ), "vector to", ), in_degrees=in_degrees, )
[docs] def plot( self, ax: Axes | None = None, **matplotlib_kwargs ) -> tuple[Figure | SubFigure, Axes]: """Plot the region of interest on a new or existing axis. Parameters ---------- ax : matplotlib.axes.Axes or None, optional Axes object on which to draw the region. If None, a new figure and axes are created. matplotlib_kwargs : Any Keyword arguments passed to the :mod:`matplotlib.pyplot` plotting function. Returns ------- fig : matplotlib.figure.Figure or matplotlib.figure.SubFigure If ``ax`` is provided, this is ``ax.figure`` (:class:`matplotlib.figure.Figure` or :class:`matplotlib.figure.SubFigure`). Otherwise, a new :class:`matplotlib.figure.Figure` is created and returned. ax : matplotlib.axes.Axes Axes on which the region was drawn. If ``ax`` is provided, the input will be directly modified and returned in this value. """ for arg, default in self._default_plot_args.items(): if arg not in matplotlib_kwargs: matplotlib_kwargs[arg] = default fig = ax.get_figure() if ax is not None else None if fig is None or ax is None: fig, ax = plt.subplots(1, 1) return self._plot(fig, ax, **matplotlib_kwargs)