Source code for oowl.boundaries.hull
"""Hull boundaries of random point clouds."""
from __future__ import annotations
import math
import numpy as np
import shapely
from shapely.geometry import MultiPoint, Polygon
from shapely.geometry.base import BaseGeometry
from .._random import as_rng
from .._validation import _validate_integer, _validate_real
from .base import Boundary, Point2D, _optional_area, _resolve_center
__all__ = ["ConvexHull"]
def _validate_concavity(concavity: float | None) -> float | None:
"""Return a usable ``concave_hull`` ratio, or ``None`` for a convex hull."""
if concavity is None:
return None
value = _validate_real("concavity", concavity)
if not math.isfinite(value):
raise ValueError("concavity must be a finite number in (0, 1], or None")
if not 0.0 < value <= 1.0:
raise ValueError(f"concavity must be in (0, 1], or None; got {value}")
return value
[docs]
class ConvexHull(Boundary):
"""Boundary as the hull of random scattered points, convex or concave.
The default ``concavity=None`` gives the exact convex hull. A value in
``(0, 1]`` is Shapely's ``concave_hull`` ratio. Smaller values wrap the
points more tightly and ``1.0`` reproduces the convex hull. The shape is
always scaled to ``area``.
Parameters
----------
area:
Realized enclosed area in square meters, ``None`` for a unit-size
shape to size later.
n_points:
Number of scattered points to draw, at least 3. A concave hull needs a
dense cloud.
center:
Centroid of the realized boundary.
concavity:
``None`` for a convex hull, or a ratio in ``(0, 1]`` for a concave one.
seed:
Seed or generator for the scattered points, drawn from a standard
normal distribution before the hull is moved to ``center``.
Raises
------
ValueError
If ``concavity`` is outside ``(0, 1]`` or not finite, or if the point
set admits no valid hull with positive area at that ratio.
"""
def __init__(
self,
*,
area: float | None = None,
n_points: int = 20,
center: Point2D | None = None,
concavity: float | None = None,
seed: int | np.random.Generator | None = None,
):
self.n_points = _validate_integer("n_points", n_points, minimum=3)
self.target_area = _optional_area(area)
self.center, self._positioned = _resolve_center(center)
self.concavity = _validate_concavity(concavity)
self.seed = self._resolve_own_seed(seed)
def _build_geometry(self) -> BaseGeometry:
rng = as_rng(self.seed)
pts = rng.standard_normal((self.n_points, 2))
cloud = MultiPoint(pts)
if self.concavity is None:
hull = cloud.convex_hull
if not isinstance(hull, Polygon):
raise ValueError(
"convex hull did not produce a polygon; add more points"
)
return hull
# Reject degenerate concave hulls instead of falling back to convex.
hull = shapely.concave_hull(cloud, ratio=self.concavity)
if not isinstance(hull, Polygon) or hull.is_empty or hull.area <= 0.0:
raise ValueError(
f"concave hull at concavity={self.concavity} is degenerate; "
"raise concavity or add more points"
)
if not hull.is_valid:
raise ValueError(
f"concave hull at concavity={self.concavity} is not a valid "
"polygon; raise concavity or add more points"
)
return hull