Source code for oowl.boundaries.radial

"""Star-shaped boundaries described by a radius at each angle."""

from __future__ import annotations

from typing import Sequence

import numpy as np
from shapely.geometry import Polygon
from shapely.geometry.base import BaseGeometry

from .._random import as_rng
from .._validation import (
    _validate_integer,
    _validate_nonnegative,
    _validate_real,
    _validate_real_array,
)
from .base import Boundary, Point2D, _optional_area, _resolve_center

__all__ = ["RadialPolygon", "RandomRadialPolygon", "SmoothBlob"]


[docs] class RadialPolygon(Boundary): """Star-shaped polygon given by the radius and angular step of each vertex. Vertex ``i`` of the generating shape lies at distance ``radii[i]`` from its origin and at the cumulative sum of ``angular_offsets`` up to ``i``, in degrees. The offsets are positive and default to equal steps. ``radii`` set only the shape, since ``area`` sets the size, and ``center`` places its centroid, which need not be that origin. """ def __init__( self, radii: Sequence[float], angular_offsets: Sequence[float] | None = None, *, area: float | None = None, center: Point2D | None = None, ): radius_array = _validate_real_array("radii", radii) if radius_array.ndim != 1 or radius_array.size < 3: raise ValueError("radii must be a one-dimensional sequence of length >= 3") if not np.all(np.isfinite(radius_array)) or np.any(radius_array <= 0): raise ValueError("radii must be finite and > 0") if angular_offsets is None: offsets = np.full(radius_array.size, 360.0 / radius_array.size) else: offsets = _validate_real_array("angular_offsets", angular_offsets) if offsets.shape != radius_array.shape: raise ValueError("angular_offsets must contain one value per radius") if not np.all(np.isfinite(offsets)) or np.any(offsets <= 0): raise ValueError("angular_offsets must be finite and > 0") self.radii = radius_array self.angular_offsets = offsets self.target_area = _optional_area(area) self.center, self._positioned = _resolve_center(center) def _build_geometry(self) -> BaseGeometry: angles = np.deg2rad(np.cumsum(self.angular_offsets)) x = self.radii * np.cos(angles) y = self.radii * np.sin(angles) return Polygon(np.column_stack([x, y]))
[docs] class RandomRadialPolygon(Boundary): """Random star-shaped polygon jittered from a regular ``n_vertices``-gon. Each unit radius is perturbed uniformly by up to ``radius_jitter`` and each equal angular step by up to the fraction ``angular_jitter``, before the steps are rescaled to a full turn. Radii stay positive. """ def __init__( self, n_vertices: int = 8, *, area: float | None = None, radius_jitter: float = 0.35, angular_jitter: float = 0.5, center: Point2D | None = None, seed: int | np.random.Generator | None = None, ): self.n_vertices = _validate_integer("n_vertices", n_vertices, minimum=3) self.target_area = _optional_area(area) self.radius_jitter = _validate_nonnegative("radius_jitter", radius_jitter) angular_jitter = _validate_real("angular_jitter", angular_jitter) if not np.isfinite(angular_jitter) or not 0 <= angular_jitter < 1: raise ValueError( "angular_jitter must be finite and satisfy 0 <= angular_jitter < 1" ) self.angular_jitter = angular_jitter self.center, self._positioned = _resolve_center(center) self.seed = self._resolve_own_seed(seed) def _build_geometry(self) -> BaseGeometry: rng = as_rng(self.seed) n = self.n_vertices base_step = 2.0 * np.pi / n steps = base_step * (1.0 + self.angular_jitter * (2.0 * rng.random(n) - 1.0)) steps = steps / steps.sum() * 2.0 * np.pi radii = 1.0 + self.radius_jitter * (2.0 * rng.random(n) - 1.0) radii = np.clip(radii, 1e-6, None) angles = np.cumsum(steps) x = radii * np.cos(angles) y = radii * np.sin(angles) return Polygon(np.column_stack([x, y]))
[docs] class SmoothBlob(Boundary): r"""Random smooth outline from a low-frequency Fourier radius function. The radius at ``n_segments`` equally spaced angles is .. math:: r(\theta) = 1 + \sum_{k=1}^{K} \frac{\rho\,\xi_k}{k}\sin(k\theta + \phi_k) with ``K = n_harmonics``, ``roughness`` :math:`\rho`, standard-normal :math:`\xi_k` and uniform phases :math:`\phi_k`. The radius is clipped at 0.1 to keep it positive. """ def __init__( self, *, area: float | None = None, n_segments: int = 200, roughness: float = 0.3, n_harmonics: int = 5, center: Point2D | None = None, seed: int | np.random.Generator | None = None, ): self.target_area = _optional_area(area) self.n_segments = _validate_integer("n_segments", n_segments, minimum=4) self.roughness = _validate_nonnegative("roughness", roughness) self.n_harmonics = _validate_integer("n_harmonics", n_harmonics) self.center, self._positioned = _resolve_center(center) self.seed = self._resolve_own_seed(seed) def _build_geometry(self) -> BaseGeometry: rng = as_rng(self.seed) theta = np.linspace(0.0, 2.0 * np.pi, self.n_segments, endpoint=False) r = np.ones_like(theta) for k in range(1, self.n_harmonics + 1): amp = self.roughness * rng.normal() / k phase = rng.uniform(0.0, 2.0 * np.pi) r += amp * np.sin(k * theta + phase) r = np.clip(r, 0.1, None) x = r * np.cos(theta) y = r * np.sin(theta) return Polygon(np.column_stack([x, y]))