Source code for movement.roi.line

"""1-dimensional lines of interest."""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np
import shapely

from movement.roi.base import BaseRegionOfInterest, LineLike, PointLikeList
from movement.utils.broadcasting import broadcastable_method
from movement.utils.logging import logger

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


[docs] class LineOfInterest(BaseRegionOfInterest[LineLike]): """Representation of boundaries or other lines of interest. This class can be used to represent boundaries or other internal divisions of the area in which the experimental data was gathered. These might include segments of a wall that are removed partway through a behavioural study, or coloured marking on the floor of the experimental enclosure that have some significance. Instances of this class also constitute the boundary of two-dimensional regions (polygons) of interest. An instance of this class can be used to represent these "one dimensional regions" (lines of interest, LoIs) in an analysis. The basic usage is to construct an instance of this class by passing in a list of points, which will then be joined (in sequence) by straight lines between consecutive pairs of points, to form the LoI that is to be studied. """ def __init__( self, points: PointLikeList, loop: bool = False, name: str | None = None, ) -> None: """Create a new line of interest (LoI). Parameters ---------- points The points (in sequence) that make up the line segment. At least two points must be provided. loop If True, the final point in ``points`` will be connected by an additional line segment to the first, creating a closed loop. (See Notes). name Name of the LoI that is to be created. A default name will be inherited from the base class if not provided, and defaults are inherited from. Notes ----- The constructor supports 'rings' or 'closed loops' via the ``loop`` argument. However, if you want to define an enclosed region for your analysis, we recommend you create a :class:`PolygonOfInterest<movement.roi.PolygonOfInterest>` and use its ``exterior_boundary`` property instead. See Also -------- movement.roi.BaseRegionOfInterest The base class that constructor arguments are passed to. """ if len(points) < 2: raise logger.error( ValueError( f"Need at least 2 points to define a 1D region " f"(got {len(points)})." ) ) if len(points) < 3 and loop: raise logger.error( ValueError("Cannot create a loop from a single line segment.") ) line: LineLike = ( shapely.LinearRing(coordinates=points) if loop else shapely.LineString(coordinates=points) ) line = shapely.normalize(line) super().__init__(line, name=name) def _plot( self, fig: Figure | SubFigure, ax: Axes, **matplotlib_kwargs ) -> tuple[Figure | SubFigure, Axes]: """LinesOfInterest can simply be plotted as lines.""" ax.plot( [c[0] for c in self.coords], [c[1] for c in self.coords], **matplotlib_kwargs, ) return fig, ax
[docs] @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. 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, 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. If this is a multi-segment line, the method raises an error. Parameters ---------- on_same_side_as 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. Returns ------- numpy.ndarray Unit normal vector(s) perpendicular to the line segment, pointing toward the half-plane containing the sample point ``on_same_side_as``. Raises ------ 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." ) on_same_side_as = np.array(on_same_side_as) parallel_to_line = np.array(self.region.coords[1]) - np.array( self.region.coords[0] ) normal = np.array([parallel_to_line[1], -parallel_to_line[0]]) normal /= np.sqrt(np.sum(normal**2)) if np.dot((on_same_side_as - self.region.coords[0]), normal) < 0: normal *= -1.0 return normal
[docs] def compute_angle_to_normal( self, direction: xr.DataArray, position: xr.DataArray, in_degrees: bool = False, ) -> xr.DataArray: """Compute the angle between the normal to the segment and a direction. The returned angle is the signed angle between the normal to the segment and the ``direction`` vector(s) provided. Parameters ---------- direction An array of vectors representing a given direction, e.g., the forward vector(s). position Spatial positions, considered the origin of the ``direction``. in_degrees If ``True``, angles are returned in degrees. Otherwise angles are returned in radians. Default ``False``. Returns ------- xarray.DataArray The signed angle between the segment normal and the provided direction vector(s). Returned in radians unless ``in_degrees=True``. See Also -------- movement.utils.vector.compute_signed_angle_2d : For the definition of the signed angle between two vectors. """ return self._boundary_angle_computation( position=position, reference_vector=direction, how_to_compute_vector_to_region=lambda p: self._reassign_space_dim( -1.0 * self.normal(p), "normal" ), in_degrees=in_degrees, )