Skip to content

Commit

Permalink
Introduce basic classes for regions of interest (#396)
Browse files Browse the repository at this point in the history
* 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
3 people authored Feb 4, 2025
1 parent 25a3717 commit 4fd9366
Show file tree
Hide file tree
Showing 8 changed files with 519 additions and 0 deletions.
2 changes: 2 additions & 0 deletions movement/roi/__init__.py
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
160 changes: 160 additions & 0 deletions movement/roi/base.py
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)
58 changes: 58 additions & 0 deletions movement/roi/line.py
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)
105 changes: 105 additions & 0 deletions movement/roi/polygon.py
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)
)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dependencies = [
"attrs",
"pooch",
"tqdm",
"shapely",
"sleap-io",
"xarray[accel,viz]",
"PyYAML",
Expand Down
21 changes: 21 additions & 0 deletions tests/test_unit/test_roi/conftest.py
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)
Loading

0 comments on commit 4fd9366

Please sign in to comment.