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 +74 -0
- isolines/__main__.py +5 -0
- isolines/_version.py +1 -0
- isolines/cli.py +172 -0
- isolines/contours.py +342 -0
- isolines/geometry.py +311 -0
- isolines/interpolate.py +227 -0
- isolines/io.py +197 -0
- isolines/levels.py +54 -0
- isolines/marching.py +322 -0
- isolines/py.typed +0 -0
- isolines/transform.py +108 -0
- isolines-0.1.0.dist-info/METADATA +269 -0
- isolines-0.1.0.dist-info/RECORD +18 -0
- isolines-0.1.0.dist-info/WHEEL +5 -0
- isolines-0.1.0.dist-info/entry_points.txt +2 -0
- isolines-0.1.0.dist-info/licenses/LICENSE +21 -0
- isolines-0.1.0.dist-info/top_level.txt +1 -0
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
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
|