Skip to content

Commit 7ce5ed2

Browse files
committed
Speed up triangulation
1 parent 9d71198 commit 7ce5ed2

File tree

2 files changed

+202
-107
lines changed

2 files changed

+202
-107
lines changed
Lines changed: 173 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
"""
22
Operations for making a triangular mesh out of the polygons of a dataset.
33
"""
4-
from typing import cast
5-
64
import numpy
5+
import pandas
6+
import shapely
77
import xarray
88
from shapely.geometry import LineString, MultiPoint, Polygon
99

1010
Vertex = tuple[float, float]
11-
Triangle = tuple[int, int, int]
11+
VertexTriangle = tuple[Vertex, Vertex, Vertex]
12+
IndexTriangle = tuple[int, int, int]
1213

1314

1415
def triangulate_dataset(
1516
dataset: xarray.Dataset,
16-
) -> tuple[list[Vertex], list[Triangle], list[int]]:
17+
) -> tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]:
1718
"""
1819
Triangulate the polygon cells of a dataset
1920
@@ -28,17 +29,22 @@ def triangulate_dataset(
2829
2930
Returns
3031
-------
31-
tuple of vertices, triangles, and cell indexes.
32-
A tuple of three lists is returned,
32+
tuple of vertices, triangles, and `cell_indices`
33+
A tuple of three numpy arrays is returned,
3334
containing vertices, triangles, and cell indexes respectively.
3435
35-
Each vertex is a tuple of (x, y) or (lon, lat) coordinates.
36+
`vertices` is a numpy array of shape (V, 2)
37+
where V is the number of unique vertices in the dataset.
38+
The vertex coordinates are in (x, y) or (lon, lat) order.
39+
40+
`triangles` is a numpy array of shape (T, 3)
41+
where T is the number of triangles in the dataset.
42+
Each triangle is a set of three vertex indices.
43+
44+
`cell_indices` is a numpy list of length T.
45+
Each entry indicates which polygon from the dataset a triangle is a part of.
3646
37-
Each triangle is a tuple of three integers,
38-
indicating which vertices make up the triangle.
3947
40-
The cell indexes tie the triangles to the original cell polygon,
41-
allowing you to plot data on the triangle mesh.
4248
4349
Examples
4450
--------
@@ -87,79 +93,160 @@ def triangulate_dataset(
8793
"""
8894
polygons = dataset.ems.polygons
8995

90-
# Getting all the vertices is easy - extract them from the polygons.
91-
# By going through a set, this will deduplicate the vertices.
92-
# Back to a list and we have a stable order
93-
vertices: list[Vertex] = list({
94-
vertex
95-
for polygon in polygons
96-
if polygon is not None
97-
for vertex in polygon.exterior.coords
98-
})
99-
100-
# This maps between a vertex tuple and its index.
101-
# Vertex positions are (probably) floats. For grid datasets, where cells
102-
# are implicitly defined by their centres, be careful to compute cell
103-
# vertices in consistent ways. Float equality is tricky!
104-
vertex_indexes = {vertex: index for index, vertex in enumerate(vertices)}
105-
106-
# Each cell polygon needs to be triangulated,
107-
# while also recording the convention native index of the cell,
108-
# so that we can later correlate cell data with the triangles.
109-
polygons_with_index = [
110-
(polygon, index)
111-
for index, polygon in enumerate(polygons)
112-
if polygon is not None]
113-
triangles_with_index = list(
114-
(tuple(vertex_indexes[vertex] for vertex in triangle_coords), dataset_index)
115-
for polygon, dataset_index in polygons_with_index
116-
for triangle_coords in _triangulate_polygon(polygon)
117-
)
118-
triangles: list[Triangle] = [tri for tri, index in triangles_with_index] # type: ignore
119-
indexes = [index for tri, index in triangles_with_index]
120-
121-
return (vertices, triangles, indexes)
122-
123-
124-
def _triangulate_polygon(polygon: Polygon) -> list[tuple[Vertex, Vertex, Vertex]]:
96+
# Find all the unique coordinates and assign them each a unique index
97+
all_coords = shapely.get_coordinates(polygons)
98+
vertex_index = pandas.MultiIndex.from_arrays(all_coords.T).drop_duplicates()
99+
vertex_series = pandas.Series(numpy.arange(len(vertex_index)), index=vertex_index)
100+
vertex_coords = numpy.array(vertex_index.to_list())
101+
102+
polygon_length = shapely.get_num_coordinates(polygons)
103+
104+
# Count the total number of triangles.
105+
# A polygon with n sides can be decomposed in to n-2 triangles,
106+
# however polygon_length counts an extra vertex because of the closed rings.
107+
total_triangles = numpy.sum(polygon_length[numpy.nonzero(polygon_length)] - 3)
108+
109+
# Pre-allocate some numpy arrays that will store the triangle data.
110+
# This is much faster than building up these data structures iteratively.
111+
face_indices = numpy.empty(total_triangles, dtype=int)
112+
triangle_coords = numpy.empty((total_triangles, 3, 2), dtype=float)
113+
# This is the index of which face we will populate next in the above arrays
114+
current_face = 0
115+
116+
def _add_triangles(face_index: int, vertex_triangles: numpy.ndarray) -> None:
117+
"""
118+
Append some triangles to the face_indices and triangle_coords arrays.
119+
120+
Parameters
121+
----------
122+
face_index : int
123+
The face index of all the triangles
124+
vertex_triangles : numpy.ndarray
125+
The triangles for this face as an (n, 3, 2) array,
126+
where n is the number of triangles in the face.
127+
"""
128+
nonlocal current_face
129+
current_length = len(vertex_triangles)
130+
face_indices[current_face:current_face + current_length] = face_index
131+
triangle_coords[current_face:current_face + current_length] = vertex_triangles
132+
current_face += current_length
133+
134+
# Find all concave polygons by comparing each polygon to its convex hull.
135+
# A convex polygon is its own convex hull,
136+
# while the convex hull of a concave polygon
137+
# will always have fewer vertices than the original polygon.
138+
# Comparing the number of vertices is a shortcut.
139+
convex_hulls = shapely.convex_hull(polygons)
140+
convex_hull_length = shapely.get_num_coordinates(convex_hulls)
141+
polygon_is_concave = numpy.flatnonzero(convex_hull_length != polygon_length)
142+
143+
# Categorize each polygon by length, skipping concave polygons.
144+
# We will handle them separately.
145+
polygon_length[polygon_is_concave] = 0
146+
unique_lengths = numpy.unique(polygon_length)
147+
148+
# Triangulate polygons in batches of identical sizes.
149+
# This allows the coordinates to be manipulated efficiently.
150+
for unique_length in unique_lengths:
151+
if unique_length == 0:
152+
# Any `None` polygons will have a length of 0,
153+
# and any concave polygons have been set to 0.
154+
continue
155+
156+
same_length_face_indices = numpy.flatnonzero(polygon_length == unique_length)
157+
same_length_polygons = polygons[same_length_face_indices]
158+
vertex_triangles = _triangulate_polygons_by_length(same_length_polygons)
159+
160+
for face_index, triangles in zip(same_length_face_indices, vertex_triangles):
161+
_add_triangles(face_index, triangles)
162+
163+
# Triangulate each concave polygon using a slower manual method.
164+
# Anecdotally concave polygons are very rare,
165+
# so using a slower method isn't an issue.
166+
for face_index in polygon_is_concave:
167+
polygon = polygons[face_index]
168+
triangles = _triangulate_concave_polygon(polygon)
169+
_add_triangles(face_index, triangles)
170+
171+
# Check that we have handled each triangle we expected.
172+
assert current_face == total_triangles
173+
174+
# Make a DataFrame. By manually constructing Series the data in the
175+
# underlying numpy arrays will be used in place.
176+
face_triangle_df = pandas.DataFrame({
177+
'face_indices': pandas.Series(face_indices),
178+
'x0': pandas.Series(triangle_coords[:, 0, 0]),
179+
'y0': pandas.Series(triangle_coords[:, 0, 1]),
180+
'x1': pandas.Series(triangle_coords[:, 1, 0]),
181+
'y1': pandas.Series(triangle_coords[:, 1, 1]),
182+
'x2': pandas.Series(triangle_coords[:, 2, 0]),
183+
'y2': pandas.Series(triangle_coords[:, 2, 1]),
184+
}, copy=False)
185+
186+
joined_df = face_triangle_df\
187+
.join(vertex_series.rename('v0'), on=['x0', 'y0'])\
188+
.join(vertex_series.rename('v1'), on=['x1', 'y1'])\
189+
.join(vertex_series.rename('v2'), on=['x2', 'y2'])
190+
191+
faces = joined_df['face_indices'].to_numpy()
192+
triangles = joined_df[['v0', 'v1', 'v2']].to_numpy()
193+
194+
return vertex_coords, triangles, faces
195+
196+
197+
def _triangulate_polygons_by_length(polygons: numpy.ndarray) -> numpy.ndarray:
125198
"""
126-
Triangulate a polygon.
199+
Triangulate a list of convex polygons of equal length.
127200
128-
.. note::
201+
Parameters
202+
----------
203+
polygons : numpy.ndarray of shapely.Polygon
204+
The polygons to triangulate.
205+
These must all have the same number of vertices
206+
and must all be convex.
129207
130-
This currently only supports simple polygons - polygons that do not
131-
intersect themselves and do not have holes.
208+
Returns
209+
-------
210+
numpy.ndarray
211+
A numpy array of shape (# polygons, # triangles, 3, 2),
212+
where `# polygons` is the length of `polygons`
213+
and `# triangles` is the number of triangles each polygon is decomposed in to.
214+
"""
215+
vertex_count = len(polygons[0].exterior.coords) - 1
216+
217+
# An array of shape (len(polygons), vertex_count, 2)
218+
coordinates = shapely.get_coordinates(shapely.get_exterior_ring(polygons))
219+
coordinates = coordinates.reshape((len(polygons), vertex_count + 1, 2))
220+
coordinates = coordinates[:, :-1, :]
221+
222+
# Arrays of shape (len(polygons), vertex_count - 2, 2)
223+
v0 = numpy.repeat(
224+
coordinates[:, 0, :].reshape((-1, 1, 2)),
225+
repeats=vertex_count - 2,
226+
axis=1)
227+
v1 = coordinates[:, 1:-1]
228+
v2 = coordinates[:, 2:]
229+
230+
# An array of shape (len(polygons), vertex_count - 2, 3, 2)
231+
triangles: numpy.ndarray = numpy.stack([v0, v1, v2], axis=2)
232+
return triangles
132233

133-
Examples
134-
--------
135234

136-
.. code-block:: python
235+
def _triangulate_concave_polygon(polygon: Polygon) -> numpy.ndarray:
236+
"""
237+
Triangulate a single convex polygon.
137238
138-
>>> polygon = Polygon([(0, 0), (2, 0), (2, 2), (1, 3), (0, 2), (0, 0)])
139-
>>> for triangle in triangulate_polygon(polygon):
140-
... print(triangle.wkt)
141-
POLYGON ((0 0, 2 0, 2 2, 0 0))
142-
POLYGON ((0 0, 2 2, 1 3, 0 0))
143-
POLYGON ((0 0, 1 3, 0 2, 0 0))
239+
Parameters
240+
----------
241+
polygon : shapely.Polygon
242+
The polygon to triangulate.
144243
145-
See Also
146-
--------
147-
:func:`triangulate_dataset`,
148-
`Polygon triangulation <https://en.wikipedia.org/wiki/Polygon_triangulation>`_
244+
Returns
245+
-------
246+
numpy.ndarray
247+
A numpy array of shape (# triangles, 3, 2),
248+
where `# triangles` is the number of triangles the polygon is decomposed in to.
149249
"""
150-
# The 'ear clipping' method used below is correct for all polygons, but not
151-
# performant. If the polygon is convex we can use a shortcut method.
152-
if polygon.equals(polygon.convex_hull):
153-
# Make a fan triangulation. For a polygon with n vertices the triangles
154-
# will have vertices:
155-
# (1, 2, 3), (1, 3, 4), (1, 4, 5), ... (1, n-1, n)
156-
exterior_vertices = numpy.array(polygon.exterior.coords)[:-1]
157-
num_triangles = len(exterior_vertices) - 2
158-
v0 = numpy.broadcast_to(exterior_vertices[0], (num_triangles, 2))
159-
v1 = exterior_vertices[1:-1]
160-
v2 = exterior_vertices[2:]
161-
return list(zip(map(tuple, v0), map(tuple, v1), map(tuple, v2)))
162-
163250
# This is the 'ear clipping' method of polygon triangulation.
164251
# In any simple polygon, there is guaranteed to be at least two 'ears'
165252
# - three neighbouring vertices whos diagonal is inside the polygon.
@@ -171,7 +258,12 @@ def _triangulate_polygon(polygon: Polygon) -> list[tuple[Vertex, Vertex, Vertex]
171258
# Most polygons will be either squares, convex quadrilaterals, or convex
172259
# polygons.
173260

174-
triangles: list[tuple[Vertex, Vertex, Vertex]] = []
261+
# A triangle with n vertices will have n - 2 triangles.
262+
# Because the exterior is a closed loop, we need to subtract 3.
263+
triangle_count = len(polygon.exterior.coords) - 3
264+
triangles = numpy.empty((triangle_count, 3, 2))
265+
triangle_index = 0
266+
175267
# Note that shapely polygons with n vertices will be closed, and thus have
176268
# n+1 coordinates. We trim that superfluous coordinate off in the next line
177269
while len(polygon.exterior.coords) > 4:
@@ -192,7 +284,8 @@ def _triangulate_polygon(polygon: Polygon) -> list[tuple[Vertex, Vertex, Vertex]
192284
# that ear will result in two disconnected polygons.
193285
and exterior.intersection(diagonal).equals(multipoint)
194286
):
195-
triangles.append((coords[i], coords[i + 1], coords[i + 2]))
287+
triangles[triangle_index] = coords[i:i + 3]
288+
triangle_index += 1
196289
polygon = Polygon(coords[:i + 1] + coords[i + 2:])
197290
break
198291
else:
@@ -201,8 +294,7 @@ def _triangulate_polygon(polygon: Polygon) -> list[tuple[Vertex, Vertex, Vertex]
201294
f"Could not find interior diagonal for polygon! {polygon.wkt}")
202295

203296
# The trimmed polygon is now a triangle. Add it to the list and we are done!
297+
triangles[triangle_index] = polygon.exterior.coords[:-1]
298+
assert (triangle_index + 1) == triangle_count
204299

205-
triangles.append(cast(
206-
tuple[Vertex, Vertex, Vertex],
207-
tuple(map(tuple, polygon.exterior.coords[:-1]))))
208300
return triangles

0 commit comments

Comments
 (0)