pycontrails 0.40.1__cp311-cp311-macosx_11_0_arm64.whl → 0.42.0__cp311-cp311-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.
- pycontrails/_version.py +2 -2
- pycontrails/core/airports.py +228 -0
- pycontrails/core/datalib.py +8 -4
- pycontrails/core/fleet.py +13 -13
- pycontrails/core/flight.py +311 -86
- pycontrails/core/met.py +78 -78
- pycontrails/core/polygon.py +329 -339
- pycontrails/core/rgi_cython.cpython-311-darwin.so +0 -0
- pycontrails/core/vector.py +63 -51
- pycontrails/datalib/__init__.py +1 -1
- pycontrails/datalib/spire/__init__.py +19 -0
- pycontrails/datalib/spire/spire.py +739 -0
- pycontrails/models/cocip/wind_shear.py +2 -2
- pycontrails/models/emissions/emissions.py +1 -1
- pycontrails/models/humidity_scaling.py +1 -1
- pycontrails/models/issr.py +1 -1
- pycontrails/models/pcr.py +1 -1
- pycontrails/models/sac.py +5 -5
- pycontrails/physics/geo.py +3 -2
- pycontrails/physics/jet.py +66 -113
- {pycontrails-0.40.1.dist-info → pycontrails-0.42.0.dist-info}/METADATA +2 -1
- {pycontrails-0.40.1.dist-info → pycontrails-0.42.0.dist-info}/RECORD +26 -23
- {pycontrails-0.40.1.dist-info → pycontrails-0.42.0.dist-info}/LICENSE +0 -0
- {pycontrails-0.40.1.dist-info → pycontrails-0.42.0.dist-info}/NOTICE +0 -0
- {pycontrails-0.40.1.dist-info → pycontrails-0.42.0.dist-info}/WHEEL +0 -0
- {pycontrails-0.40.1.dist-info → pycontrails-0.42.0.dist-info}/top_level.txt +0 -0
pycontrails/core/polygon.py
CHANGED
|
@@ -9,327 +9,348 @@ See Also
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import warnings
|
|
12
|
-
from typing import
|
|
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 '
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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_]
|
|
40
|
-
Contour to
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
127
|
-
|
|
129
|
+
shapely.Polygon
|
|
130
|
+
Polygon with rounded coordinates.
|
|
128
131
|
"""
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
+
convex_hull: bool,
|
|
163
145
|
epsilon: float,
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
|
254
|
+
return min(d_lon, d_lat) / 2
|
|
256
255
|
|
|
257
256
|
|
|
258
|
-
def
|
|
259
|
-
|
|
257
|
+
def find_multipolygon(
|
|
258
|
+
arr: npt.NDArray[np.float_],
|
|
259
|
+
threshold: float,
|
|
260
260
|
min_area: float,
|
|
261
261
|
epsilon: float,
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
|
279
|
+
Minimum area of a polygon to be included in the output.
|
|
280
280
|
epsilon : float
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
296
|
+
shapely.MultiPolygon
|
|
297
|
+
A multipolygon of the contours.
|
|
289
298
|
"""
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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(
|
|
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
|
-
|
|
339
|
+
polygon : shapely.Polygon
|
|
319
340
|
Linear ring to take the convex hull of.
|
|
320
341
|
|
|
321
342
|
Returns
|
|
322
343
|
-------
|
|
323
|
-
shapely.
|
|
344
|
+
shapely.Polygon
|
|
324
345
|
Convex hull of the input.
|
|
325
346
|
"""
|
|
326
|
-
convex_hull =
|
|
327
|
-
if
|
|
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.
|
|
350
|
+
return shapely.Polygon(convex_hull.exterior.reverse())
|
|
330
351
|
|
|
331
352
|
|
|
332
|
-
def _buffer_simplify_iterate(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
373
|
-
if out.is_ccw != is_ccw:
|
|
374
|
-
out = shapely.
|
|
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
|
|
410
|
+
return polygon.simplify(epsilon, preserve_topology=True)
|
|
384
411
|
|
|
385
412
|
|
|
386
|
-
def
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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
|
-
#
|
|
426
|
-
|
|
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
|
|
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 {
|
|
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
|
|
442
|
-
|
|
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
|
-
|
|
447
|
-
) ->
|
|
448
|
-
"""Convert
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
+
}
|