# -*- coding: utf-8 -*-
import numpy as np
from geonetworkx.geograph import GeoGraph
from geonetworkx.utils import get_line_start
import geonetworkx as gnx
from shapely.geometry import Polygon, LineString, MultiPolygon
from shapely.ops import cascaded_union
import math
from typing import Union
from scipy.spatial import Delaunay
import geopandas as gpd
GenericPolygon = Union[Polygon, MultiPolygon]
[docs]def get_edges_voronoi_cells(graph: GeoGraph, tolerance=1e-7) -> gpd.GeoSeries:
"""Return edge voronoi cells as `GeoSeries`."""
edge_as_lines = graph.get_edges_as_line_series()
lines = list(edge_as_lines)
edge_cells = gnx.compute_voronoi_cells_from_lines(lines, tolerance)
edge_cells_as_series = gpd.GeoSeries({e: cell for e, cell in zip(edge_as_lines.index, edge_cells)})
edge_cells_as_series.crs = graph.crs
return edge_cells_as_series
[docs]def get_segment_boundary_buffer_polygon(segment_coords: Union[list, np.array],
radius: float, residual_radius: float) -> Polygon:
"""Return a segment boundary polygon using given radius. It represents all reachable points from the first
extremity of the segment. The returned polygon is a trapeze. See ``boundary_edge_buffer``."""
segment_direction = [segment_coords[1][0] - segment_coords[0][0], segment_coords[1][1] - segment_coords[0][1]]
orthogonal_dir = np.array([- segment_direction[1], segment_direction[0]])
orthogonal_dir /= np.linalg.norm(orthogonal_dir)
top_points = [segment_coords[0] + orthogonal_dir * radius, segment_coords[1] + orthogonal_dir * residual_radius]
bottom_points = [segment_coords[0] - orthogonal_dir * radius, segment_coords[1] - orthogonal_dir * residual_radius]
return Polygon(top_points + list(reversed(bottom_points)))
[docs]def get_point_boundary_buffer_polygon(point_coords: list, radius: float,
segment_direction: list, resolution=16) -> Polygon:
"""Returns a half-disk centered on the given point, with the given radius and having the boundary edge orthogonal to
the given segment direction. See ``boundary_edge_buffer``."""
# Segment angle with system coordinates
phi = math.acos(segment_direction[0] / np.linalg.norm(segment_direction))
if segment_direction[1] < 0:
phi *= -1.0
# Discretization angle
theta = math.pi / float(resolution)
# Start angle
angle = phi - math.pi / 2.0
coords = []
for i in range(resolution + 1):
coords.append(point_coords + radius * np.array([math.cos(angle), math.sin(angle)]))
angle -= theta
return Polygon(coords)
[docs]def boundary_edge_buffer(line: LineString) -> GenericPolygon:
"""Return the edge buffer polygon on the oriented line. This represented the area where all points are reachable
starting from the line first extremity and using the closest edge projection rule."""
radius = line.length
residual_radius = radius
boundary_polygons = []
for i in range(len(line.coords) - 1):
segment_coords = np.array([line.coords[i], line.coords[i + 1]])
residual_radius -= gnx.euclidian_distance_coordinates(segment_coords[0], segment_coords[1])
boundary_polygon = get_segment_boundary_buffer_polygon(segment_coords, radius, residual_radius)
boundary_polygons.append(boundary_polygon)
segment_direction = segment_coords[1] - segment_coords[0]
boundary_polygons.append(get_point_boundary_buffer_polygon(segment_coords[0], radius, segment_direction))
radius = residual_radius
return cascaded_union(boundary_polygons)
[docs]def isochrone_polygon(graph: GeoGraph, source, limit, weight="length", tolerance=1e-7) -> GenericPolygon:
"""Return a polygon approximating the isochrone set in the geograph.
Parameters
----------
graph : Geograph
Graph representing possible routes.
source :
Source node from where distance is computed
limit : float or int
Isochrone limit (e.g. 100 meters, 5 minutes, depending on ``weight`` unit).
weight : str
Weight attribute on edges to compute distances (edge weights should be non-negative). (Default value = "length")
tolerance : float
Tolerance to compute Voronoi cells. (Default value = 1e-7)
Returns
-------
Polygon or MultiPolygon
A polygon representing all reachable points within the given limit from the source node.
"""
working_graph = graph.copy()
# Compute the ego-graph
gnx.add_ego_boundary_nodes(working_graph, source, limit, distance=weight)
ego_graph = gnx.extended_ego_graph(working_graph, source, limit, distance=weight)
# Compute edges voronoi cells
edge_voronoi_cells = get_edges_voronoi_cells(working_graph, tolerance)
# Set ego-graph edges cells
isochrone_polygons = []
for e, edge in enumerate(edge_voronoi_cells.index):
if ego_graph.has_edge(*edge):
p = edge_voronoi_cells[e]
if not graph.has_edge(*edge): # For boundary edges
boundary_line = working_graph.edges[edge][working_graph.edges_geometry_key]
if get_line_start(working_graph, edge, boundary_line) != edge[0]:
boundary_line = LineString(reversed(boundary_line.coords))
edge_buffer_pol = boundary_edge_buffer(boundary_line)
isochrone_polygons.append(p.intersection(edge_buffer_pol))
else:
isochrone_polygons.append(p)
# Merge as isochrone polygon
final_polygon = cascaded_union(isochrone_polygons)
final_polygon = final_polygon.buffer(tolerance)
return final_polygon
[docs]def get_alpha_shape_polygon(points: list, quantile: float) -> GenericPolygon:
"""Return the alpha-shape polygon formed by the given points. Alpha parameter is determined using a quantile of
circumradius of Delaunay triangles.
Parameters
----------
points : list
List of input points (2D)
quantile : float
Quantile on circumradius to determine alpha (100 returns the convex hull,
0 returns an empty polygon). ``0 <= quantile <= 100``.
Returns
-------
Polygon or MultiPolygon
The polygon formed by all triangles having a circumradius inferior or equal to :math:`1/\\alpha`.
Note that this does not return the exhaustive alpha-shape for low quantiles, the minimum spanning tree LineString
should be added to the returned polygon.
This is adapted from `Sean Gillies code <https://sgillies.net/2012/10/13/the-fading-shape-of-alpha.html>`_.
"""
points = np.asarray(points)
tri = Delaunay(points)
polygons = []
# loop over triangles:
# ia, ib, ic = indices of corner points of the triangle
circum_radius = []
for ia, ib, ic in tri.vertices:
pa = points[ia]
pb = points[ib]
pc = points[ic]
# Lengths of sides of triangle
a = gnx.euclidian_distance_coordinates(pa, pb)
b = gnx.euclidian_distance_coordinates(pb, pc)
c = gnx.euclidian_distance_coordinates(pc, pa)
# Semiperimeter of triangle
s = (a + b + c) / 2.0
# Area of triangle by Heron's formula
area = math.sqrt(np.clip(s * (s - a) * (s - b) * (s - c), 0.0, float("inf")))
if area <= 0.0:
continue
circum_r = a * b * c / (4.0 * area)
circum_radius.append(circum_r)
polygons.append(Polygon([pa, pb, pc]))
inv_alpha = np.percentile(circum_radius, quantile)
filtered_polygons = [p for i, p in enumerate(polygons) if circum_radius[i] <= inv_alpha]
return cascaded_union(filtered_polygons)
[docs]def isochrone_polygon_with_alpha_shape(graph: GeoGraph, source, limit,
weight="length",
alpha_quantile=99.0,
remove_holes=True,
tolerance=1e-7) -> GenericPolygon:
"""Returns an approximation of the isochrone polygon using an alpha-shape of the Shortest Path Tree.
Parameters
----------
graph : GeoGraph
GeoGraph to browse
source :
Source node from where distance is computed
limit : float or int
Isochrone limit (e.g. 100 meters, 5 minutes, depending on ``weight`` unit).
weight : str
Weight attribute on edges to compute distances (edge weights should be non-negative). (Default value = "length")
alpha_quantile : float
Quantile on circumradius to determine alpha (100 returns the convex hull,
0 returns an empty polygon). ``0 <= quantile <= 100``. (Default value = 99.0)
remove_holes : bool
If ``True`` remove holes in the returned polygon. (Default value = True)
tolerance : float
Buffering tolerance on polygon for rendering (Default value = 1e-7)
Returns
-------
Polygon or MultiPolygon
A polygon approximating the isochrone.
"""
# Compute the ego-graph
ego_graph = gnx.extended_ego_graph(graph, source, limit, distance=weight)
edge_as_lines = ego_graph.get_edges_as_line_series()
discretized_lines, _ = gnx.discretize_lines(edge_as_lines)
alpha_shape = get_alpha_shape_polygon(discretized_lines, alpha_quantile)
alpha_shape = alpha_shape.buffer(tolerance)
if remove_holes:
if isinstance(alpha_shape, MultiPolygon):
alpha_shape = MultiPolygon([Polygon(sub_p.exterior) for sub_p in alpha_shape])
else:
alpha_shape = Polygon(alpha_shape.exterior)
return alpha_shape