pycontrails 0.40.1__cp39-cp39-macosx_11_0_arm64.whl → 0.42.0__cp39-cp39-macosx_11_0_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pycontrails might be problematic. Click here for more details.

@@ -9,327 +9,348 @@ See Also
9
9
  from __future__ import annotations
10
10
 
11
11
  import warnings
12
- from typing import Iterator
12
+ from typing import Any
13
13
 
14
14
  import numpy as np
15
15
  import numpy.typing as npt
16
16
 
17
17
  try:
18
+ import cv2
18
19
  import shapely
20
+ import shapely.geometry
19
21
  import shapely.validation
20
- from skimage import draw, measure
21
22
  except ModuleNotFoundError as exc:
22
23
  raise ModuleNotFoundError(
23
- "This module requires the 'scikit-image' and 'shapely' packages. "
24
+ "This module requires the 'opencv-python' and 'shapely' packages. "
24
25
  "These can be installed with 'pip install pycontrails[vis]'."
25
26
  ) from exc
26
27
 
27
28
 
28
- class NestedContours:
29
- """A data structure for storing nested contours.
30
-
31
- This data structure is not intended to be instantiated directly. Rather, the
32
- :func:`find_contours_to_depth` returns an instance.
33
-
34
- There is no validation to ensure child contours are actually nested. It is up
35
- to the caller (ie, :func:`find_contours_to_depth`) to provide this.
29
+ def buffer_and_clean(
30
+ contour: npt.NDArray[np.float_],
31
+ min_area: float,
32
+ convex_hull: bool,
33
+ epsilon: float,
34
+ precision: int | None,
35
+ buffer: float,
36
+ is_exterior: bool,
37
+ ) -> shapely.Polygon | None:
38
+ """Buffer and clean a contour.
36
39
 
37
40
  Parameters
38
41
  ----------
39
- contour : npt.NDArray[np.float_] | None
40
- Contour to store at the given node. If None, this is a top level contour.
41
- None by default.
42
+ contour : npt.NDArray[np.float_]
43
+ Contour to buffer and clean. A 2d array of shape (n, 2) where n is the number
44
+ of vertices in the contour.
45
+ min_area : float
46
+ Minimum area of the polygon. If the area of the buffered contour is less than
47
+ this, return None.
48
+ convex_hull : bool
49
+ Whether to take the convex hull of the buffered contour.
50
+ epsilon : float
51
+ Epsilon value for polygon simplification. If 0, no simplification is performed.
52
+ precision : int | None
53
+ Precision of the output polygon. If None, no rounding is performed.
54
+ buffer : float
55
+ Buffer distance.
56
+ is_exterior : bool, optional
57
+ Whether the contour is an exterior contour. If True, the contour is buffered
58
+ with a larger buffer distance. The polygon orientation is CCW iff this is True.
59
+
60
+ Returns
61
+ -------
62
+ shapely.Polygon | None
63
+ Buffered and cleaned polygon. If the area of the buffered contour is less than
64
+ ``min_area``, return None.
42
65
  """
66
+ if len(contour) == 1:
67
+ base = shapely.Point(contour)
68
+ elif len(contour) < 4:
69
+ base = shapely.LineString(contour)
70
+ else:
71
+ base = shapely.LinearRing(contour)
72
+
73
+ if is_exterior:
74
+ # The contours computed by openCV go directly over array points
75
+ # with value 1. With marching squares, we expect the contours to
76
+ # be the midpoint between the 0-1 boundary. Apply a small buffer
77
+ # to the exterior contours to account for this.
78
+ polygon = base.buffer(buffer, quad_segs=1)
79
+ else:
80
+ # Only buffer the interiors if necessary
81
+ try:
82
+ polygon = shapely.Polygon(base)
83
+ except shapely.errors.TopologicalError:
84
+ return None
85
+ if not polygon.is_valid:
86
+ polygon = polygon.buffer(buffer / 10, quad_segs=1)
87
+
88
+ assert polygon.is_valid
89
+
90
+ # Remove all interior rings
91
+ if polygon.interiors:
92
+ polygon = shapely.Polygon(polygon.exterior)
93
+
94
+ if polygon.area < min_area:
95
+ return None
96
+
97
+ # Exterior polygons should have CCW orientation
98
+ if is_exterior != polygon.exterior.is_ccw:
99
+ polygon = polygon.reverse()
100
+ if convex_hull:
101
+ polygon = _take_convex_hull(polygon)
102
+ if epsilon:
103
+ polygon = _buffer_simplify_iterate(polygon, epsilon)
43
104
 
44
- def __init__(self, contour: npt.NDArray[np.float_] | None = None):
45
- if contour is not None:
46
- self.contour = contour
47
- self._children: list[NestedContours] = []
48
-
49
- def __iter__(self) -> Iterator[NestedContours]:
50
- return iter(self._children)
51
-
52
- def add(self, child: NestedContours) -> None:
53
- """Add a child contour to this instance.
54
-
55
- Parameters
56
- ----------
57
- child : NestedContours
58
- Child contour to add.
59
- """
60
- self._children.append(child)
61
-
62
- @property
63
- def n_children(self) -> int:
64
- """Get the number of children."""
65
- return len(self._children)
66
-
67
- @property
68
- def n_vertices(self) -> int:
69
- """Get the number of vertices in :attr:`contour`."""
70
- return len(getattr(self, "contour", []))
71
-
72
- def __repr__(self) -> str:
73
- if hasattr(self, "contour"):
74
- out = f"Contour with {self.n_vertices:3} vertices and {self.n_children} children"
75
- else:
76
- out = f"Top level NestedContours instance with {self.n_children} children"
77
-
78
- for c in self:
79
- out += "\n" + "\n".join([" " + line for line in repr(c).split("\n")])
80
- return out
105
+ if precision is not None:
106
+ while precision < 10:
107
+ out = _round_polygon(polygon, precision)
108
+ if out.is_valid:
109
+ return out
110
+ precision += 1
81
111
 
112
+ warnings.warn("Could not round polygon to a valid geometry.")
82
113
 
83
- def calc_exterior_contours(
84
- arr: npt.NDArray[np.float_],
85
- threshold: float,
86
- min_area: float,
87
- epsilon: float,
88
- convex_hull: bool,
89
- positive_orientation: str,
90
- ) -> list[npt.NDArray[np.float_]]:
91
- """Calculate exterior contours of the padded array ``arr``.
92
-
93
- This function removes degenerate contours (contours with fewer than 3 vertices) and
94
- contours that are not closed.
114
+ return polygon
95
115
 
96
- This function proceeds as follows:
97
116
 
98
- #. Determine all contours at the given ``threshold``.
99
- #. Convert each contour to a mask and take the union of all masks.
100
- #. Fill any array values inside the mask lower than the ``threshold`` with some nominal large
101
- value (such as ``np.inf`` or ``np.max(arr)``). This ensures every value inside the mask
102
- is above the ``threshold``, and every value outside the mask is below the ``threshold``.
103
- #. Recalculate all contours of the modified array. This will now contain only contours
104
- exterior contours of the original array.
105
- #. Return "clean" contours. See :func:`clean_contours` for details.
117
+ def _round_polygon(polygon: shapely.Polygon, precision: int) -> shapely.Polygon:
118
+ """Round the coordinates of a polygon.
106
119
 
107
120
  Parameters
108
121
  ----------
109
- arr : npt.NDArray[np.float_]
110
- Padded array. Assumed to have dtype ``float``, to be padded with some constant value
111
- below ``threshold``, and not to contain any nan values. These assumptions are *NOT*
112
- checked.
113
- threshold : float
114
- Threshold value for contour creation.
115
- min_area: float | None
116
- Minimum area of a contour to be considered. Passed into :func:`clean_contours`.
117
- epsilon : float
118
- Passed as ``tolerance`` parameter into :func:`shapely.simplify`.
119
- convex_hull : bool
120
- Passed into :func:`clean_contours`.
121
- positive_orientation: {"high", "low"}
122
- Passed into :func:`skimage.measure.find_contours`
122
+ polygon : shapely.Polygon
123
+ Polygon to round.
124
+ precision : int
125
+ Precision to use when rounding.
123
126
 
124
127
  Returns
125
128
  -------
126
- list[npt.NDArray[np.float_]]
127
- List of exterior contours.
129
+ shapely.Polygon
130
+ Polygon with rounded coordinates.
128
131
  """
129
- fully_connected = "low" if positive_orientation == "high" else "high"
130
- kwargs = {
131
- "level": threshold,
132
- "positive_orientation": positive_orientation,
133
- "fully_connected": fully_connected,
134
- }
135
- contours = measure.find_contours(arr, **kwargs)
136
-
137
- # The snippet below is a little faster (~1.5x) than using draw.polygon2mask
138
- # Under the hood, draw.polygon2mask does the exact same thing as here
139
- mask = np.zeros(arr.shape, dtype=bool)
140
- for c in contours:
141
- rr, cc = draw.polygon(*c.T, shape=arr.shape)
142
- mask[rr, cc] = True
132
+ if polygon.is_empty:
133
+ return polygon
143
134
 
144
- marr = arr.copy()
145
- # I've gone back and forth on the "correct" fill value here
146
- # It is somewhat important for continuous data because measure.find_contours
147
- # does an interpolation under the hood, and this value *might* play a role there
148
- # np.max(arr) seems safer than np.inf, and is compatible with integer dtype
149
- marr[mask & (arr <= threshold)] = np.max(arr)
135
+ exterior = np.round(np.asarray(polygon.exterior.coords), precision)
136
+ interiors = [np.round(np.asarray(i.coords), precision) for i in polygon.interiors]
137
+ return shapely.Polygon(exterior, interiors)
150
138
 
151
- # After setting interior points to some high value, when we recalculate
152
- # contours we are left with just exterior contours
153
- contours = measure.find_contours(marr, **kwargs)
154
139
 
155
- return clean_contours(contours, min_area, epsilon, convex_hull)
156
-
157
-
158
- def find_contours_to_depth(
159
- arr: npt.NDArray[np.float_],
160
- threshold: float,
140
+ def _contours_to_polygons(
141
+ contours: tuple[npt.NDArray[np.float_], ...],
142
+ hierarchy: npt.NDArray[np.int_],
161
143
  min_area: float,
162
- min_area_to_iterate: float,
144
+ convex_hull: bool,
163
145
  epsilon: float,
164
- depth: int,
165
- convex_hull: bool = False,
166
- positive_orientation: str = "high",
167
- root: NestedContours | None = None,
168
- ) -> NestedContours:
169
- """Find nested contours up to a given depth via DFS.
170
-
171
- At a high level, this function proceeds as follows:
172
-
173
- #. Determine all exterior contours at the given ``threshold``
174
- (see :func:`calc_exterior_contours`).
175
- #. For each exterior contour:
176
- #. Convert the contour to a mask
177
- #. Copy and modify the ``arr`` array by filling values outside of the mask with
178
- some nominal large value
179
- #. Negate both the modified array and the threshold value.
180
- #. Recurse by calling this function with these new parameters.
146
+ longitude: npt.NDArray[np.float_] | None,
147
+ latitude: npt.NDArray[np.float_] | None,
148
+ precision: int | None,
149
+ buffer: float,
150
+ i: int = 0,
151
+ ) -> list[shapely.Polygon]:
152
+ """Convert the outputs of :func:`cv2.findContours` to :class:`shapely.Polygon`.
181
153
 
182
154
  Parameters
183
155
  ----------
184
- arr : npt.NDArray[np.float_]
185
- Padded array. Assumed to have dtype ``float``, to be padded with some constant value
186
- below ``threshold``, and not to contain any nan values. These assumptions are *NOT*
187
- checked.
188
- threshold : float
189
- Threshold value for contour creation.
156
+ contours : tuple[npt.NDArray[np.float_], ...]
157
+ The contours output from :func:`cv2.findContours`.
158
+ hierarchy : npt.NDArray[np.int_]
159
+ The hierarchy output from :func:`cv2.findContours`.
190
160
  min_area : float
191
- Minimum area of a contour to be considered. See :func:`clean_contours` for details.
192
- min_area_to_iterate : float
193
- Minimum area of a contour to be considered when recursing.
161
+ Minimum area of a polygon to be included in the output.
162
+ convex_hull : bool
163
+ Whether to take the convex hull of each polygon.
194
164
  epsilon : float
195
- Passed as ``tolerance`` parameter into :func:`shapely.simplify`.
196
- depth : int
197
- Depth to which to recurse. For GeoJSON Polygons, this should be 2 in order to
198
- generate Polygons with exterior contours and interior contours.
199
- convex_hull : bool, optional
200
- Passed into :func:`clean_contours`. Default is False.
201
- positive_orientation : {"high", "low"}
202
- Passed into :func:`skimage.measure.find_contours`. By default, "high", meaning
203
- top level exterior contours always have counter-clockwise orientation. This
204
- value of this parameter alternates between "high" and "low" in successive recursive
205
- calls to this function.
206
- root : NestedContours | None, optional
207
- Root node to use. If None, a new root node is created. Used for recursion and
208
- not intended for direct use. Default is None.
165
+ Epsilon value to use when simplifying the polygons.
166
+ longitude : npt.NDArray[np.float_] | None
167
+ Longitude values for the grid.
168
+ latitude : npt.NDArray[np.float_] | None
169
+ Latitude values for the grid.
170
+ precision : int | None
171
+ Precision to use when rounding the coordinates.
172
+ buffer : float
173
+ Buffer to apply to the contours when converting to polygons.
174
+ i : int, optional
175
+ The index of the contour to start with. Defaults to 0.
209
176
 
210
177
  Returns
211
178
  -------
212
- NestedContours
213
- Root node of the contour tree.
179
+ list[shapely.Polygon]
180
+ A list of polygons. Polygons with a parent-child relationship are merged into
181
+ a single polygon.
214
182
  """
215
- if depth == 0:
216
- if root is None:
217
- raise ValueError("Parameter root must be non-None if depth is zero.")
218
- return root
219
-
220
- root = root or NestedContours()
221
- contours = calc_exterior_contours(
222
- arr, threshold, min_area, epsilon, convex_hull, positive_orientation
223
- )
224
- for c in contours:
225
- child = NestedContours(c)
226
-
227
- # When depth == 1, we are at the bottom of the recursion
228
- if depth == 1:
229
- root.add(child)
230
- continue
231
-
232
- # If the area is too small, don't recurse
233
- if shapely.Polygon(c).area < min_area_to_iterate:
234
- root.add(child)
183
+ out = []
184
+ while i != -1:
185
+ child_i, parent_i = hierarchy[i, 2:]
186
+ is_exterior = parent_i == -1
187
+
188
+ contour = contours[i][:, 0, ::-1]
189
+ i = hierarchy[i, 0]
190
+ if longitude is not None and latitude is not None:
191
+ lon_idx = contour[:, 0]
192
+ lat_idx = contour[:, 1]
193
+
194
+ # Calculate interpolated longitude and latitude values and recreate contour
195
+ lon = np.interp(lon_idx, np.arange(longitude.shape[0]), longitude)
196
+ lat = np.interp(lat_idx, np.arange(latitude.shape[0]), latitude)
197
+ contour = np.stack([lon, lat], axis=1)
198
+
199
+ polygon = buffer_and_clean(
200
+ contour,
201
+ min_area,
202
+ convex_hull,
203
+ epsilon,
204
+ precision,
205
+ buffer,
206
+ is_exterior,
207
+ )
208
+ if polygon is None:
235
209
  continue
236
210
 
237
- # Fill points outside exterior contours with high values
238
- marr = np.full_like(arr, np.max(arr))
239
- rr, cc = draw.polygon(*c.T, shape=arr.shape)
240
- marr[rr, cc] = arr[rr, cc] # keep the same interior arr values
241
-
242
- # And the important part: recurse on the negative
243
- child = find_contours_to_depth(
244
- arr=-marr,
245
- threshold=-threshold,
246
- min_area=min_area,
247
- min_area_to_iterate=min_area_to_iterate,
248
- epsilon=epsilon,
249
- depth=depth - 1,
250
- positive_orientation="low" if positive_orientation == "high" else "high",
251
- root=child,
211
+ if child_i != -1:
212
+ holes = _contours_to_polygons(
213
+ contours,
214
+ hierarchy,
215
+ min_area=min_area,
216
+ convex_hull=False,
217
+ epsilon=epsilon,
218
+ longitude=longitude,
219
+ latitude=latitude,
220
+ precision=precision,
221
+ buffer=buffer,
222
+ i=child_i,
223
+ )
224
+
225
+ candidate = shapely.Polygon(polygon.exterior, [h.exterior for h in holes])
226
+ # Abundance of caution: check if the candidate is valid
227
+ # If the candidate isn't valid, ignore all the holes
228
+ # This can happen if there are many holes and the buffer operation
229
+ # causes the holes to overlap
230
+ if candidate.is_valid:
231
+ polygon = candidate
232
+
233
+ out.append(polygon)
234
+ return out
235
+
236
+
237
+ def determine_buffer(longitude: npt.NDArray[np.float_], latitude: npt.NDArray[np.float_]) -> float:
238
+ """Determine the proper buffer size to use when converting to polygons."""
239
+ try:
240
+ d_lon = longitude[1] - longitude[0]
241
+ d_lat = latitude[1] - latitude[0]
242
+ except IndexError as e:
243
+ raise ValueError("Longitude and latitude must each have at least 2 elements.") from e
244
+
245
+ if d_lon != d_lat:
246
+ warnings.warn(
247
+ "Longitude and latitude are not evenly spaced. Buffer size may be inaccurate."
252
248
  )
253
- root.add(child)
249
+ if not np.all(np.diff(longitude) == d_lon):
250
+ warnings.warn("Longitude is not evenly spaced. Buffer size may be inaccurate.")
251
+ if not np.all(np.diff(latitude) == d_lat):
252
+ warnings.warn("Latitude is not evenly spaced. Buffer size may be inaccurate.")
254
253
 
255
- return root
254
+ return min(d_lon, d_lat) / 2
256
255
 
257
256
 
258
- def clean_contours(
259
- contours: list[npt.NDArray[np.float_]],
257
+ def find_multipolygon(
258
+ arr: npt.NDArray[np.float_],
259
+ threshold: float,
260
260
  min_area: float,
261
261
  epsilon: float,
262
- convex_hull: bool,
263
- ) -> list[npt.NDArray[np.float_]]:
264
- """Remove degenerate contours, contours that are not closed, and contours with negligible area.
265
-
266
- This function also calls :func:`shapely.simplify` to simplify the contours.
267
-
268
- .. versionchanged:: 0.38.0
269
-
270
- Apply a smaller buffer when simplifying contours. This allows for changes
271
- to the underlying polygon topology. Previously, any contour topology was
272
- preserved when simplifying.
262
+ interiors: bool = True,
263
+ convex_hull: bool = False,
264
+ longitude: npt.NDArray[np.float_] | None = None,
265
+ latitude: npt.NDArray[np.float_] | None = None,
266
+ precision: int | None = None,
267
+ ) -> shapely.MultiPolygon:
268
+ """Compute a multipolygon from a 2d array.
273
269
 
274
270
  Parameters
275
271
  ----------
276
- contours : list[npt.NDArray[np.float_]]
277
- List of contours to clean.
272
+ arr : npt.NDArray[np.float_]
273
+ Array to convert to a multipolygon. The array will be converted to a binary
274
+ array by comparing each element to `threshold`. This binary array is then
275
+ passed into :func:`cv2.findContours` to find the contours.
276
+ threshold : float
277
+ Threshold to use when converting `arr` to a binary array.
278
278
  min_area : float
279
- Minimum area for a contour to be kept. If 0, this filter is not applied.
279
+ Minimum area of a polygon to be included in the output.
280
280
  epsilon : float
281
- Passed as ``tolerance`` parameter into :func:`shapely.simplify`.
281
+ Epsilon value to use when simplifying the polygons. Passed into shapely's
282
+ :meth:`shapely.geometry.Polygon.simplify` method.
283
+ interiors : bool
284
+ Whether to include interior polygons.
282
285
  convex_hull : bool
283
- If True, use the convex hull of the contour as the simplified contour.
286
+ Experimental. Whether to take the convex hull of each polygon.
287
+ longitude, latitude : npt.NDArray[np.float_], optional
288
+ If provided, the coordinates values corresponding to the dimensions of `arr`.
289
+ The contour coordinates will be converted to longitude-latitude values by indexing
290
+ into this array. Defaults to None.
291
+ precision : int, optional
292
+ If provided, the precision to use when rounding the coordinates. Defaults to None.
284
293
 
285
294
  Returns
286
295
  -------
287
- list[npt.NDArray[np.float_]]
288
- Cleaned list of contours.
296
+ shapely.MultiPolygon
297
+ A multipolygon of the contours.
289
298
  """
290
- lr_list = []
291
- for contour in contours:
292
- if len(contour) <= 3:
293
- continue
294
- lr = shapely.LinearRing(contour)
295
- if not lr.is_valid:
296
- continue
297
- if shapely.Polygon(lr).area < min_area:
298
- continue
299
- if convex_hull:
300
- lr = _take_convex_hull(lr).exterior
301
- if epsilon:
302
- lr = _buffer_simplify_iterate(lr, epsilon)
303
-
304
- lr_list.append(lr)
305
-
306
- # After simplifying, the polygons may not longer be disjoint.
307
- mp = shapely.MultiPolygon([shapely.Polygon(lr) for lr in lr_list])
308
- mp = _make_multipolygon_valid(mp, convex_hull)
309
-
310
- return [np.asarray(p.exterior.coords) for p in mp.geoms]
299
+ if arr.ndim != 2:
300
+ raise ValueError("Array must be 2d")
301
+ assert (longitude is None) == (latitude is None)
302
+ if longitude is not None:
303
+ assert latitude is not None
304
+ assert arr.shape == (*longitude.shape, *latitude.shape)
305
+ buffer = determine_buffer(longitude, latitude)
306
+ else:
307
+ buffer = 0.5
308
+
309
+ arr_bin = np.empty(arr.shape, dtype=np.uint8)
310
+ np.greater_equal(arr, threshold, out=arr_bin)
311
+
312
+ mode = cv2.RETR_CCOMP if interiors else cv2.RETR_EXTERNAL
313
+ contours, hierarchy = cv2.findContours(arr_bin, mode, cv2.CHAIN_APPROX_SIMPLE)
314
+ if not contours:
315
+ return shapely.MultiPolygon()
316
+
317
+ assert len(hierarchy) == 1
318
+ hierarchy = hierarchy[0]
319
+
320
+ polygons = _contours_to_polygons(
321
+ contours,
322
+ hierarchy,
323
+ min_area,
324
+ convex_hull,
325
+ epsilon,
326
+ longitude,
327
+ latitude,
328
+ precision,
329
+ buffer,
330
+ )
331
+ return _make_valid_multipolygon(polygons, convex_hull)
311
332
 
312
333
 
313
- def _take_convex_hull(lr: shapely.LinearRing) -> shapely.Polygon:
334
+ def _take_convex_hull(polygon: shapely.Polygon) -> shapely.Polygon:
314
335
  """Take the convex hull of a linear ring and preserve the orientation.
315
336
 
316
337
  Parameters
317
338
  ----------
318
- lr : shapely.LinearRing
339
+ polygon : shapely.Polygon
319
340
  Linear ring to take the convex hull of.
320
341
 
321
342
  Returns
322
343
  -------
323
- shapely.LinearRing
344
+ shapely.Polygon
324
345
  Convex hull of the input.
325
346
  """
326
- convex_hull = lr.convex_hull
327
- if lr.is_ccw == convex_hull.exterior.is_ccw:
347
+ convex_hull = polygon.convex_hull
348
+ if polygon.exterior.is_ccw == convex_hull.exterior.is_ccw:
328
349
  return convex_hull
329
- return shapely.Polygon(convex_hull.exterior.coords[::-1])
350
+ return shapely.Polygon(convex_hull.exterior.reverse())
330
351
 
331
352
 
332
- def _buffer_simplify_iterate(linear_ring: shapely.LinearRing, epsilon: float) -> shapely.LinearRing:
353
+ def _buffer_simplify_iterate(polygon: shapely.Polygon, epsilon: float) -> shapely.Polygon:
333
354
  """Simplify a linear ring by iterating over a larger buffer.
334
355
 
335
356
  This function calls :func:`shapely.buffer` and :func:`shapely.simplify`
@@ -342,7 +363,7 @@ def _buffer_simplify_iterate(linear_ring: shapely.LinearRing, epsilon: float) ->
342
363
 
343
364
  Parameters
344
365
  ----------
345
- linear_ring : shapely.LinearRing
366
+ polygon : shapely.Polygon
346
367
  Linear ring to simplify.
347
368
  epsilon : float
348
369
  Passed as ``tolerance`` parameter into :func:`shapely.simplify`.
@@ -354,14 +375,20 @@ def _buffer_simplify_iterate(linear_ring: shapely.LinearRing, epsilon: float) ->
354
375
  """
355
376
  # Try to simplify without a buffer first
356
377
  # This seems to be computationally faster
357
- out = linear_ring.simplify(epsilon, preserve_topology=False)
378
+ out = polygon.simplify(epsilon, preserve_topology=False)
379
+
380
+ # In some rare situations, calling simplify actually gives a MultiPolygon.
381
+ # In this case, take the polygon with the largest area
382
+ if isinstance(out, shapely.MultiPolygon):
383
+ out = max(out.geoms, key=lambda x: x.area)
384
+
358
385
  if out.is_simple and out.is_valid:
359
386
  return out
360
387
 
361
388
  # Applying a naive linear_ring.buffer(0) can destroy the polygon completely
362
389
  # https://stackoverflow.com/a/20873812
363
390
 
364
- is_ccw = linear_ring.is_ccw
391
+ is_ccw = polygon.exterior.is_ccw
365
392
 
366
393
  # Values here are somewhat ad hoc: These seem to allow the algorithm to
367
394
  # terminate and are not too computationally expensive
@@ -369,9 +396,9 @@ def _buffer_simplify_iterate(linear_ring: shapely.LinearRing, epsilon: float) ->
369
396
  distance = epsilon * i / 10
370
397
 
371
398
  # Taking the buffer can change the orientation of the contour
372
- out = linear_ring.buffer(distance, join_style="mitre", quad_segs=2).exterior
373
- if out.is_ccw != is_ccw:
374
- out = shapely.LineString(out.coords[::-1])
399
+ out = polygon.buffer(distance, quad_segs=1)
400
+ if out.exterior.is_ccw != is_ccw:
401
+ out = shapely.Polygon(out.exterior.coords[::-1])
375
402
 
376
403
  out = out.simplify(epsilon, preserve_topology=False)
377
404
  if out.is_simple and out.is_valid:
@@ -380,10 +407,12 @@ def _buffer_simplify_iterate(linear_ring: shapely.LinearRing, epsilon: float) ->
380
407
  warnings.warn(
381
408
  f"Could not simplify contour with epsilon {epsilon}. Try passing a smaller epsilon."
382
409
  )
383
- return linear_ring.simplify(epsilon, preserve_topology=True)
410
+ return polygon.simplify(epsilon, preserve_topology=True)
384
411
 
385
412
 
386
- def _make_multipolygon_valid(mp: shapely.MultiPolygon, convex_hull: bool) -> shapely.MultiPolygon:
413
+ def _make_valid_multipolygon(
414
+ polygons: list[shapely.Polygon], convex_hull: bool
415
+ ) -> shapely.MultiPolygon:
387
416
  """Make a multipolygon valid.
388
417
 
389
418
  This function attempts to make a multipolygon valid by iteratively
@@ -392,12 +421,15 @@ def _make_multipolygon_valid(mp: shapely.MultiPolygon, convex_hull: bool) -> sha
392
421
  invalid after 5 attempts, a warning is raised and the last
393
422
  multipolygon is returned.
394
423
 
424
+ This function is needed because simplifying a contour can change the
425
+ geometry of it, which can cause non-disjoint polygons to be created.
426
+
395
427
  .. versionadded:: 0.38.0
396
428
 
397
429
  Parameters
398
430
  ----------
399
- mp : shapely.MultiPolygon
400
- Multipolygon to make valid.
431
+ polygons : list[shapely.Polygon]
432
+ List of polygons to combine into a multipolygon.
401
433
  convex_hull : bool
402
434
  If True, take the convex hull of merged polygons.
403
435
 
@@ -406,15 +438,12 @@ def _make_multipolygon_valid(mp: shapely.MultiPolygon, convex_hull: bool) -> sha
406
438
  shapely.MultiPolygon
407
439
  Valid multipolygon.
408
440
  """
441
+ mp = shapely.MultiPolygon(polygons)
409
442
  if mp.is_empty:
410
443
  return mp
411
444
 
412
- # Get orientation of the first polygon. We assume that all polygons
413
- # share this orientation.
414
- is_ccw = mp.geoms[0].exterior.is_ccw
415
-
416
- n_attemps = 5
417
- for _ in range(n_attemps):
445
+ n_attempts = 5
446
+ for _ in range(n_attempts):
418
447
  if mp.is_valid:
419
448
  return mp
420
449
 
@@ -422,99 +451,60 @@ def _make_multipolygon_valid(mp: shapely.MultiPolygon, convex_hull: bool) -> sha
422
451
  if isinstance(mp, shapely.Polygon):
423
452
  mp = shapely.MultiPolygon([mp])
424
453
 
425
- # Make sure the orientation of the polygons is consistent
426
- # There is a shapely.geometry.polygon.orient function, but it doesn't look any better
427
- if mp.geoms[0].exterior.is_ccw != is_ccw:
428
- mp = shapely.MultiPolygon([shapely.Polygon(p.exterior.coords[::-1]) for p in mp.geoms])
454
+ # Fix the orientation of the polygons
455
+ mp = shapely.MultiPolygon([shapely.geometry.polygon.orient(p, sign=1) for p in mp.geoms])
429
456
 
430
457
  if convex_hull:
431
- mp = shapely.MultiPolygon([_take_convex_hull(p.exterior) for p in mp.geoms])
458
+ mp = shapely.MultiPolygon([_take_convex_hull(p) for p in mp.geoms])
432
459
 
433
460
  warnings.warn(
434
- f"Could not make multipolygon valid after {n_attemps} attempts. "
461
+ f"Could not make multipolygon valid after {n_attempts} attempts. "
435
462
  "According to shapely, the multipolygon is invalid because: "
436
463
  f"{shapely.validation.explain_validity(mp)}"
437
464
  )
438
465
  return mp
439
466
 
440
467
 
441
- def contour_to_lon_lat(
442
- contour: npt.NDArray[np.float_],
443
- longitude: npt.NDArray[np.float_],
444
- latitude: npt.NDArray[np.float_],
468
+ def multipolygon_to_geojson(
469
+ multipolygon: shapely.MultiPolygon,
445
470
  altitude: float | None,
446
- precision: int | None,
447
- ) -> list[list[float]]:
448
- """Convert contour longitude-latitude coordinates.
449
-
450
- This function assumes ``contour`` was created from a padded array of shape
451
- ``(longitude.size + 2, latitude.size + 2)``.
452
-
453
- .. versionchanged:: 0.25.12
454
-
455
- Previous implementation assumed indexes were integers or half-integers.
456
- This is the case for binary arrays, but not for continuous arrays.
457
- The new implementation performs the linear interpolation necessary for
458
- continuous arrays. See :func:`skimage.measure.find_contours`.
459
-
460
- .. versionchanged:: 0.32.1
461
-
462
- Add ``precision`` parameter. Ensure that the returned contour is not
463
- degenerate after rounding.
471
+ properties: dict[str, Any] | None = None,
472
+ ) -> dict[str, Any]:
473
+ """Convert a shapely multipolygon to a GeoJSON feature.
464
474
 
465
475
  Parameters
466
476
  ----------
467
- contour : npt.NDArray[np.float_]
468
- Contour array of shape ``(n, 2)``.
469
- longitude : npt.NDArray[np.float_]
470
- One dimensional array of longitude values.
471
- latitude : npt.NDArray[np.float_]
472
- One dimensional array of latitude values.
473
- altitude : float | None, optional
474
- Altitude value to use for the output. If not provided, the z-coordinate
475
- is not included in the output. Default is None.
476
- precision : int, optional
477
- Number of decimal places to round the longitude and latitude values to.
478
- If None, no rounding is performed. If after rounding, the polygon
479
- becomes degenerate, the rounding is increased by one decimal place.
477
+ multipolygon : shapely.MultiPolygon
478
+ Multipolygon to convert.
479
+ altitude : float or None
480
+ Altitude of the multipolygon. If provided, the multipolygon coordinates
481
+ will be given a z-coordinate.
482
+ properties : dict[str, Any], optional
483
+ Properties to add to the GeoJSON feature.
480
484
 
481
485
  Returns
482
486
  -------
483
- list[list[float]]
484
- Contour array of longitude, latitude values with shape ``(n, 2)`` converted
485
- to a list of lists. The vertices of the returned contours are rounded to some
486
- hard-coded precision to reduce the size of the corresponding JSON output.
487
- If ``altitude`` is provided, the returned list of lists will have shape
488
- ``(n, 3)`` (each vertex includes a z-coordinate).
487
+ dict[str, Any]
488
+ GeoJSON feature with geometry type "MultiPolygon".
489
489
  """
490
- # Account for padding
491
- lon_idx = contour[:, 0] - 1
492
- lat_idx = contour[:, 1] - 1
493
-
494
- # Calculate interpolated longitude and latitude values
495
- lon = np.interp(lon_idx, np.arange(longitude.shape[0]), longitude)
496
- lat = np.interp(lat_idx, np.arange(latitude.shape[0]), latitude)
497
-
498
- # Round to some precision
499
- if precision is not None:
500
- while precision < 10:
501
- rounded_lon = np.round(lon, precision)
502
- rounded_lat = np.round(lat, precision)
503
- lr = shapely.LinearRing(np.stack([rounded_lon, rounded_lat], axis=1))
504
- if lr.is_valid:
505
- lon = rounded_lon
506
- lat = rounded_lat
507
- break
508
- precision += 1
509
- else:
510
- raise RuntimeError("Could not round contour to valid LinearRing.")
511
-
512
- # Include altitude in output if provided
513
- if altitude is None:
514
- arrays = [lon, lat]
515
- else:
516
- alt = np.full_like(lon, altitude).round(1)
517
- arrays = [lon, lat, alt]
518
-
519
- stacked = np.stack(arrays, axis=1)
520
- return stacked.tolist()
490
+ coordinates = []
491
+ for polygon in multipolygon.geoms:
492
+ poly_coords = []
493
+ rings = polygon.exterior, *polygon.interiors
494
+ for ring in rings:
495
+ if altitude is None:
496
+ coords = np.asarray(ring.coords)
497
+ else:
498
+ shape = len(ring.coords), 3
499
+ coords = np.empty(shape)
500
+ coords[:, :2] = ring.coords
501
+ coords[:, 2] = altitude
502
+
503
+ poly_coords.append(coords.tolist())
504
+ coordinates.append(poly_coords)
505
+
506
+ return {
507
+ "type": "Feature",
508
+ "properties": properties or {},
509
+ "geometry": {"type": "MultiPolygon", "coordinates": coordinates},
510
+ }