661 lines
20 KiB
Python
661 lines
20 KiB
Python
import numpy as np
|
|
|
|
from shapely.geometry import Polygon
|
|
|
|
from collections import deque
|
|
|
|
from .. import util
|
|
from .. import bounds
|
|
from .. import graph
|
|
|
|
from ..constants import tol_path as tol
|
|
from ..constants import log
|
|
from ..transformations import transform_points
|
|
|
|
from .simplify import fit_circle_check
|
|
from .traversal import resample_path
|
|
|
|
try:
|
|
import networkx as nx
|
|
except BaseException as E:
|
|
# create a dummy module which will raise the ImportError
|
|
# or other exception only when someone tries to use networkx
|
|
from ..exceptions import ExceptionModule
|
|
nx = ExceptionModule(E)
|
|
try:
|
|
from rtree import Rtree
|
|
except BaseException as E:
|
|
# create a dummy module which will raise the ImportError
|
|
from ..exceptions import closure
|
|
Rtree = closure(E)
|
|
|
|
|
|
def enclosure_tree(polygons):
|
|
"""
|
|
Given a list of shapely polygons with only exteriors,
|
|
find which curves represent the exterior shell or root curve
|
|
and which represent holes which penetrate the exterior.
|
|
|
|
This is done with an R-tree for rough overlap detection,
|
|
and then exact polygon queries for a final result.
|
|
|
|
Parameters
|
|
-----------
|
|
polygons : (n,) shapely.geometry.Polygon
|
|
Polygons which only have exteriors and may overlap
|
|
|
|
Returns
|
|
-----------
|
|
roots : (m,) int
|
|
Index of polygons which are root
|
|
contains : networkx.DiGraph
|
|
Edges indicate a polygon is
|
|
contained by another polygon
|
|
"""
|
|
tree = Rtree()
|
|
# nodes are indexes in polygons
|
|
contains = nx.DiGraph()
|
|
for i, polygon in enumerate(polygons):
|
|
# if a polygon is None it means creation
|
|
# failed due to weird geometry so ignore it
|
|
if polygon is None or len(polygon.bounds) != 4:
|
|
continue
|
|
# insert polygon bounds into rtree
|
|
tree.insert(i, polygon.bounds)
|
|
# make sure every valid polygon has a node
|
|
contains.add_node(i)
|
|
|
|
# loop through every polygon
|
|
for i in contains.nodes():
|
|
polygon = polygons[i]
|
|
# we first query for bounding box intersections from the R-tree
|
|
for j in tree.intersection(polygon.bounds):
|
|
# if we are checking a polygon against itself continue
|
|
if (i == j):
|
|
continue
|
|
# do a more accurate polygon in polygon test
|
|
# for the enclosure tree information
|
|
if polygons[i].contains(polygons[j]):
|
|
contains.add_edge(i, j)
|
|
elif polygons[j].contains(polygons[i]):
|
|
contains.add_edge(j, i)
|
|
|
|
# a root or exterior curve has an even number of parents
|
|
# wrap in dict call to avoid networkx view
|
|
degree = dict(contains.in_degree())
|
|
|
|
# convert keys and values to numpy arrays
|
|
indexes = np.array(list(degree.keys()))
|
|
degrees = np.array(list(degree.values()))
|
|
|
|
# roots are curves with an even inward degree (parent count)
|
|
roots = indexes[(degrees % 2) == 0]
|
|
|
|
# if there are multiple nested polygons split the graph
|
|
# so the contains logic returns the individual polygons
|
|
if len(degrees) > 0 and degrees.max() > 1:
|
|
# collect new edges for graph
|
|
edges = []
|
|
# find edges of subgraph for each root and children
|
|
for root in roots:
|
|
children = indexes[degrees == degree[root] + 1]
|
|
edges.extend(contains.subgraph(np.append(children, root)).edges())
|
|
# stack edges into new directed graph
|
|
contains = nx.from_edgelist(edges, nx.DiGraph())
|
|
# if roots have no children add them anyway
|
|
contains.add_nodes_from(roots)
|
|
|
|
return roots, contains
|
|
|
|
|
|
def edges_to_polygons(edges, vertices):
|
|
"""
|
|
Given an edge list of indices and associated vertices
|
|
representing lines, generate a list of polygons.
|
|
|
|
Parameters
|
|
-----------
|
|
edges : (n, 2) int
|
|
Indexes of vertices which represent lines
|
|
vertices : (m, 2) float
|
|
Vertices in 2D space
|
|
|
|
Returns
|
|
----------
|
|
polygons : (p,) shapely.geometry.Polygon
|
|
Polygon objects with interiors
|
|
"""
|
|
|
|
# create closed polygon objects
|
|
polygons = []
|
|
# loop through a sequence of ordered traversals
|
|
for dfs in graph.traversals(edges, mode='dfs'):
|
|
try:
|
|
# try to recover polygons before they are more complicated
|
|
polygons.append(repair_invalid(Polygon(vertices[dfs])))
|
|
except ValueError:
|
|
continue
|
|
|
|
# if there is only one polygon, just return it
|
|
if len(polygons) == 1:
|
|
return polygons
|
|
|
|
# find which polygons contain which other polygons
|
|
roots, tree = enclosure_tree(polygons)
|
|
|
|
# generate list of polygons with proper interiors
|
|
complete = []
|
|
for root in roots:
|
|
interior = list(tree[root].keys())
|
|
shell = polygons[root].exterior.coords
|
|
holes = [polygons[i].exterior.coords for i in interior]
|
|
complete.append(Polygon(shell=shell,
|
|
holes=holes))
|
|
return complete
|
|
|
|
|
|
def polygons_obb(polygons):
|
|
"""
|
|
Find the OBBs for a list of shapely.geometry.Polygons
|
|
"""
|
|
rectangles = [None] * len(polygons)
|
|
transforms = [None] * len(polygons)
|
|
for i, p in enumerate(polygons):
|
|
transforms[i], rectangles[i] = polygon_obb(p)
|
|
return np.array(transforms), np.array(rectangles)
|
|
|
|
|
|
def polygon_obb(polygon):
|
|
"""
|
|
Find the oriented bounding box of a Shapely polygon.
|
|
|
|
The OBB is always aligned with an edge of the convex hull of the polygon.
|
|
|
|
Parameters
|
|
-------------
|
|
polygons : shapely.geometry.Polygon
|
|
Input geometry
|
|
|
|
Returns
|
|
-------------
|
|
transform : (3, 3) float
|
|
Transformation matrix
|
|
which will move input polygon from its original position
|
|
to the first quadrant where the AABB is the OBB
|
|
extents : (2,) float
|
|
Extents of transformed polygon
|
|
"""
|
|
if hasattr(polygon, 'exterior'):
|
|
points = np.asanyarray(polygon.exterior.coords)
|
|
elif isinstance(polygon, np.ndarray):
|
|
points = polygon
|
|
else:
|
|
raise ValueError('polygon or points must be provided')
|
|
return bounds.oriented_bounds_2D(points)
|
|
|
|
|
|
def transform_polygon(polygon, matrix):
|
|
"""
|
|
Transform a polygon by a a 2D homogeneous transform.
|
|
|
|
Parameters
|
|
-------------
|
|
polygon : shapely.geometry.Polygon
|
|
2D polygon to be transformed.
|
|
matrix : (3, 3) float
|
|
2D homogeneous transformation.
|
|
|
|
Returns
|
|
--------------
|
|
result : shapely.geometry.Polygon
|
|
Polygon transformed by matrix.
|
|
"""
|
|
matrix = np.asanyarray(matrix, dtype=np.float64)
|
|
|
|
if util.is_sequence(polygon):
|
|
result = [transform_polygon(p, t)
|
|
for p, t in zip(polygon, matrix)]
|
|
return result
|
|
# transform the outer shell
|
|
shell = transform_points(np.array(polygon.exterior.coords),
|
|
matrix)[:, :2]
|
|
# transform the interiors
|
|
holes = [transform_points(np.array(i.coords),
|
|
matrix)[:, :2]
|
|
for i in polygon.interiors]
|
|
# create a new polygon with the result
|
|
result = Polygon(shell=shell, holes=holes)
|
|
return result
|
|
|
|
|
|
def plot_polygon(polygon, show=True, **kwargs):
|
|
"""
|
|
Plot a shapely polygon using matplotlib.
|
|
|
|
Parameters
|
|
------------
|
|
polygon : shapely.geometry.Polygon
|
|
Polygon to be plotted
|
|
show : bool
|
|
If True will display immediately
|
|
**kwargs
|
|
Passed to plt.plot
|
|
"""
|
|
import matplotlib.pyplot as plt
|
|
|
|
def plot_single(single):
|
|
plt.plot(*single.exterior.xy, **kwargs)
|
|
for interior in single.interiors:
|
|
plt.plot(*interior.xy, **kwargs)
|
|
# make aspect ratio non- stupid
|
|
plt.axes().set_aspect('equal', 'datalim')
|
|
if util.is_sequence(polygon):
|
|
[plot_single(i) for i in polygon]
|
|
else:
|
|
plot_single(polygon)
|
|
|
|
if show:
|
|
plt.show()
|
|
|
|
|
|
def resample_boundaries(polygon, resolution, clip=None):
|
|
"""
|
|
Return a version of a polygon with boundaries resampled
|
|
to a specified resolution.
|
|
|
|
Parameters
|
|
-------------
|
|
polygon : shapely.geometry.Polygon
|
|
Source geometry
|
|
resolution : float
|
|
Desired distance between points on boundary
|
|
clip : (2,) int
|
|
Upper and lower bounds to clip
|
|
number of samples to avoid exploding count
|
|
|
|
Returns
|
|
------------
|
|
kwargs : dict
|
|
Keyword args for a Polygon constructor `Polygon(**kwargs)`
|
|
"""
|
|
def resample_boundary(boundary):
|
|
# add a polygon.exterior or polygon.interior to
|
|
# the deque after resampling based on our resolution
|
|
count = boundary.length / resolution
|
|
count = int(np.clip(count, *clip))
|
|
return resample_path(boundary.coords, count=count)
|
|
if clip is None:
|
|
clip = [8, 200]
|
|
# create a sequence of [(n,2)] points
|
|
kwargs = {'shell': resample_boundary(polygon.exterior),
|
|
'holes': deque()}
|
|
for interior in polygon.interiors:
|
|
kwargs['holes'].append(resample_boundary(interior))
|
|
kwargs['holes'] = np.array(kwargs['holes'])
|
|
return kwargs
|
|
|
|
|
|
def stack_boundaries(boundaries):
|
|
"""
|
|
Stack the boundaries of a polygon into a single
|
|
(n, 2) list of vertices.
|
|
|
|
Parameters
|
|
------------
|
|
boundaries : dict
|
|
With keys 'shell', 'holes'
|
|
|
|
Returns
|
|
------------
|
|
stacked : (n, 2) float
|
|
Stacked vertices
|
|
"""
|
|
if len(boundaries['holes']) == 0:
|
|
return boundaries['shell']
|
|
result = np.vstack((boundaries['shell'],
|
|
np.vstack(boundaries['holes'])))
|
|
return result
|
|
|
|
|
|
def medial_axis(polygon,
|
|
resolution=None,
|
|
clip=None):
|
|
"""
|
|
Given a shapely polygon, find the approximate medial axis
|
|
using a voronoi diagram of evenly spaced points on the
|
|
boundary of the polygon.
|
|
|
|
Parameters
|
|
----------
|
|
polygon : shapely.geometry.Polygon
|
|
The source geometry
|
|
resolution : float
|
|
Distance between each sample on the polygon boundary
|
|
clip : None, or (2,) int
|
|
Clip sample count to min of clip[0] and max of clip[1]
|
|
|
|
Returns
|
|
----------
|
|
edges : (n, 2) int
|
|
Vertex indices representing line segments
|
|
on the polygon's medial axis
|
|
vertices : (m, 2) float
|
|
Vertex positions in space
|
|
"""
|
|
# a circle will have a single point medial axis
|
|
if len(polygon.interiors) == 0:
|
|
# what is the approximate scale of the polygon
|
|
scale = np.reshape(polygon.bounds, (2, 2)).ptp(axis=0).max()
|
|
# a (center, radius, error) tuple
|
|
fit = fit_circle_check(
|
|
polygon.exterior.coords, scale=scale)
|
|
# is this polygon in fact a circle
|
|
if fit is not None:
|
|
# return an edge that has the center as the midpoint
|
|
epsilon = np.clip(
|
|
fit['radius'] / 500, 1e-5, np.inf)
|
|
vertices = np.array(
|
|
[fit['center'] + [0, epsilon],
|
|
fit['center'] - [0, epsilon]],
|
|
dtype=np.float64)
|
|
# return a single edge to avoid consumers needing to special case
|
|
edges = np.array([[0, 1]], dtype=np.int64)
|
|
return edges, vertices
|
|
|
|
from scipy.spatial import Voronoi
|
|
from shapely import vectorized
|
|
|
|
if resolution is None:
|
|
resolution = np.reshape(
|
|
polygon.bounds, (2, 2)).ptp(axis=0).max() / 100
|
|
|
|
# get evenly spaced points on the polygons boundaries
|
|
samples = resample_boundaries(polygon=polygon,
|
|
resolution=resolution,
|
|
clip=clip)
|
|
# stack the boundary into a (m,2) float array
|
|
samples = stack_boundaries(samples)
|
|
# create the voronoi diagram on 2D points
|
|
voronoi = Voronoi(samples)
|
|
# which voronoi vertices are contained inside the polygon
|
|
contains = vectorized.contains(polygon, *voronoi.vertices.T)
|
|
# ridge vertices of -1 are outside, make sure they are False
|
|
contains = np.append(contains, False)
|
|
# make sure ridge vertices is numpy array
|
|
ridge = np.asanyarray(voronoi.ridge_vertices, dtype=np.int64)
|
|
# only take ridges where every vertex is contained
|
|
edges = ridge[contains[ridge].all(axis=1)]
|
|
|
|
# now we need to remove uncontained vertices
|
|
contained = np.unique(edges)
|
|
mask = np.zeros(len(voronoi.vertices), dtype=np.int64)
|
|
mask[contained] = np.arange(len(contained))
|
|
|
|
# mask voronoi vertices
|
|
vertices = voronoi.vertices[contained]
|
|
# re-index edges
|
|
edges_final = mask[edges]
|
|
|
|
if tol.strict:
|
|
# make sure we didn't screw up indexes
|
|
assert (vertices[edges_final] -
|
|
voronoi.vertices[edges]).ptp() < 1e-5
|
|
|
|
return edges_final, vertices
|
|
|
|
|
|
def polygon_hash(polygon):
|
|
"""
|
|
Return a vector containing values representitive of
|
|
a particular polygon.
|
|
|
|
Parameters
|
|
---------
|
|
polygon : shapely.geometry.Polygon
|
|
Input geometry
|
|
|
|
Returns
|
|
---------
|
|
hashed: (6), float
|
|
Representitive values representing input polygon
|
|
"""
|
|
result = np.array(
|
|
[len(polygon.interiors),
|
|
polygon.convex_hull.area,
|
|
polygon.convex_hull.length,
|
|
polygon.area,
|
|
polygon.length,
|
|
polygon.exterior.length],
|
|
dtype=np.float64)
|
|
return result
|
|
|
|
|
|
def random_polygon(segments=8, radius=1.0):
|
|
"""
|
|
Generate a random polygon with a maximum number of sides and approximate radius.
|
|
|
|
Parameters
|
|
---------
|
|
segments : int
|
|
The maximum number of sides the random polygon will have
|
|
radius : float
|
|
The approximate radius of the polygon desired
|
|
|
|
Returns
|
|
---------
|
|
polygon : shapely.geometry.Polygon
|
|
Geometry object with random exterior and no interiors.
|
|
"""
|
|
angles = np.sort(np.cumsum(np.random.random(
|
|
segments) * np.pi * 2) % (np.pi * 2))
|
|
radii = np.random.random(segments) * radius
|
|
points = np.column_stack(
|
|
(np.cos(angles), np.sin(angles))) * radii.reshape((-1, 1))
|
|
points = np.vstack((points, points[0]))
|
|
polygon = Polygon(points).buffer(0.0)
|
|
if util.is_sequence(polygon):
|
|
return polygon[0]
|
|
return polygon
|
|
|
|
|
|
def polygon_scale(polygon):
|
|
"""
|
|
For a Polygon object return the diagonal length of the AABB.
|
|
|
|
Parameters
|
|
------------
|
|
polygon : shapely.geometry.Polygon
|
|
Source geometry
|
|
|
|
Returns
|
|
------------
|
|
scale : float
|
|
Length of AABB diagonal
|
|
"""
|
|
extents = np.reshape(polygon.bounds, (2, 2)).ptp(axis=0)
|
|
scale = (extents ** 2).sum() ** .5
|
|
|
|
return scale
|
|
|
|
|
|
def paths_to_polygons(paths, scale=None):
|
|
"""
|
|
Given a sequence of connected points turn them into
|
|
valid shapely Polygon objects.
|
|
|
|
Parameters
|
|
-----------
|
|
paths : (n,) sequence
|
|
Of (m, 2) float closed paths
|
|
scale: float
|
|
Approximate scale of drawing for precision
|
|
|
|
Returns
|
|
-----------
|
|
polys : (p,) list
|
|
Filled with Polygon or None
|
|
|
|
"""
|
|
polygons = [None] * len(paths)
|
|
for i, path in enumerate(paths):
|
|
if len(path) < 4:
|
|
# since the first and last vertices are identical in
|
|
# a closed loop a 4 vertex path is the minimum for
|
|
# non-zero area
|
|
continue
|
|
try:
|
|
polygons[i] = repair_invalid(Polygon(path), scale)
|
|
except ValueError:
|
|
# raised if a polygon is unrecoverable
|
|
continue
|
|
except BaseException:
|
|
log.error('unrecoverable polygon', exc_info=True)
|
|
polygons = np.array(polygons)
|
|
return polygons
|
|
|
|
|
|
def sample(polygon, count, factor=1.5, max_iter=10):
|
|
"""
|
|
Use rejection sampling to generate random points inside a
|
|
polygon.
|
|
|
|
Parameters
|
|
-----------
|
|
polygon : shapely.geometry.Polygon
|
|
Polygon that will contain points
|
|
count : int
|
|
Number of points to return
|
|
factor : float
|
|
How many points to test per loop
|
|
max_iter : int
|
|
Maximum number of intersection checks is:
|
|
> count * factor * max_iter
|
|
|
|
Returns
|
|
-----------
|
|
hit : (n, 2) float
|
|
Random points inside polygon
|
|
where n <= count
|
|
"""
|
|
# do batch point-in-polygon queries
|
|
from shapely import vectorized
|
|
|
|
# get size of bounding box
|
|
bounds = np.reshape(polygon.bounds, (2, 2))
|
|
extents = bounds.ptp(axis=0)
|
|
|
|
hit = []
|
|
hit_count = 0
|
|
per_loop = int(count * factor)
|
|
|
|
for i in range(max_iter):
|
|
# generate points inside polygons AABB
|
|
points = np.random.random((per_loop, 2))
|
|
points = (points * extents) + bounds[0]
|
|
|
|
# do the point in polygon test and append resulting hits
|
|
mask = vectorized.contains(polygon, *points.T)
|
|
hit.append(points[mask])
|
|
|
|
# keep track of how many points we've collected
|
|
hit_count += len(hit[-1])
|
|
|
|
# if we have enough points exit the loop
|
|
if hit_count > count:
|
|
break
|
|
|
|
# stack the hits into an (n,2) array and truncate
|
|
hit = np.vstack(hit)[:count]
|
|
|
|
return hit
|
|
|
|
|
|
def repair_invalid(polygon, scale=None, rtol=.5):
|
|
"""
|
|
Given a shapely.geometry.Polygon, attempt to return a
|
|
valid version of the polygon through buffering tricks.
|
|
|
|
Parameters
|
|
-----------
|
|
polygon : shapely.geometry.Polygon
|
|
Source geometry
|
|
rtol : float
|
|
How close does a perimeter have to be
|
|
scale : float or None
|
|
For numerical precision reference
|
|
|
|
Returns
|
|
----------
|
|
repaired : shapely.geometry.Polygon
|
|
Repaired polygon
|
|
|
|
Raises
|
|
----------
|
|
ValueError
|
|
If polygon can't be repaired
|
|
"""
|
|
if hasattr(polygon, 'is_valid') and polygon.is_valid:
|
|
return polygon
|
|
|
|
# basic repair involves buffering the polygon outwards
|
|
# this will fix a subset of problems.
|
|
basic = polygon.buffer(tol.zero)
|
|
# if it returned multiple polygons check the largest
|
|
if util.is_sequence(basic):
|
|
basic = basic[np.argmax([i.area for i in basic])]
|
|
|
|
# check perimeter of result against original perimeter
|
|
if basic.is_valid and np.isclose(basic.length,
|
|
polygon.length,
|
|
rtol=rtol):
|
|
return basic
|
|
|
|
if scale is None:
|
|
distance = 0.002 * polygon_scale(polygon)
|
|
else:
|
|
distance = 0.002 * scale
|
|
|
|
# if there are no interiors, we can work with just the exterior
|
|
# ring, which is often more reliable
|
|
if len(polygon.interiors) == 0:
|
|
# try buffering the exterior of the polygon
|
|
# the interior will be offset by -tol.buffer
|
|
rings = polygon.exterior.buffer(distance).interiors
|
|
if len(rings) == 1:
|
|
# reconstruct a single polygon from the interior ring
|
|
recon = Polygon(shell=rings[0]).buffer(distance)
|
|
# check perimeter of result against original perimeter
|
|
if recon.is_valid and np.isclose(recon.length,
|
|
polygon.length,
|
|
rtol=rtol):
|
|
return recon
|
|
|
|
# try de-deuplicating the outside ring
|
|
points = np.array(polygon.exterior)
|
|
# remove any segments shorter than tol.merge
|
|
# this is a little risky as if it was discretized more
|
|
# finely than 1-e8 it may remove detail
|
|
unique = np.append(True, (np.diff(points, axis=0)**2).sum(
|
|
axis=1)**.5 > 1e-8)
|
|
# make a new polygon with result
|
|
dedupe = Polygon(shell=points[unique])
|
|
# check result
|
|
if dedupe.is_valid and np.isclose(dedupe.length,
|
|
polygon.length,
|
|
rtol=rtol):
|
|
return dedupe
|
|
|
|
# buffer and unbuffer the whole polygon
|
|
buffered = polygon.buffer(distance).buffer(-distance)
|
|
# if it returned multiple polygons check the largest
|
|
if util.is_sequence(buffered):
|
|
buffered = buffered[np.argmax([i.area for i in buffered])]
|
|
# check perimeter of result against original perimeter
|
|
if buffered.is_valid and np.isclose(buffered.length,
|
|
polygon.length,
|
|
rtol=rtol):
|
|
log.debug('Recovered invalid polygon through double buffering')
|
|
return buffered
|
|
|
|
raise ValueError('unable to recover polygon!')
|