"""Boundaries sampled from a fitted Fourier descriptor model.
The model is a joint PCA Gaussian over the Fourier descriptors of canonicalized
polygons plus a shifted log-normal over their vertex counts. A model fitted to
EMODnet offshore wind-farm polygons ships with the package. Fitting is outside
the package, and models in the older per-frequency format remain loadable.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from functools import cache
from pathlib import Path
import numpy as np
from shapely.geometry import Polygon
from shapely.geometry.base import BaseGeometry
from .._random import as_rng
from .._validation import _validate_finite, _validate_integer, _validate_nonnegative
from .base import (
Boundary,
Point2D,
_optional_area,
_oriented,
_resolve_center,
)
__all__ = ["FourierPolygon"]
_DEFAULT_MODEL_PATH = Path(__file__).parents[1] / "data" / "shape_model.npz"
@dataclass(repr=False)
class _ShapeModel:
"""Fitted generative model consumed by :class:`FourierPolygon`.
Holds a joint PCA Gaussian over Fourier descriptors (``Z_mean``,
``Z_components``, ``score_chol``) and a shifted log-normal over vertex
counts (the ``v_*`` scalars). Older models store per-frequency factors in
``Z_chol`` instead. ``K`` equals ``Z_mean.shape[0]`` and is not stored.
"""
Z_mean: np.ndarray # (K,) complex per-bin mean of Fourier coefficients
Z_chol: np.ndarray | None # legacy per-bin (Re, Im) covariance factor
K: int # Fourier truncation length (equals Z_mean.shape[0])
v_shift: float # rigid shift, log-normal is fit on log(v - v_shift)
v_log_mu: float # mean of log(v - v_shift) over the trimmed population
v_log_sigma: float # std of log(v - v_shift) over the trimmed population
v_min: int # inclusive lower bound on sampled vertex counts
v_max: int # inclusive upper bound on sampled vertex counts
Z_components: np.ndarray | None = None # (P, K) complex joint PCA loadings
score_chol: np.ndarray | None = None # (P, P) PCA-score covariance factor
metadata: dict | None = None
def __repr__(self) -> str:
# The arrays run to thousands of numbers, too many to print.
return "<_ShapeModel>"
def save(self, path: str | Path) -> Path:
"""Write the model to ``path`` as an ``.npz`` archive and return its path."""
path = Path(path).with_suffix(".npz")
arrays: dict[str, np.ndarray] = {
"schema_version": np.asarray(2 if self.Z_components is not None else 1),
"Z_mean": self.Z_mean,
"v_shift": np.asarray(self.v_shift),
"v_log_mu": np.asarray(self.v_log_mu),
"v_log_sigma": np.asarray(self.v_log_sigma),
"v_min": np.asarray(self.v_min),
"v_max": np.asarray(self.v_max),
"metadata_json": np.asarray(
json.dumps(self.metadata or {}, sort_keys=True)
),
}
if self.Z_components is not None:
assert self.score_chol is not None
arrays["Z_components"] = self.Z_components
arrays["score_chol"] = self.score_chol
else:
assert self.Z_chol is not None
arrays["Z_chol"] = self.Z_chol
# NumPy typing workaround. All archive values are arrays.
np.savez(path, **arrays) # ty: ignore[invalid-argument-type]
return path
@classmethod
def load(cls, path: str | Path) -> "_ShapeModel":
"""Load a model written by :meth:`save`."""
with np.load(Path(path).with_suffix(".npz")) as f:
Z_mean = f["Z_mean"]
Z_chol = f["Z_chol"] if "Z_chol" in f else None
Z_components = f["Z_components"] if "Z_components" in f else None
score_chol = f["score_chol"] if "score_chol" in f else None
v_shift = float(f["v_shift"])
v_log_mu = float(f["v_log_mu"])
v_log_sigma = float(f["v_log_sigma"])
v_min = int(f["v_min"])
v_max = int(f["v_max"])
metadata = {}
if "metadata_json" in f:
metadata = json.loads(str(f["metadata_json"].item()))
return cls(
Z_mean=Z_mean,
Z_chol=Z_chol,
K=int(Z_mean.shape[0]),
v_shift=v_shift,
v_log_mu=v_log_mu,
v_log_sigma=v_log_sigma,
v_min=v_min,
v_max=v_max,
Z_components=Z_components,
score_chol=score_chol,
metadata=metadata,
)
@cache
def _load_default_model() -> _ShapeModel:
"""Load the packaged model once. Its arrays are shared, so read-only."""
model = _ShapeModel.load(_DEFAULT_MODEL_PATH)
for array in (model.Z_mean, model.Z_chol, model.Z_components, model.score_chol):
if array is not None:
array.flags.writeable = False
return model
def _simplify_to_v(
boundary: np.ndarray, target_v: int, max_iterations: int = 28
) -> Polygon | None:
"""Douglas-Peucker-simplify ``boundary`` to about ``target_v`` vertices.
Bisects the tolerance. The result may miss ``target_v`` by one. Returns the
closest valid polygon, or ``None`` if every step was degenerate.
"""
poly = Polygon(boundary)
if not poly.is_valid or poly.area <= 0:
return None
diam = float(np.linalg.norm(np.ptp(boundary, axis=0)))
lo, hi = 0.0, diam
best: Polygon | None = None
best_diff = float("inf")
for _ in range(max_iterations):
mid = 0.5 * (lo + hi)
s = poly.simplify(mid, preserve_topology=True)
if not isinstance(s, Polygon) or not s.is_valid or s.area <= 0:
hi = mid
continue
v = len(s.exterior.coords) - 1
diff = abs(v - target_v)
if diff < best_diff:
best, best_diff = s, diff
if v == target_v:
return s
if v > target_v:
lo = mid
else:
hi = mid
return best
def _sample_vertex_count(model: _ShapeModel, rng: np.random.Generator) -> int:
"""Draw a vertex count from the fitted shifted log-normal.
Rejection-samples in batches of 64 until a count lies within
``[v_min, v_max]``.
"""
while True:
y = np.exp(rng.normal(model.v_log_mu, model.v_log_sigma, size=64))
v = np.rint(y + model.v_shift).astype(int)
ok = v[(v >= model.v_min) & (v <= model.v_max)]
if len(ok):
return int(ok[0])
[docs]
class FourierPolygon(Boundary):
"""Polygon sampled from a fitted Fourier descriptor model.
Each attempt draws PCA scores scaled by ``temperature``, inverse-FFTs the
coefficients to a smooth curve, and simplifies it to a vertex count drawn
from the model. Invalid results are retried up to ``max_attempts`` times,
then a ``RuntimeError`` is raised. ``model`` is a model or ``.npz`` path and
defaults to the packaged model. The shape is scaled to ``area`` and moved
to ``center``. A heading is drawn per sample unless ``rotation`` pins it in
degrees.
"""
# The model as passed, so the packaged default prints as None.
_repr_attributes = {**Boundary._repr_attributes, "model": "_model_argument"}
def __init__(
self,
model: _ShapeModel | str | Path | None = None,
*,
temperature: float = 2.0,
max_attempts: int = 128,
area: float | None = None,
center: Point2D | None = None,
rotation: float | None = None,
seed: int | np.random.Generator | None = None,
):
self.max_attempts = _validate_integer("max_attempts", max_attempts)
self._model_argument = model
if model is None:
model = _load_default_model()
if isinstance(model, (str, Path)):
model = _ShapeModel.load(model)
self.model = model
self.temperature = _validate_nonnegative("temperature", temperature)
self.target_area = _optional_area(area)
self.center, self._positioned = _resolve_center(center)
self.rotation = (
None if rotation is None else _validate_finite("rotation", rotation)
)
self.seed = self._resolve_own_seed(seed)
def _build_geometry(self) -> BaseGeometry:
rng = as_rng(self.seed)
model = self.model
for _ in range(self.max_attempts):
if model.Z_components is not None:
n_scores = model.Z_components.shape[0]
u = rng.standard_normal(n_scores) * self.temperature
scores = model.score_chol @ u
# A complex vector-matrix product through BLAS spins every BLAS
# thread for a few microseconds of work, so einsum forms it.
Z = model.Z_mean + np.einsum("i,ij->j", scores, model.Z_components)
else:
assert model.Z_chol is not None
u = rng.standard_normal((model.K, 2)) * self.temperature
delta = np.einsum("kij,kj->ki", model.Z_chol, u)
Z = model.Z_mean + delta[:, 0] + 1j * delta[:, 1]
Z[0] = 0 # canonicalization centers each polygon, so the DC bin is 0
z = np.fft.ifft(Z)
boundary = np.column_stack([z.real, z.imag])
v = _sample_vertex_count(model, rng)
result = _simplify_to_v(boundary, v)
if result is not None and result.is_valid and result.area > 1e-6:
return _oriented(result, self.rotation, rng)
raise RuntimeError(
f"could not generate a valid shape in {self.max_attempts} attempts"
)