isolines 0.1.0__py3-none-any.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.
isolines/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """isolines: contour lines and filled contours (isobands) from grids and points.
2
+
3
+ Quick start::
4
+
5
+ import numpy as np
6
+ import isolines
7
+
8
+ yy, xx = np.mgrid[0:50, 0:50]
9
+ z = np.hypot(xx - 25, yy - 25) # a cone
10
+ lines = isolines.contour_lines(z, [5, 10, 15])
11
+ bands = isolines.isobands(z, [0, 5, 10, 15])
12
+ isolines.write_geojson("contours.geojson", lines + bands)
13
+ """
14
+
15
+ from ._version import __version__
16
+ from .contours import ContourLine, IsoBand, contour_lines, format_label, isobands
17
+ from .geometry import (
18
+ assemble_polygons,
19
+ chaikin,
20
+ douglas_peucker,
21
+ line_length,
22
+ orient_polygon,
23
+ point_in_ring,
24
+ signed_area,
25
+ validate_polygon,
26
+ )
27
+ from .interpolate import GridSpec, idw, linear, nearest, points_to_grid
28
+ from .io import (
29
+ Raster,
30
+ load_grid,
31
+ read_ascii_grid,
32
+ read_points_csv,
33
+ read_raster,
34
+ to_geojson,
35
+ write_ascii_grid,
36
+ write_geojson,
37
+ )
38
+ from .levels import interval_levels, nice_interval, nice_levels
39
+ from .transform import GeoTransform, as_geotransform
40
+
41
+ __all__ = [
42
+ "__version__",
43
+ "ContourLine",
44
+ "IsoBand",
45
+ "contour_lines",
46
+ "isobands",
47
+ "format_label",
48
+ "GeoTransform",
49
+ "as_geotransform",
50
+ "interval_levels",
51
+ "nice_interval",
52
+ "nice_levels",
53
+ "GridSpec",
54
+ "idw",
55
+ "nearest",
56
+ "linear",
57
+ "points_to_grid",
58
+ "Raster",
59
+ "load_grid",
60
+ "read_ascii_grid",
61
+ "write_ascii_grid",
62
+ "read_raster",
63
+ "read_points_csv",
64
+ "to_geojson",
65
+ "write_geojson",
66
+ "signed_area",
67
+ "line_length",
68
+ "point_in_ring",
69
+ "chaikin",
70
+ "douglas_peucker",
71
+ "assemble_polygons",
72
+ "orient_polygon",
73
+ "validate_polygon",
74
+ ]
isolines/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
isolines/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
isolines/cli.py ADDED
@@ -0,0 +1,172 @@
1
+ """Command-line interface: ``isolines lines|bands|grid``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import math
7
+ import sys
8
+ from typing import List, Optional, Sequence
9
+
10
+ import numpy as np
11
+
12
+ from ._version import __version__
13
+ from .contours import contour_lines, isobands
14
+ from .interpolate import points_to_grid
15
+ from .io import Raster, load_grid, read_points_csv, write_ascii_grid, write_geojson
16
+ from .levels import interval_levels, nice_levels
17
+
18
+
19
+ def _floats(text: str) -> List[float]:
20
+ out = []
21
+ for part in text.split(","):
22
+ part = part.strip().lower()
23
+ if part in ("-inf", "inf", "+inf"):
24
+ out.append(float(part))
25
+ elif part:
26
+ out.append(float(part))
27
+ return out
28
+
29
+
30
+ def _add_input(p: argparse.ArgumentParser) -> None:
31
+ p.add_argument(
32
+ "input",
33
+ help="grid (.asc, .npy, or any raster via the 'raster' extra) or a point CSV (.csv)",
34
+ )
35
+ p.add_argument("--band", type=int, default=1, help="raster band (default 1)")
36
+ p.add_argument("--nodata", type=float, help="extra no-data value")
37
+ g = p.add_argument_group("point CSV input")
38
+ g.add_argument("--x-col", default="x")
39
+ g.add_argument("--y-col", default="y")
40
+ g.add_argument("--value-col", default="value")
41
+ g.add_argument("--cell-size", type=float, help="grid cell size for point input")
42
+ g.add_argument("--method", choices=["idw", "nearest", "linear"], default="idw")
43
+ g.add_argument("--power", type=float, default=2.0, help="IDW power (default 2)")
44
+ g.add_argument("--radius", type=float, help="IDW search radius / nearest max distance")
45
+ g.add_argument("--margin", type=float, default=0.0, help="grid margin around the points")
46
+
47
+
48
+ def _load(args: argparse.Namespace) -> Raster:
49
+ if str(args.input).lower().endswith(".csv"):
50
+ if not args.cell_size:
51
+ raise SystemExit("point CSV input needs --cell-size")
52
+ x, y, v = read_points_csv(args.input, args.x_col, args.y_col, args.value_col)
53
+ kwargs = {}
54
+ if args.method == "idw":
55
+ kwargs = {"power": args.power, "radius": args.radius}
56
+ elif args.method == "nearest":
57
+ kwargs = {"max_distance": args.radius}
58
+ z, gt = points_to_grid(
59
+ x, y, v, args.cell_size, method=args.method, margin=args.margin, **kwargs
60
+ )
61
+ return Raster(z, gt)
62
+ return load_grid(args.input, args.band)
63
+
64
+
65
+ def _add_output(p: argparse.ArgumentParser) -> None:
66
+ p.add_argument("-o", "--output", required=True, help="output GeoJSON path")
67
+ p.add_argument("--smooth", type=int, default=0, help="Chaikin iterations (default 0)")
68
+ p.add_argument("--simplify", type=float, default=0.0, help="Douglas-Peucker tolerance")
69
+ p.add_argument("--precision", type=int, help="round coordinates to N decimals")
70
+ p.add_argument("--label-format", default="{:g}", help="label pattern (default {:g})")
71
+
72
+
73
+ def _levels(args: argparse.Namespace, z: np.ndarray, attr: str) -> List[float]:
74
+ explicit = getattr(args, attr)
75
+ if explicit:
76
+ return _floats(explicit)
77
+ finite = z[np.isfinite(z)]
78
+ if finite.size == 0:
79
+ return []
80
+ if args.interval:
81
+ return interval_levels(float(finite.min()), float(finite.max()), args.interval, args.base)
82
+ return nice_levels(finite)
83
+
84
+
85
+ def build_parser() -> argparse.ArgumentParser:
86
+ parser = argparse.ArgumentParser(
87
+ prog="isolines",
88
+ description="Contour lines and filled contours (isobands) as GeoJSON.",
89
+ )
90
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
91
+ sub = parser.add_subparsers(dest="command", required=True)
92
+
93
+ p_lines = sub.add_parser("lines", help="contour lines (LineString features)")
94
+ _add_input(p_lines)
95
+ p_lines.add_argument("--levels", help="comma-separated levels")
96
+ p_lines.add_argument("--interval", type=float, help="contour interval")
97
+ p_lines.add_argument("--base", type=float, default=0.0, help="interval offset (default 0)")
98
+ p_lines.add_argument("--min-length", type=float, default=0.0, help="drop shorter lines")
99
+ _add_output(p_lines)
100
+
101
+ p_bands = sub.add_parser("bands", help="filled contours (MultiPolygon features)")
102
+ _add_input(p_bands)
103
+ p_bands.add_argument("--breaks", help="comma-separated band limits; -inf/inf allowed")
104
+ p_bands.add_argument("--interval", type=float, help="band width")
105
+ p_bands.add_argument("--base", type=float, default=0.0, help="interval offset (default 0)")
106
+ p_bands.add_argument(
107
+ "--open-ends",
108
+ action="store_true",
109
+ help="add -inf and inf so values outside the breaks are covered too",
110
+ )
111
+ p_bands.add_argument("--min-area", type=float, default=0.0, help="drop smaller rings")
112
+ _add_output(p_bands)
113
+
114
+ p_grid = sub.add_parser("grid", help="interpolate a point CSV to an Esri ASCII grid")
115
+ p_grid.add_argument("input", help="point CSV")
116
+ p_grid.add_argument("-o", "--output", required=True, help="output .asc path")
117
+ p_grid.add_argument("--x-col", default="x")
118
+ p_grid.add_argument("--y-col", default="y")
119
+ p_grid.add_argument("--value-col", default="value")
120
+ p_grid.add_argument("--cell-size", type=float, required=True)
121
+ p_grid.add_argument("--method", choices=["idw", "nearest", "linear"], default="idw")
122
+ p_grid.add_argument("--power", type=float, default=2.0)
123
+ p_grid.add_argument("--radius", type=float)
124
+ p_grid.add_argument("--margin", type=float, default=0.0)
125
+ p_grid.add_argument("--band", type=int, default=1, help=argparse.SUPPRESS)
126
+ return parser
127
+
128
+
129
+ def main(argv: Optional[Sequence[str]] = None) -> int:
130
+ args = build_parser().parse_args(argv)
131
+ try:
132
+ raster = _load(args)
133
+ if args.command == "grid":
134
+ assert raster.transform is not None
135
+ write_ascii_grid(args.output, raster.z, raster.transform)
136
+ print(f"wrote {raster.z.shape[0]} x {raster.z.shape[1]} grid to {args.output}")
137
+ return 0
138
+ common = {
139
+ "transform": raster.transform,
140
+ "nodata": args.nodata,
141
+ "smooth": args.smooth,
142
+ "simplify": args.simplify,
143
+ "label_format": args.label_format,
144
+ }
145
+ if args.command == "lines":
146
+ levels = _levels(args, raster.z, "levels")
147
+ items: list = (
148
+ contour_lines(raster.z, levels, min_length=args.min_length, **common)
149
+ if levels
150
+ else []
151
+ )
152
+ kind = "lines"
153
+ else:
154
+ breaks = _levels(args, raster.z, "breaks")
155
+ if args.open_ends:
156
+ breaks = sorted(set(breaks) | {-math.inf, math.inf})
157
+ if len(breaks) < 2:
158
+ raise ValueError("need at least two breaks (try --open-ends)")
159
+ items = isobands(raster.z, breaks, min_area=args.min_area, **common)
160
+ kind = "bands"
161
+ write_geojson(args.output, items, precision=args.precision)
162
+ print(f"wrote {len(items)} {kind} to {args.output}")
163
+ if raster.crs:
164
+ print(f"coordinates are in {raster.crs}")
165
+ return 0
166
+ except (OSError, ValueError, ImportError) as exc:
167
+ print(f"isolines: error: {exc}", file=sys.stderr)
168
+ return 1
169
+
170
+
171
+ if __name__ == "__main__": # pragma: no cover
172
+ sys.exit(main())
isolines/contours.py ADDED
@@ -0,0 +1,342 @@
1
+ """Public contouring API: contour lines and filled contours (isobands)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
8
+
9
+ import numpy as np
10
+
11
+ from .geometry import (
12
+ assemble_polygons,
13
+ chaikin,
14
+ dedupe,
15
+ douglas_peucker,
16
+ line_length,
17
+ orient_polygon,
18
+ signed_area,
19
+ )
20
+ from .marching import Key, MarchingGrid, chain_rings, chain_segments
21
+ from .transform import GeoTransform, as_geotransform
22
+
23
+ __all__ = ["ContourLine", "IsoBand", "contour_lines", "isobands", "format_label"]
24
+
25
+ ArrayLike = Any
26
+ Mapper = Callable[[np.ndarray], np.ndarray]
27
+ # Rings smaller than this (in grid cells) only arise around single nodes that
28
+ # lie exactly on a level (see marching.T_EPS) and are dropped.
29
+ _DEGENERATE = 1e-5
30
+
31
+
32
+ def format_label(value: Optional[float], label_format: str = "{:g}") -> str:
33
+ """Format a level for a label; infinite or missing values give ``""``."""
34
+ if value is None or not math.isfinite(value):
35
+ return ""
36
+ return label_format.format(value)
37
+
38
+
39
+ def _json_number(value: float) -> Optional[float]:
40
+ return float(value) if math.isfinite(value) else None
41
+
42
+
43
+ def _round(coords: np.ndarray, precision: Optional[int]) -> list:
44
+ arr = coords if precision is None else np.round(coords, precision)
45
+ out: list = arr.tolist()
46
+ return out
47
+
48
+
49
+ @dataclass
50
+ class ContourLine:
51
+ """One continuous contour line.
52
+
53
+ ``coords`` is an ``(n, 2)`` array in output coordinates. Lines are
54
+ oriented with higher values on the left. ``closed`` lines repeat their
55
+ first position at the end.
56
+ """
57
+
58
+ level: float
59
+ coords: np.ndarray
60
+ closed: bool
61
+ label: str = ""
62
+
63
+ @property
64
+ def length(self) -> float:
65
+ """Polyline length in output units."""
66
+ return line_length(self.coords)
67
+
68
+ def to_feature(self, precision: Optional[int] = None) -> Dict[str, Any]:
69
+ """GeoJSON ``Feature`` with a ``LineString`` geometry."""
70
+ return {
71
+ "type": "Feature",
72
+ "geometry": {"type": "LineString", "coordinates": _round(self.coords, precision)},
73
+ "properties": {
74
+ "level": float(self.level),
75
+ "label": self.label,
76
+ "closed": self.closed,
77
+ "length": self.length,
78
+ },
79
+ }
80
+
81
+
82
+ @dataclass
83
+ class IsoBand:
84
+ """All polygons where ``lower <= z < upper``.
85
+
86
+ ``polygons`` is a list of polygons; each polygon is a list of closed rings,
87
+ exterior first (counter-clockwise) then holes (clockwise), following
88
+ RFC 7946 section 3.1.6.
89
+ """
90
+
91
+ lower: float
92
+ upper: float
93
+ polygons: List[List[np.ndarray]] = field(default_factory=list)
94
+ label: str = ""
95
+
96
+ @property
97
+ def area(self) -> float:
98
+ """Total area (exteriors minus holes) in squared output units."""
99
+ return float(
100
+ sum(
101
+ abs(signed_area(r)) * (1 if i == 0 else -1)
102
+ for poly in self.polygons
103
+ for i, r in enumerate(poly)
104
+ )
105
+ )
106
+
107
+ def to_feature(self, precision: Optional[int] = None) -> Dict[str, Any]:
108
+ """GeoJSON ``Feature`` with a ``MultiPolygon`` geometry."""
109
+ return {
110
+ "type": "Feature",
111
+ "geometry": {
112
+ "type": "MultiPolygon",
113
+ "coordinates": [[_round(r, precision) for r in poly] for poly in self.polygons],
114
+ },
115
+ "properties": {
116
+ "lower": _json_number(self.lower),
117
+ "upper": _json_number(self.upper),
118
+ "label": self.label,
119
+ "area": self.area,
120
+ },
121
+ }
122
+
123
+
124
+ # --------------------------------------------------------------------- helpers
125
+ def _prepare(z: ArrayLike, nodata: Optional[float]) -> np.ndarray:
126
+ if np.ma.isMaskedArray(z):
127
+ arr = np.ma.filled(np.ma.asarray(z).astype(float), np.nan)
128
+ else:
129
+ arr = np.array(z, dtype=float, copy=True)
130
+ if nodata is not None and not (isinstance(nodata, float) and math.isnan(nodata)):
131
+ arr[arr == nodata] = np.nan
132
+ arr[~np.isfinite(arr)] = np.nan
133
+ return arr
134
+
135
+
136
+ def _make_mapper(
137
+ shape: Tuple[int, int],
138
+ x: Optional[ArrayLike],
139
+ y: Optional[ArrayLike],
140
+ transform: Optional[Union[GeoTransform, Sequence[float], Any]],
141
+ pixel_is_area: bool,
142
+ ) -> Mapper:
143
+ nrows, ncols = shape
144
+ if transform is not None:
145
+ if x is not None or y is not None:
146
+ raise ValueError("give either transform or x/y coordinates, not both")
147
+ gt = as_geotransform(transform)
148
+ off = 0.5 if pixel_is_area else 0.0
149
+
150
+ def map_gt(pts: np.ndarray) -> np.ndarray:
151
+ wx, wy = gt.apply(pts[:, 0] + off, pts[:, 1] + off)
152
+ return np.column_stack([wx, wy])
153
+
154
+ return map_gt
155
+ xs = np.arange(ncols, dtype=float) if x is None else np.asarray(x, dtype=float)
156
+ ys = np.arange(nrows, dtype=float) if y is None else np.asarray(y, dtype=float)
157
+ if xs.shape != (ncols,) or ys.shape != (nrows,):
158
+ raise ValueError(f"x must have {ncols} values and y {nrows} values (one per column/row)")
159
+ for name, v in (("x", xs), ("y", ys)):
160
+ d = np.diff(v)
161
+ if not (np.all(d > 0) or np.all(d < 0)):
162
+ raise ValueError(f"{name} coordinates must be strictly monotonic")
163
+ cols = np.arange(ncols, dtype=float)
164
+ rows = np.arange(nrows, dtype=float)
165
+
166
+ def map_xy(pts: np.ndarray) -> np.ndarray:
167
+ return np.column_stack([np.interp(pts[:, 0], cols, xs), np.interp(pts[:, 1], rows, ys)])
168
+
169
+ return map_xy
170
+
171
+
172
+ def _orientation(mapper: Mapper) -> float:
173
+ p = mapper(np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]))
174
+ return float(
175
+ (p[1, 0] - p[0, 0]) * (p[2, 1] - p[0, 1]) - (p[1, 1] - p[0, 1]) * (p[2, 0] - p[0, 0])
176
+ )
177
+
178
+
179
+ def _levels(levels: Union[float, Sequence[float], np.ndarray], allow_inf: bool) -> List[float]:
180
+ vals = np.atleast_1d(np.asarray(levels, dtype=float))
181
+ if np.isnan(vals).any():
182
+ raise ValueError("levels must not contain NaN")
183
+ if not allow_inf and not np.isfinite(vals).all():
184
+ raise ValueError("contour levels must be finite")
185
+ return sorted({float(v) for v in vals})
186
+
187
+
188
+ # ------------------------------------------------------------------------ API
189
+ def contour_lines(
190
+ z: ArrayLike,
191
+ levels: Union[float, Sequence[float], np.ndarray],
192
+ *,
193
+ x: Optional[ArrayLike] = None,
194
+ y: Optional[ArrayLike] = None,
195
+ transform: Optional[Any] = None,
196
+ nodata: Optional[float] = None,
197
+ pixel_is_area: bool = True,
198
+ smooth: int = 0,
199
+ simplify: float = 0.0,
200
+ min_length: float = 0.0,
201
+ label_format: str = "{:g}",
202
+ ) -> List[ContourLine]:
203
+ """Extract contour lines from a 2-D grid with marching squares.
204
+
205
+ Parameters
206
+ ----------
207
+ z:
208
+ 2-D array, ``z[row, col]``. NaN, infinities, masked values and
209
+ ``nodata`` are treated as no-data; lines stop at no-data cells.
210
+ levels:
211
+ One level or a sequence of levels (sorted and de-duplicated).
212
+ x, y:
213
+ Optional 1-D, strictly monotonic coordinates of the columns and rows.
214
+ transform:
215
+ Optional affine georeferencing: a :class:`GeoTransform`, a GDAL
216
+ 6-tuple or a rasterio ``Affine``. With ``pixel_is_area=True``
217
+ (default, GDAL/rasterio convention) the value of ``z[i, j]`` is placed
218
+ at the centre of pixel ``(i, j)``. Without ``x``/``y``/``transform``,
219
+ output coordinates are ``(col, row)`` indices.
220
+ smooth:
221
+ Number of Chaikin smoothing iterations (0 = off).
222
+ simplify:
223
+ Douglas-Peucker tolerance in output units (0 = off).
224
+ min_length:
225
+ Drop lines shorter than this (output units).
226
+ label_format:
227
+ ``str.format`` pattern for the ``label`` attribute.
228
+
229
+ Returns
230
+ -------
231
+ list of :class:`ContourLine`, ordered by level, oriented with higher
232
+ values on the left; lines touching the grid or no-data edge are open,
233
+ others closed.
234
+ """
235
+ arr = _prepare(z, nodata)
236
+ lv = _levels(levels, allow_inf=False)
237
+ grid = MarchingGrid(arr)
238
+ mapper = _make_mapper(arr.shape, x, y, transform, pixel_is_area)
239
+ flip = _orientation(mapper) < 0
240
+ out: List[ContourLine] = []
241
+ for k, level in enumerate(lv):
242
+ segs = grid.level_segments(k, level)
243
+ if not segs:
244
+ continue
245
+ for keys, closed in chain_segments(segs):
246
+ idx_pts = grid.key_coords(keys, lv)
247
+ if closed and np.ptp(idx_pts, axis=0).max() < _DEGENERATE:
248
+ continue # ring around a single node lying exactly on the level
249
+ pts = dedupe(mapper(idx_pts))
250
+ if flip:
251
+ pts = pts[::-1].copy()
252
+ if smooth:
253
+ pts = chaikin(pts, smooth, closed)
254
+ if simplify:
255
+ pts = douglas_peucker(pts, simplify, closed)
256
+ if len(pts) < 2 or (closed and len(pts) < 4):
257
+ continue
258
+ line = ContourLine(level, pts, closed, format_label(level, label_format))
259
+ if min_length and line.length < min_length:
260
+ continue
261
+ out.append(line)
262
+ return out
263
+
264
+
265
+ def isobands(
266
+ z: ArrayLike,
267
+ breaks: Union[Sequence[float], np.ndarray],
268
+ *,
269
+ x: Optional[ArrayLike] = None,
270
+ y: Optional[ArrayLike] = None,
271
+ transform: Optional[Any] = None,
272
+ nodata: Optional[float] = None,
273
+ pixel_is_area: bool = True,
274
+ smooth: int = 0,
275
+ simplify: float = 0.0,
276
+ min_area: float = 0.0,
277
+ keep_empty: bool = False,
278
+ label_format: str = "{:g}",
279
+ ) -> List[IsoBand]:
280
+ """Filled contours: polygons with holes for each band ``[b_k, b_(k+1))``.
281
+
282
+ ``breaks`` are the band limits (at least two; ``-inf``/``inf`` allowed for
283
+ open-ended bands). Band boundaries are the oriented marching-squares
284
+ contours of the two limits plus the edge of the valid (non-no-data) grid
285
+ domain, so the bands of all intervals tile the valid domain without gaps
286
+ or overlaps. Polygons are returned with RFC 7946 orientation (exterior
287
+ counter-clockwise, holes clockwise) in output coordinates.
288
+
289
+ ``smooth`` and ``simplify`` are applied ring by ring; they are not
290
+ topology-preserving, so neighbouring bands may then slightly overlap.
291
+ Other parameters as in :func:`contour_lines`. Rings with an area
292
+ ``<= min_area`` (squared output units) are dropped.
293
+ """
294
+ arr = _prepare(z, nodata)
295
+ b = _levels(breaks, allow_inf=True)
296
+ if len(b) < 2:
297
+ raise ValueError("isobands needs at least two distinct breaks")
298
+ grid = MarchingGrid(arr)
299
+ mapper = _make_mapper(arr.shape, x, y, transform, pixel_is_area)
300
+ out: List[IsoBand] = []
301
+ for k in range(len(b) - 1):
302
+ lo, hi = b[k], b[k + 1]
303
+ segs = grid.band_segments(k, lo, k + 1, hi)
304
+ polygons: List[List[np.ndarray]] = []
305
+ if segs:
306
+ keys: List[Key] = list({key for seg in segs for key in seg})
307
+ xy = grid.key_coords(keys, b)
308
+ coords_of = {key: (float(p[0]), float(p[1])) for key, p in zip(keys, xy)}
309
+ rings_idx = []
310
+ for ring in chain_rings(segs, coords_of):
311
+ pts = dedupe(np.array([coords_of[key] for key in ring]))
312
+ if len(pts) >= 4:
313
+ rings_idx.append(pts)
314
+ for poly in assemble_polygons(rings_idx, min_area=_DEGENERATE**2):
315
+ world: List[np.ndarray] = []
316
+ for ring_no, wring in enumerate(orient_polygon([mapper(r) for r in poly])):
317
+ r = dedupe(wring)
318
+ if smooth:
319
+ r = chaikin(r, smooth, closed=True)
320
+ if simplify:
321
+ r = douglas_peucker(r, simplify, closed=True)
322
+ if len(r) < 4 or abs(signed_area(r)) <= min_area:
323
+ if ring_no == 0:
324
+ break
325
+ continue
326
+ world.append(r)
327
+ if world:
328
+ polygons.append(orient_polygon(world))
329
+ if polygons or keep_empty:
330
+ label = (
331
+ format_label(lo, label_format) + "–" + format_label(hi, label_format)
332
+ if math.isfinite(lo) and math.isfinite(hi)
333
+ else (
334
+ f"< {format_label(hi, label_format)}"
335
+ if math.isfinite(hi)
336
+ else f">= {format_label(lo, label_format)}"
337
+ if math.isfinite(lo)
338
+ else "all"
339
+ )
340
+ )
341
+ out.append(IsoBand(lo, hi, polygons, label))
342
+ return out