Source code for movement.roi.base
"""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
[docs]
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)