-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Introduce basic classes for regions of interest (#396)
* Create RoI base skeleton * Write 1D and 2D ROI classes * Write some basic instantiation tests * Pre-commit lint * Fix polygon boundary attributes, return our wrappers instead * CodeCov and SonarQube recommendations * rst != markdown, again, Will * Apply batch suggestions from code review Co-authored-by: Niko Sirmpilatze <[email protected]> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Expose non-base ROI classes via __init__ * shapely is a core dependency now * Address kwargs and boundary naming conventions * Update tests to compute holes too * Fix docstring of holes now disambiguation is complete. Co-authored-by: Niko Sirmpilatze <[email protected]> --------- Co-authored-by: Niko Sirmpilatze <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
- Loading branch information
1 parent
25a3717
commit 4fd9366
Showing
8 changed files
with
519 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
from movement.roi.line import LineOfInterest | ||
from movement.roi.polygon import PolygonOfInterest |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,160 @@ | ||
"""Class for representing 1- or 2-dimensional regions of interest (RoIs).""" | ||
|
||
from __future__ import annotations | ||
|
||
from collections.abc import Sequence | ||
from typing import Literal, TypeAlias | ||
|
||
import shapely | ||
from shapely.coords import CoordinateSequence | ||
|
||
from movement.utils.logging import log_error | ||
|
||
LineLike: TypeAlias = shapely.LinearRing | shapely.LineString | ||
PointLike: TypeAlias = tuple[float, float] | ||
PointLikeList: TypeAlias = Sequence[PointLike] | ||
RegionLike: TypeAlias = shapely.Polygon | ||
SupportedGeometry: TypeAlias = LineLike | RegionLike | ||
|
||
|
||
class BaseRegionOfInterest: | ||
"""Base class for representing 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. | ||
""" | ||
|
||
__default_name: str = "Un-named region" | ||
|
||
_name: str | None | ||
_shapely_geometry: SupportedGeometry | ||
|
||
@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 self.dimensions < 2 | ||
else self.region.exterior.coords | ||
) | ||
|
||
@property | ||
def dimensions(self) -> int: | ||
"""Dimensionality of the region.""" | ||
return 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) -> SupportedGeometry: | ||
"""``shapely.Geometry`` representation of the region.""" | ||
return self._shapely_geometry | ||
|
||
def __init__( | ||
self, | ||
points: PointLikeList, | ||
dimensions: Literal[1, 2] = 2, | ||
closed: bool = False, | ||
holes: Sequence[PointLikeList] | None = None, | ||
name: str | None = None, | ||
) -> None: | ||
"""Initialise a region of interest. | ||
Parameters | ||
---------- | ||
points : Sequence of (x, y) values | ||
Sequence of (x, y) coordinate pairs that will form the region. | ||
dimensions : Literal[1, 2], default 2 | ||
The dimensionality of the region to construct. | ||
'1' creates a sequence of joined line segments, | ||
'2' creates a polygon whose boundary is defined by ``points``. | ||
closed : bool, default False | ||
Whether the line to be created should be closed. That is, whether | ||
the final point should also link to the first point. | ||
Ignored if ``dimensions`` is 2. | ||
holes : sequence of sequences of (x, y) pairs, default None | ||
A sequence of items, where each item will be interpreted like | ||
``points``. These items will be used to construct internal holes | ||
within the region. See the ``holes`` argument to | ||
``shapely.Polygon`` for details. Ignored if ``dimensions`` is 1. | ||
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 | ||
if len(points) < dimensions + 1: | ||
raise log_error( | ||
ValueError, | ||
f"Need at least {dimensions + 1} points to define a " | ||
f"{dimensions}D region (got {len(points)}).", | ||
) | ||
elif dimensions < 1 or dimensions > 2: | ||
raise log_error( | ||
ValueError, | ||
"Only regions of interest of dimension 1 or 2 are supported " | ||
f"(requested {dimensions})", | ||
) | ||
elif dimensions == 1 and len(points) < 3 and closed: | ||
raise log_error( | ||
ValueError, | ||
"Cannot create a loop from a single line segment.", | ||
) | ||
if dimensions == 2: | ||
self._shapely_geometry = shapely.Polygon(shell=points, holes=holes) | ||
else: | ||
self._shapely_geometry = ( | ||
shapely.LinearRing(coordinates=points) | ||
if closed | ||
else shapely.LineString(coordinates=points) | ||
) | ||
|
||
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
"""1-dimensional lines of interest.""" | ||
|
||
from movement.roi.base import BaseRegionOfInterest, PointLikeList | ||
|
||
|
||
class LineOfInterest(BaseRegionOfInterest): | ||
"""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 : tuple of (x, y) pairs | ||
The points (in sequence) that make up the line segment. At least | ||
two points must be provided. | ||
loop : bool, default False | ||
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 : str, optional | ||
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 ``PolygonOfInterest`` and use | ||
its ``boundary`` property instead. | ||
See Also | ||
-------- | ||
movement.roi.base.BaseRegionOfInterest | ||
The base class that constructor arguments are passed to. | ||
""" | ||
super().__init__(points, dimensions=1, closed=loop, name=name) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
"""2-dimensional regions of interest.""" | ||
|
||
from __future__ import annotations | ||
|
||
from collections.abc import Sequence | ||
|
||
from movement.roi.base import BaseRegionOfInterest, PointLikeList | ||
from movement.roi.line import LineOfInterest | ||
|
||
|
||
class PolygonOfInterest(BaseRegionOfInterest): | ||
"""Representation of a two-dimensional region in the x-y plane. | ||
This class can be used to represent polygonal regions or subregions | ||
of the area in which the experimental data was gathered. These might | ||
include the arms of a maze, a nesting area, a food source, or other | ||
similar areas of the experimental enclosure that have some significance. | ||
An instance of this class can be used to represent these regions of | ||
interest (RoIs) 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 exterior boundary of the RoI. Note that the exterior boundary | ||
(accessible as via the ``.exterior`` property) is a (closed) | ||
``LineOfInterest``, and may be treated accordingly. | ||
The class also supports holes - subregions properly contained inside the | ||
region that are not part of the region itself. These can be specified by | ||
the ``holes`` argument, and define the interior boundaries of the region. | ||
These interior boundaries are accessible via the ``.interior_boundaries`` | ||
property, and the polygonal regions that make up the holes are accessible | ||
via the ``holes`` property. | ||
""" | ||
|
||
def __init__( | ||
self, | ||
exterior_boundary: PointLikeList, | ||
holes: Sequence[PointLikeList] | None = None, | ||
name: str | None = None, | ||
) -> None: | ||
"""Create a new region of interest (RoI). | ||
Parameters | ||
---------- | ||
exterior_boundary : tuple of (x, y) pairs | ||
The points (in sequence) that make up the boundary of the region. | ||
At least three points must be provided. | ||
holes : sequence of sequences of (x, y) pairs, default None | ||
A sequence of items, where each item will be interpreted as the | ||
``exterior_boundary`` of an internal hole within the region. See | ||
the ``holes`` argument to ``shapely.Polygon`` for details. | ||
name : str, optional | ||
Name of the RoI that is to be created. A default name will be | ||
inherited from the base class if not provided. | ||
See Also | ||
-------- | ||
movement.roi.base.BaseRegionOfInterest : The base class that | ||
constructor arguments are passed to, and defaults are inherited | ||
from. | ||
""" | ||
super().__init__( | ||
points=exterior_boundary, dimensions=2, holes=holes, name=name | ||
) | ||
|
||
@property | ||
def exterior_boundary(self) -> LineOfInterest: | ||
"""The exterior boundary of this RoI.""" | ||
return LineOfInterest( | ||
self.region.exterior.coords, | ||
loop=True, | ||
name=f"Exterior boundary of {self.name}", | ||
) | ||
|
||
@property | ||
def holes(self) -> tuple[PolygonOfInterest, ...]: | ||
"""The interior holes of this RoI. | ||
Holes are regions properly contained within the exterior boundary of | ||
the RoI that are not part of the RoI itself (like the centre of a | ||
doughnut, for example). A region with no holes returns the empty tuple. | ||
""" | ||
return tuple( | ||
PolygonOfInterest( | ||
int_boundary.coords, name=f"Hole {i} of {self.name}" | ||
) | ||
for i, int_boundary in enumerate(self.region.interiors) | ||
) | ||
|
||
@property | ||
def interior_boundaries(self) -> tuple[LineOfInterest, ...]: | ||
"""The interior boundaries of this RoI. | ||
Interior boundaries are the boundaries of holes contained within the | ||
polygon. A region with no holes returns the empty tuple. | ||
""" | ||
return tuple( | ||
LineOfInterest( | ||
int_boundary.coords, | ||
loop=True, | ||
name=f"Interior boundary {i} of {self.name}", | ||
) | ||
for i, int_boundary in enumerate(self.region.interiors) | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -19,6 +19,7 @@ dependencies = [ | |
"attrs", | ||
"pooch", | ||
"tqdm", | ||
"shapely", | ||
"sleap-io", | ||
"xarray[accel,viz]", | ||
"PyYAML", | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import numpy as np | ||
import pytest | ||
|
||
|
||
@pytest.fixture() | ||
def unit_square_pts() -> np.ndarray: | ||
return np.array( | ||
[ | ||
[0.0, 0.0], | ||
[1.0, 0.0], | ||
[1.0, 1.0], | ||
[0.0, 1.0], | ||
], | ||
dtype=float, | ||
) | ||
|
||
|
||
@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).""" | ||
return 0.25 + (unit_square_pts.copy() * 0.5) |
Oops, something went wrong.