diff --git a/alpha_shapes/alpha_shapes.py b/alpha_shapes/alpha_shapes.py index b906d75..c691cf8 100644 --- a/alpha_shapes/alpha_shapes.py +++ b/alpha_shapes/alpha_shapes.py @@ -1,7 +1,9 @@ """ -Utility module for the calculation of alpha shapes +This is a core module of package which contains exceptions, functions +and classes essential to creating and working with figures. """ -from typing import Tuple + +from typing import Tuple, Union import numpy as np from matplotlib.tri import Triangulation @@ -11,28 +13,42 @@ class AlphaException(Exception): + """Abstract class for exceptions which could be raised during the work of Alpha_Shaper class.""" pass class NotEnoughPoints(AlphaException): + """Raised when an operation requires a certain number of points and that condition is not met.""" pass class OptimizationFailure(AlphaException): + """Raised when the conditions for optimization are not met.""" pass -class OptimizationWarnging(UserWarning): +class OptimizationWarning(UserWarning): + """Warns user without interrupting the program.""" pass class Delaunay(Triangulation): - """ + """Abstract class with useful interface. Visitor sublclass of matplotlib.tri.Triangulation. - Mimics scipy.spatial.Delaunay interface. + See similar idea on scipy.spatial.Delaunay solution. """ - def __init__(self, coords: NDArray): + def __init__(self, coords: NDArray) -> None: + """Set the interface object and pass the coords into it. + + Args: + coords(NDArray): raw coords at which preparation process will be performed. + + Raises: + - NotEnoughPoints: If there are fewer than 3 points provided. + - ValueError: For other value-related issues with the coordinates. + """ + try: super().__init__(x=coords[:, 0], y=coords[:, 1]) except ValueError as e: @@ -42,17 +58,31 @@ def __init__(self, coords: NDArray): raise @property - def simplices(self): + def simplices(self) -> NDArray: + """Return the collection of triangles.""" return self.triangles - def __len__(self): + def __len__(self) -> int: + """Return amount of object's simplices.""" return self.simplices.shape[0] class Alpha_Shaper(Delaunay): + """The class enables the creation of shapes and further operations on them.""" + mask: NDArray # for type hinting - def __init__(self, points: ArrayLike, normalize=True): + def __init__(self, points: ArrayLike, normalize=True) -> None: + """Pass points into shaper. Optionally perform normalization. + + Args: + points(ArrayLike): points used to create shapes. + + normalize(bool): The flag determines whether the normalization process will be carried out. + See more about normalization on https://github.com/panosz/alpha_shapes. + + """ + self.normalized = normalize points = np.array(points) @@ -65,10 +95,14 @@ def __init__(self, points: ArrayLike, normalize=True): else: self._initialize(points) - def _initialize(self, points: NDArray): - """ - _initialize the alpha shaper. + def _initialize(self, points: NDArray) -> None: + """_initialize the alpha shaper. + + Args: + points(NDArray): points at which normalization will be performed. + """ + super().__init__(points) self.circumradii_sq = self._calculate_cirumradii_sq_of_internal_triangles() @@ -76,37 +110,52 @@ def _initialize(self, points: NDArray): default_mask = np.full_like(self.circumradii_sq, False, dtype=bool) self.set_mask(default_mask) - def _denormalize(self, center, scale): + def _denormalize(self, center: NDArray, scale: NDArray) -> None: + """Transform back points into their original scale.""" self.x = self.x * scale[0] + center[0] self.y = self.y * scale[1] + center[1] - def _calculate_cirumradii_sq_of_internal_triangles(self): + def _calculate_cirumradii_sq_of_internal_triangles(self) -> NDArray: + """Method calculates circumradiuses squares of all internal triangles.""" + circumradii_sq = [ self._get_circumradius_sq_of_internal_simplex(smpl) for smpl in self.simplices ] return np.array(circumradii_sq) - def _get_circumradius_sq_of_internal_simplex(self, smpl): + def _get_circumradius_sq_of_internal_simplex(self, smpl: slice) -> NDArray: + """Read value of squared circumradius of internal triangle.""" x = self.x[smpl] y = self.y[smpl] return _calculate_cirumradius_sq_of_triangle(x, y) - def _sorted_simplices(self): + def _sorted_simplices(self) -> NDArray[np.float64]: + """Return the collection of simplices, sorted by their circumradius.""" return self.simplices[self.argsort] def _sorted_circumradii_sw(self) -> NDArray[np.float64]: + """Return sorted values of squares circumradiuses of internal triangles.""" return self.circumradii_sq[self.argsort] - def _shape_from_simplices(self, simplices): + def _shape_from_simplices(self, simplices: ArrayLike) -> ArrayLike: + """Return the shape from simplices. + Output is in unary_union form which makes further operations on a shape easier. """ + triangles = [_simplex_to_triangle(smpl, self) for smpl in simplices] return unary_union(triangles) - def get_mask(self, alpha): + def get_mask(self, alpha: float) -> NDArray: + """Return mask, based on squares of circumradiuses of internal triangles. + Mask specifies which elements should be considered for triangulation.""" return self.circumradii_sq > 1 / alpha**2 - def get_shape(self, alpha): + def get_shape(self, alpha: float) -> ArrayLike: + """Return shape, based on the given alpha. + If alpha is less or equal to 0, the shape creation will be based on external points. + """ + if alpha > 0: select = self.circumradii_sq <= 1 / alpha**2 simplices = self.simplices[select] @@ -115,29 +164,30 @@ def get_shape(self, alpha): return self._shape_from_simplices(simplices) - def _nth_shape(self, n): - """ - return the shape formed by the n smallest simplices - """ + def _nth_shape(self, n: int) -> ArrayLike: + """Return the shape formed by the amount of n-smallest simplices.""" simplices = self._sorted_simplices()[:n] return self._shape_from_simplices(simplices) - def all_vertices(self): + def all_vertices(self) -> set: + """Return all vertices of object.""" return set(np.ravel(self.simplices)) - def _uncovered_vertices(self, simplices): - """ - Return a set of vertices that is not covered by the - specified simplices. - """ + def _uncovered_vertices(self, simplices: ArrayLike) -> set: + """Return a set of vertices, which is not covered by the specified simplices.""" return self.all_vertices() - set(np.ravel(simplices)) def _get_minimum_fully_covering_index_of_simplices(self) -> int: - """ - Return the minimum index of simplices needed to cover all vertices. + """Return the minimum index of simplices needed to cover all vertices. The set of all simplices up to this index is fully covering. + + Raises: + - OptimizationFailure: For issues when the conditions for optimization are not met. + A common issue is duplicate points in the dataset. + """ - # At least N//3 triangles are needed to connect N points. + + # We have to use at least N//3 triangles to connect N points. simplices = self._sorted_simplices() n_start = len(self) // 3 n_finish = len(self) @@ -154,8 +204,17 @@ def _get_minimum_fully_covering_index_of_simplices(self) -> int: raise OptimizationFailure("Maybe there are duplicate points?") - def optimize(self): - # At least N//3 triangles are needed to connect N points. + def optimize(self) -> Tuple[NDArray, ArrayLike]: + """Return the alpha value that allows plotting the shape with the minimum number of triangles. + Vertices of initial triangulation aren't left uncovered. + + Returns: + alpha_opt(NDArray): optimized alpha value. + shape(ArrayLike): shape based on the optimized alpha value. + + """ + + # We have to use at least N//3 triangles to connect N points n_min = self._get_minimum_fully_covering_index_of_simplices() alpha_opt = 1 / np.sqrt(self._sorted_circumradii_sw()[n_min]) - 1e-10 simplices = self._sorted_simplices() @@ -164,34 +223,31 @@ def optimize(self): return alpha_opt, shape def set_mask_at_alpha(self, alpha: float): - """ - Set the mask for the alpha shape at the specified alpha value. - """ + """Set the mask for the alpha shape based on the given alpha value.""" mask = self.get_mask(alpha) self.set_mask(mask) return self def _normalize_points(points: NDArray) -> Tuple[NDArray, NDArray, NDArray]: - """ - Normalize points to the unit square, centered at the origin. + """Normalize points to the unit square, centered at the origin. - Parameters: - ----------- + Args: points: array-like, shape(N,2) coordinates of the points Returns: - -------- - points: array, shape(N,2) - normalized coordinates of the points + points: array, shape(N,2) + normalized coordinates of the points + + center: array, shape(2,) + coordinates of the center of the points - center: array, shape(2,) - coordinates of the center of the points + scale: array, shape(2,) + scale factors for the normalization - scale: array, shape(2,) - scale factors for the normalization """ + center = points.mean(axis=0) scale = np.ptp(points, axis=0) # peak to peak distance normalized_points = (points - center) / scale @@ -199,13 +255,18 @@ def _normalize_points(points: NDArray) -> Tuple[NDArray, NDArray, NDArray]: return normalized_points, center, scale -def _circumradius_sq(lengths): - r""" - Calculate the squared circumradius `r_c^2`, - where - r_c = \frac {abc}{4{\sqrt {s(s-a)(s-b)(s-c)}}} - See: `https://en.wikipedia.org/wiki/Circumscribed_circle` +def _circumradius_sq(lengths: NDArray) -> Union[float, np.inf]: + """Calculate the squared circumradius `r_c^2` where r_c = \frac {abc}{4{\sqrt {s(s-a)(s-b)(s-c)}}}. + See more about it on: `https://en.wikipedia.org/wiki/Circumscribed_circle`. + + Args: + lengths(NDArray): contains lengths of triangle's sides. + + Returns: + Union[float, np.inf]: value of squared circumradius. + """ + lengths = np.asarray(lengths) s = np.sum(lengths) / 2 @@ -219,15 +280,18 @@ def _circumradius_sq(lengths): return num / denom -def _calculate_cirumradius_sq_of_triangle(x: ArrayLike, y: ArrayLike): - """ - calculates the squared circumradius of a triangle with coordinates x, y +def _calculate_cirumradius_sq_of_triangle(x: ArrayLike, y: ArrayLike) -> NDArray: + """Calculate the squared circumradius of a triangle with coordinates x, y. - Parameters: - ----------- - x, y: array-like, shape(3,) + Args: + x, y: array-like, shape(3,) coordinates of the triangle + + Returns: + NDArray: squared circumradius of internal triangle. + """ + dx = x - np.roll(x, shift=-1) dy = y - np.roll(y, shift=-1) @@ -235,7 +299,18 @@ def _calculate_cirumradius_sq_of_triangle(x: ArrayLike, y: ArrayLike): return _circumradius_sq(lengths) -def _simplex_to_triangle(smpl, tri): +def _simplex_to_triangle(smpl: slice, tri) -> Polygon: + """Return triangle points. + + Args: + smpl(slice): value of simplex. + tri: particular triangle. + + Returns: + Polygon: contains points values of triangle. + + """ + x = tri.x[smpl] y = tri.y[smpl] diff --git a/alpha_shapes/plotting.py b/alpha_shapes/plotting.py index 9f9698d..e158365 100644 --- a/alpha_shapes/plotting.py +++ b/alpha_shapes/plotting.py @@ -1,9 +1,21 @@ +"""This module contains mechanisms essential to printing figures. +Functions make necessary operations on points sets and plot appropriate shapes. +""" + import numpy as np from matplotlib.path import Path from matplotlib.patches import PathPatch def plot_alpha_shape(ax, alpha_shape): + """Plot final set of figure's points. + + Args: + ax(Axes.matplotlib.subplots): axes of image. + alpha_shape(numpy.ArrayLike): set of points to print. + + """ + try: geoms = alpha_shape.geoms except AttributeError: @@ -14,10 +26,15 @@ def plot_alpha_shape(ax, alpha_shape): def _plot_polygon(ax, polygon): + """Plot a polygon using matplotlib's PathPatch. + This thread on stackoverflow may be helpful https://stackoverflow.com/a/70533052/6060982. + + Args: + ax(Axes.matplotlib.subplots): axes of image. + polygon(shapely.geometry.Polygon): set of points to print. + """ - Plot a polygon using matplotlib's PathPatch. - see https://stackoverflow.com/a/70533052/6060982 - """ + xe, ye = polygon.exterior.xy exterior = Path(np.column_stack([xe, ye])) holes = [Path(np.asarray(hole.coords)) for hole in polygon.interiors]