rastr 0.3.0__py3-none-any.whl → 0.5.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.
Potentially problematic release.
This version of rastr might be problematic. Click here for more details.
- rastr/_version.py +2 -2
- rastr/create.py +24 -14
- rastr/io.py +5 -5
- rastr/raster.py +579 -102
- {rastr-0.3.0.dist-info → rastr-0.5.0.dist-info}/METADATA +10 -4
- {rastr-0.3.0.dist-info → rastr-0.5.0.dist-info}/RECORD +8 -8
- {rastr-0.3.0.dist-info → rastr-0.5.0.dist-info}/WHEEL +0 -0
- {rastr-0.3.0.dist-info → rastr-0.5.0.dist-info}/licenses/LICENSE +0 -0
rastr/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.5.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 5, 0)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
rastr/create.py
CHANGED
|
@@ -13,7 +13,7 @@ from shapely.geometry import Point
|
|
|
13
13
|
|
|
14
14
|
from rastr.gis.fishnet import create_point_grid, get_point_grid_shape
|
|
15
15
|
from rastr.meta import RasterMeta
|
|
16
|
-
from rastr.raster import
|
|
16
|
+
from rastr.raster import Raster
|
|
17
17
|
|
|
18
18
|
if TYPE_CHECKING:
|
|
19
19
|
from collections.abc import Iterable
|
|
@@ -49,9 +49,9 @@ def raster_distance_from_polygon(
|
|
|
49
49
|
*,
|
|
50
50
|
raster_meta: RasterMeta,
|
|
51
51
|
extent_polygon: Polygon | None = None,
|
|
52
|
-
snap_raster:
|
|
52
|
+
snap_raster: Raster | None = None,
|
|
53
53
|
show_pbar: bool = False,
|
|
54
|
-
) ->
|
|
54
|
+
) -> Raster:
|
|
55
55
|
"""Make a raster where each cell's value is its centre's distance to a polygon.
|
|
56
56
|
|
|
57
57
|
The raster should use a projected coordinate system.
|
|
@@ -116,7 +116,7 @@ def raster_distance_from_polygon(
|
|
|
116
116
|
distances = np.where(mask, np.array([polygon.distance(pt) for pt in _pts]), np.nan)
|
|
117
117
|
distance_raster = distances.reshape(x.shape)
|
|
118
118
|
|
|
119
|
-
return
|
|
119
|
+
return Raster(arr=distance_raster, raster_meta=raster_meta)
|
|
120
120
|
|
|
121
121
|
|
|
122
122
|
def _pbar(iterable: Iterable[_T], *, desc: str | None = None) -> Iterable[_T]:
|
|
@@ -130,11 +130,11 @@ def full_raster(
|
|
|
130
130
|
*,
|
|
131
131
|
bounds: tuple[float, float, float, float],
|
|
132
132
|
fill_value: float = np.nan,
|
|
133
|
-
) ->
|
|
133
|
+
) -> Raster:
|
|
134
134
|
"""Create a raster with a specified fill value for all cells."""
|
|
135
135
|
shape = get_point_grid_shape(bounds=bounds, cell_size=raster_meta.cell_size)
|
|
136
136
|
arr = np.full(shape, fill_value, dtype=np.float32)
|
|
137
|
-
return
|
|
137
|
+
return Raster(arr=arr, raster_meta=raster_meta)
|
|
138
138
|
|
|
139
139
|
|
|
140
140
|
def rasterize_gdf(
|
|
@@ -142,7 +142,7 @@ def rasterize_gdf(
|
|
|
142
142
|
*,
|
|
143
143
|
raster_meta: RasterMeta,
|
|
144
144
|
target_cols: list[str],
|
|
145
|
-
) -> list[
|
|
145
|
+
) -> list[Raster]:
|
|
146
146
|
"""Rasterize geometries from a GeoDataFrame.
|
|
147
147
|
|
|
148
148
|
Supports polygons, points, linestrings, and other geometry types.
|
|
@@ -205,8 +205,8 @@ def rasterize_gdf(
|
|
|
205
205
|
dtype=np.float32,
|
|
206
206
|
)
|
|
207
207
|
|
|
208
|
-
# Create
|
|
209
|
-
raster =
|
|
208
|
+
# Create Raster
|
|
209
|
+
raster = Raster(arr=raster_array, raster_meta=raster_meta)
|
|
210
210
|
rasters.append(raster)
|
|
211
211
|
|
|
212
212
|
return rasters
|
|
@@ -286,7 +286,7 @@ def raster_from_point_cloud(
|
|
|
286
286
|
*,
|
|
287
287
|
crs: CRS | str,
|
|
288
288
|
cell_size: float | None = None,
|
|
289
|
-
) ->
|
|
289
|
+
) -> Raster:
|
|
290
290
|
"""Create a raster from a point cloud via interpolation.
|
|
291
291
|
|
|
292
292
|
Interpolation is only possible within the convex hull of the points. Outside of
|
|
@@ -320,8 +320,18 @@ def raster_from_point_cloud(
|
|
|
320
320
|
if len(x) != len(y) or len(x) != len(z):
|
|
321
321
|
msg = "Length of x, y, and z must be equal."
|
|
322
322
|
raise ValueError(msg)
|
|
323
|
+
xy_finite_mask = np.isfinite(x) & np.isfinite(y)
|
|
324
|
+
if np.any(~xy_finite_mask):
|
|
325
|
+
msg = "Some (x,y) points are NaN-valued or non-finite. These will be ignored."
|
|
326
|
+
warnings.warn(msg, stacklevel=2)
|
|
327
|
+
x = x[xy_finite_mask]
|
|
328
|
+
y = y[xy_finite_mask]
|
|
329
|
+
z = z[xy_finite_mask]
|
|
323
330
|
if len(x) < 3:
|
|
324
|
-
msg =
|
|
331
|
+
msg = (
|
|
332
|
+
"At least three valid (x, y, z) points are required to triangulate a "
|
|
333
|
+
"surface."
|
|
334
|
+
)
|
|
325
335
|
raise ValueError(msg)
|
|
326
336
|
# Check for duplicate (x, y) points
|
|
327
337
|
xy_points = np.column_stack((x, y))
|
|
@@ -332,8 +342,8 @@ def raster_from_point_cloud(
|
|
|
332
342
|
# Heuristic for cell size if not provided
|
|
333
343
|
if cell_size is None:
|
|
334
344
|
# Half the 5th percentile of nearest neighbor distances between the (x,y) points
|
|
335
|
-
tree = KDTree(
|
|
336
|
-
distances, _ = tree.query(
|
|
345
|
+
tree = KDTree(xy_points)
|
|
346
|
+
distances, _ = tree.query(xy_points, k=2)
|
|
337
347
|
distances: np.ndarray
|
|
338
348
|
cell_size = float(np.percentile(distances[distances > 0], 5)) / 2
|
|
339
349
|
|
|
@@ -378,4 +388,4 @@ def raster_from_point_cloud(
|
|
|
378
388
|
crs=crs,
|
|
379
389
|
transform=transform,
|
|
380
390
|
)
|
|
381
|
-
return
|
|
391
|
+
return Raster(arr=arr, raster_meta=raster_meta)
|
rastr/io.py
CHANGED
|
@@ -9,7 +9,7 @@ import rasterio.merge
|
|
|
9
9
|
from pyproj.crs.crs import CRS
|
|
10
10
|
|
|
11
11
|
from rastr.meta import RasterMeta
|
|
12
|
-
from rastr.raster import
|
|
12
|
+
from rastr.raster import Raster
|
|
13
13
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
15
|
from numpy.typing import NDArray
|
|
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
|
|
17
17
|
|
|
18
18
|
def read_raster_inmem(
|
|
19
19
|
raster_path: Path | str, *, crs: CRS | str | None = None
|
|
20
|
-
) ->
|
|
20
|
+
) -> Raster:
|
|
21
21
|
"""Read raster data from a file and return an in-memory Raster object."""
|
|
22
22
|
crs = CRS.from_user_input(crs) if crs is not None else None
|
|
23
23
|
|
|
@@ -35,13 +35,13 @@ def read_raster_inmem(
|
|
|
35
35
|
arr[arr == nodata] = np.nan
|
|
36
36
|
|
|
37
37
|
raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
|
|
38
|
-
raster_obj =
|
|
38
|
+
raster_obj = Raster(arr=arr, raster_meta=raster_meta)
|
|
39
39
|
return raster_obj
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def read_raster_mosaic_inmem(
|
|
43
43
|
mosaic_dir: Path | str, *, glob: str = "*.tif", crs: CRS | None = None
|
|
44
|
-
) ->
|
|
44
|
+
) -> Raster:
|
|
45
45
|
"""Read a raster mosaic from a directory and return an in-memory Raster object.
|
|
46
46
|
|
|
47
47
|
This assumes that all rasters have the same metadata, e.g. coordinate system,
|
|
@@ -87,7 +87,7 @@ def read_raster_mosaic_inmem(
|
|
|
87
87
|
arr = arr.squeeze().astype(np.float64)
|
|
88
88
|
|
|
89
89
|
raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
|
|
90
|
-
raster_obj =
|
|
90
|
+
raster_obj = Raster(arr=arr, raster_meta=raster_meta)
|
|
91
91
|
return raster_obj
|
|
92
92
|
finally:
|
|
93
93
|
for src in sources:
|
rastr/raster.py
CHANGED
|
@@ -4,17 +4,20 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import importlib.util
|
|
6
6
|
import warnings
|
|
7
|
+
from collections.abc import Collection
|
|
7
8
|
from contextlib import contextmanager
|
|
8
9
|
from pathlib import Path
|
|
9
|
-
from typing import TYPE_CHECKING, Any, Literal
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Literal, overload
|
|
10
11
|
|
|
11
12
|
import numpy as np
|
|
12
13
|
import numpy.ma
|
|
14
|
+
import rasterio.features
|
|
13
15
|
import rasterio.plot
|
|
14
16
|
import rasterio.sample
|
|
15
17
|
import rasterio.transform
|
|
16
18
|
import skimage.measure
|
|
17
19
|
from pydantic import BaseModel, InstanceOf, field_validator
|
|
20
|
+
from pyproj import Transformer
|
|
18
21
|
from pyproj.crs.crs import CRS
|
|
19
22
|
from rasterio.enums import Resampling
|
|
20
23
|
from rasterio.io import MemoryFile
|
|
@@ -29,10 +32,14 @@ if TYPE_CHECKING:
|
|
|
29
32
|
from collections.abc import Callable, Generator
|
|
30
33
|
|
|
31
34
|
import geopandas as gpd
|
|
35
|
+
from affine import Affine
|
|
36
|
+
from branca.colormap import LinearColormap as BrancaLinearColormap
|
|
32
37
|
from folium import Map
|
|
33
38
|
from matplotlib.axes import Axes
|
|
39
|
+
from matplotlib.image import AxesImage
|
|
34
40
|
from numpy.typing import ArrayLike, NDArray
|
|
35
41
|
from rasterio.io import BufferedDatasetWriter, DatasetReader, DatasetWriter
|
|
42
|
+
from shapely import MultiPolygon
|
|
36
43
|
from typing_extensions import Self
|
|
37
44
|
|
|
38
45
|
try:
|
|
@@ -45,17 +52,28 @@ FOLIUM_INSTALLED = importlib.util.find_spec("folium") is not None
|
|
|
45
52
|
BRANCA_INSTALLED = importlib.util.find_spec("branca") is not None
|
|
46
53
|
MATPLOTLIB_INSTALLED = importlib.util.find_spec("matplotlib") is not None
|
|
47
54
|
|
|
55
|
+
CONTOUR_PERTURB_EPS = 1e-10
|
|
56
|
+
|
|
48
57
|
|
|
49
58
|
class RasterCellArrayShapeError(ValueError):
|
|
50
59
|
"""Custom error for invalid raster cell array shapes."""
|
|
51
60
|
|
|
52
61
|
|
|
53
|
-
class
|
|
62
|
+
class Raster(BaseModel):
|
|
54
63
|
"""2-dimensional raster and metadata."""
|
|
55
64
|
|
|
56
65
|
arr: InstanceOf[np.ndarray]
|
|
57
66
|
raster_meta: RasterMeta
|
|
58
67
|
|
|
68
|
+
@field_validator("arr")
|
|
69
|
+
@classmethod
|
|
70
|
+
def check_2d_array(cls, v: NDArray) -> NDArray:
|
|
71
|
+
"""Validator to ensure the cell array is 2D."""
|
|
72
|
+
if v.ndim != 2:
|
|
73
|
+
msg = "Cell array must be 2D"
|
|
74
|
+
raise RasterCellArrayShapeError(msg)
|
|
75
|
+
return v
|
|
76
|
+
|
|
59
77
|
@property
|
|
60
78
|
def meta(self) -> RasterMeta:
|
|
61
79
|
"""Alias for raster_meta."""
|
|
@@ -65,6 +83,31 @@ class RasterModel(BaseModel):
|
|
|
65
83
|
def meta(self, value: RasterMeta) -> None:
|
|
66
84
|
self.raster_meta = value
|
|
67
85
|
|
|
86
|
+
@property
|
|
87
|
+
def shape(self) -> tuple[int, ...]:
|
|
88
|
+
"""Shape of the raster array."""
|
|
89
|
+
return self.arr.shape
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def crs(self) -> CRS:
|
|
93
|
+
"""Convenience property to access the CRS via meta."""
|
|
94
|
+
return self.meta.crs
|
|
95
|
+
|
|
96
|
+
@crs.setter
|
|
97
|
+
def crs(self, value: CRS) -> None:
|
|
98
|
+
"""Set the CRS via meta."""
|
|
99
|
+
self.meta.crs = value
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def transform(self) -> Affine:
|
|
103
|
+
"""Convenience property to access the transform via meta."""
|
|
104
|
+
return self.meta.transform
|
|
105
|
+
|
|
106
|
+
@transform.setter
|
|
107
|
+
def transform(self, value: Affine) -> None:
|
|
108
|
+
"""Set the transform via meta."""
|
|
109
|
+
self.meta.transform = value
|
|
110
|
+
|
|
68
111
|
def __init__(
|
|
69
112
|
self,
|
|
70
113
|
*,
|
|
@@ -93,14 +136,25 @@ class RasterModel(BaseModel):
|
|
|
93
136
|
super().__init__(arr=arr, raster_meta=raster_meta)
|
|
94
137
|
|
|
95
138
|
def __eq__(self, other: object) -> bool:
|
|
96
|
-
"""Check equality of two
|
|
97
|
-
if not isinstance(other,
|
|
139
|
+
"""Check equality of two Raster objects."""
|
|
140
|
+
if not isinstance(other, Raster):
|
|
98
141
|
return NotImplemented
|
|
99
142
|
return (
|
|
100
143
|
np.array_equal(self.arr, other.arr)
|
|
101
144
|
and self.raster_meta == other.raster_meta
|
|
102
145
|
)
|
|
103
146
|
|
|
147
|
+
def is_like(self, other: Raster) -> bool:
|
|
148
|
+
"""Check if two Raster objects have the same metadata and shape.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
other: Another Raster to compare with.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
True if both rasters have the same meta and shape attributes.
|
|
155
|
+
"""
|
|
156
|
+
return self.meta == other.meta and self.shape == other.shape
|
|
157
|
+
|
|
104
158
|
__hash__ = BaseModel.__hash__
|
|
105
159
|
|
|
106
160
|
def __add__(self, other: float | Self) -> Self:
|
|
@@ -108,7 +162,7 @@ class RasterModel(BaseModel):
|
|
|
108
162
|
if isinstance(other, float | int):
|
|
109
163
|
new_arr = self.arr + other
|
|
110
164
|
return cls(arr=new_arr, raster_meta=self.raster_meta)
|
|
111
|
-
elif isinstance(other,
|
|
165
|
+
elif isinstance(other, Raster):
|
|
112
166
|
if self.raster_meta != other.raster_meta:
|
|
113
167
|
msg = (
|
|
114
168
|
"Rasters must have the same metadata (e.g. CRS, cell size, etc.) "
|
|
@@ -134,7 +188,7 @@ class RasterModel(BaseModel):
|
|
|
134
188
|
if isinstance(other, float | int):
|
|
135
189
|
new_arr = self.arr * other
|
|
136
190
|
return cls(arr=new_arr, raster_meta=self.raster_meta)
|
|
137
|
-
elif isinstance(other,
|
|
191
|
+
elif isinstance(other, Raster):
|
|
138
192
|
if self.raster_meta != other.raster_meta:
|
|
139
193
|
msg = (
|
|
140
194
|
"Rasters must have the same metadata (e.g. CRS, cell size, etc.) "
|
|
@@ -157,7 +211,7 @@ class RasterModel(BaseModel):
|
|
|
157
211
|
if isinstance(other, float | int):
|
|
158
212
|
new_arr = self.arr / other
|
|
159
213
|
return cls(arr=new_arr, raster_meta=self.raster_meta)
|
|
160
|
-
elif isinstance(other,
|
|
214
|
+
elif isinstance(other, Raster):
|
|
161
215
|
if self.raster_meta != other.raster_meta:
|
|
162
216
|
msg = (
|
|
163
217
|
"Rasters must have the same metadata (e.g. CRS, cell size, etc.) "
|
|
@@ -193,12 +247,12 @@ class RasterModel(BaseModel):
|
|
|
193
247
|
@property
|
|
194
248
|
def cell_x_coords(self) -> NDArray[np.float64]:
|
|
195
249
|
"""Get the x coordinates of the cell centres in the raster."""
|
|
196
|
-
return self.raster_meta.get_cell_x_coords(self.arr.shape[
|
|
250
|
+
return self.raster_meta.get_cell_x_coords(self.arr.shape[1])
|
|
197
251
|
|
|
198
252
|
@property
|
|
199
253
|
def cell_y_coords(self) -> NDArray[np.float64]:
|
|
200
254
|
"""Get the y coordinates of the cell centres in the raster."""
|
|
201
|
-
return self.raster_meta.get_cell_y_coords(self.arr.shape[
|
|
255
|
+
return self.raster_meta.get_cell_y_coords(self.arr.shape[0])
|
|
202
256
|
|
|
203
257
|
@contextmanager
|
|
204
258
|
def to_rasterio_dataset(
|
|
@@ -207,7 +261,7 @@ class RasterModel(BaseModel):
|
|
|
207
261
|
"""Create a rasterio in-memory dataset from the Raster object.
|
|
208
262
|
|
|
209
263
|
Example:
|
|
210
|
-
>>> raster =
|
|
264
|
+
>>> raster = Raster.example()
|
|
211
265
|
>>> with raster.to_rasterio_dataset() as dataset:
|
|
212
266
|
>>> ...
|
|
213
267
|
"""
|
|
@@ -233,12 +287,30 @@ class RasterModel(BaseModel):
|
|
|
233
287
|
finally:
|
|
234
288
|
memfile.close()
|
|
235
289
|
|
|
290
|
+
@overload
|
|
236
291
|
def sample(
|
|
237
292
|
self,
|
|
238
|
-
xy:
|
|
293
|
+
xy: Collection[tuple[float, float]] | Collection[Point] | ArrayLike,
|
|
239
294
|
*,
|
|
240
295
|
na_action: Literal["raise", "ignore"] = "raise",
|
|
241
|
-
) -> NDArray
|
|
296
|
+
) -> NDArray: ...
|
|
297
|
+
@overload
|
|
298
|
+
def sample(
|
|
299
|
+
self,
|
|
300
|
+
xy: tuple[float, float] | Point,
|
|
301
|
+
*,
|
|
302
|
+
na_action: Literal["raise", "ignore"] = "raise",
|
|
303
|
+
) -> float: ...
|
|
304
|
+
def sample(
|
|
305
|
+
self,
|
|
306
|
+
xy: Collection[tuple[float, float]]
|
|
307
|
+
| Collection[Point]
|
|
308
|
+
| ArrayLike
|
|
309
|
+
| tuple[float, float]
|
|
310
|
+
| Point,
|
|
311
|
+
*,
|
|
312
|
+
na_action: Literal["raise", "ignore"] = "raise",
|
|
313
|
+
) -> NDArray | float:
|
|
242
314
|
"""Sample raster values at GeoSeries locations and return sampled values.
|
|
243
315
|
|
|
244
316
|
Args:
|
|
@@ -255,13 +327,30 @@ class RasterModel(BaseModel):
|
|
|
255
327
|
# https://rdrn.me/optimising-sampling/
|
|
256
328
|
|
|
257
329
|
# Convert shapely Points to coordinate tuples if needed
|
|
258
|
-
if isinstance(xy,
|
|
259
|
-
xy = [
|
|
330
|
+
if isinstance(xy, Point):
|
|
331
|
+
xy = [(xy.x, xy.y)]
|
|
332
|
+
singleton = True
|
|
333
|
+
elif (
|
|
334
|
+
isinstance(xy, Collection)
|
|
335
|
+
and len(xy) > 0
|
|
336
|
+
and isinstance(next(iter(xy)), Point)
|
|
337
|
+
):
|
|
338
|
+
xy = [(point.x, point.y) for point in xy] # pyright: ignore[reportAttributeAccessIssue]
|
|
339
|
+
singleton = False
|
|
340
|
+
elif (
|
|
341
|
+
isinstance(xy, tuple)
|
|
342
|
+
and len(xy) == 2
|
|
343
|
+
and isinstance(next(iter(xy)), (float, int))
|
|
344
|
+
):
|
|
345
|
+
xy = [xy] # pyright: ignore[reportAssignmentType]
|
|
346
|
+
singleton = True
|
|
347
|
+
else:
|
|
348
|
+
singleton = False
|
|
260
349
|
|
|
261
350
|
xy = np.asarray(xy, dtype=float)
|
|
262
351
|
|
|
263
|
-
# Short-circuit
|
|
264
352
|
if len(xy) == 0:
|
|
353
|
+
# Short-circuit
|
|
265
354
|
return np.array([], dtype=float)
|
|
266
355
|
|
|
267
356
|
# Create in-memory rasterio dataset from the incumbent Raster object
|
|
@@ -311,6 +400,10 @@ class RasterModel(BaseModel):
|
|
|
311
400
|
axis=0,
|
|
312
401
|
)
|
|
313
402
|
|
|
403
|
+
if singleton:
|
|
404
|
+
(raster_value,) = raster_values
|
|
405
|
+
return raster_value
|
|
406
|
+
|
|
314
407
|
return raster_values
|
|
315
408
|
|
|
316
409
|
@property
|
|
@@ -339,13 +432,16 @@ class RasterModel(BaseModel):
|
|
|
339
432
|
]
|
|
340
433
|
)
|
|
341
434
|
|
|
342
|
-
def explore(
|
|
435
|
+
def explore( # noqa: PLR0913 c.f. geopandas.explore which also has many input args
|
|
343
436
|
self,
|
|
344
437
|
*,
|
|
345
438
|
m: Map | None = None,
|
|
346
439
|
opacity: float = 1.0,
|
|
347
|
-
colormap: str
|
|
440
|
+
colormap: str
|
|
441
|
+
| Callable[[float], tuple[float, float, float, float]] = "viridis",
|
|
348
442
|
cbar_label: str | None = None,
|
|
443
|
+
vmin: float | None = None,
|
|
444
|
+
vmax: float | None = None,
|
|
349
445
|
) -> Map:
|
|
350
446
|
"""Display the raster on a folium map."""
|
|
351
447
|
if not FOLIUM_INSTALLED or not MATPLOTLIB_INSTALLED:
|
|
@@ -353,39 +449,44 @@ class RasterModel(BaseModel):
|
|
|
353
449
|
raise ImportError(msg)
|
|
354
450
|
|
|
355
451
|
import folium.raster_layers
|
|
356
|
-
import geopandas as gpd
|
|
357
452
|
import matplotlib as mpl
|
|
358
453
|
|
|
359
454
|
if m is None:
|
|
360
455
|
m = folium.Map()
|
|
361
456
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
457
|
+
if vmin is not None and vmax is not None and vmax <= vmin:
|
|
458
|
+
msg = "'vmin' must be less than 'vmax'."
|
|
459
|
+
raise ValueError(msg)
|
|
460
|
+
|
|
461
|
+
if isinstance(colormap, str):
|
|
462
|
+
colormap = mpl.colormaps[colormap]
|
|
365
463
|
|
|
366
|
-
#
|
|
464
|
+
# Transform bounds to WGS84 using pyproj directly
|
|
367
465
|
wgs84_crs = CRS.from_epsg(4326)
|
|
368
|
-
|
|
369
|
-
wgs84_crs
|
|
466
|
+
transformer = Transformer.from_crs(
|
|
467
|
+
self.raster_meta.crs, wgs84_crs, always_xy=True
|
|
370
468
|
)
|
|
371
|
-
xmin, ymin, xmax, ymax = gdf.total_bounds
|
|
372
469
|
|
|
373
|
-
|
|
470
|
+
# Get the corner points of the bounding box
|
|
471
|
+
raster_xmin, raster_ymin, raster_xmax, raster_ymax = self.bounds
|
|
472
|
+
corner_points = [
|
|
473
|
+
(raster_xmin, raster_ymin),
|
|
474
|
+
(raster_xmin, raster_ymax),
|
|
475
|
+
(raster_xmax, raster_ymax),
|
|
476
|
+
(raster_xmax, raster_ymin),
|
|
477
|
+
]
|
|
478
|
+
|
|
479
|
+
# Transform all corner points to WGS84
|
|
480
|
+
transformed_points = [transformer.transform(x, y) for x, y in corner_points]
|
|
374
481
|
|
|
375
|
-
#
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
message="All-NaN slice encountered",
|
|
380
|
-
category=RuntimeWarning,
|
|
381
|
-
)
|
|
382
|
-
min_val = np.nanmin(arr)
|
|
383
|
-
max_val = np.nanmax(arr)
|
|
482
|
+
# Find the bounding box of the transformed points
|
|
483
|
+
transformed_xs, transformed_ys = zip(*transformed_points, strict=True)
|
|
484
|
+
xmin, xmax = min(transformed_xs), max(transformed_xs)
|
|
485
|
+
ymin, ymax = min(transformed_ys), max(transformed_ys)
|
|
384
486
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
arr = np.zeros_like(arr) # In case all values are the same
|
|
487
|
+
# Normalize the array to [0, 1] for colormap mapping
|
|
488
|
+
_vmin, _vmax = _get_vmin_vmax(self, vmin=vmin, vmax=vmax)
|
|
489
|
+
arr = self.normalize(vmin=_vmin, vmax=_vmax).arr
|
|
389
490
|
|
|
390
491
|
# Finally, need to determine whether to flip the image based on negative Affine
|
|
391
492
|
# coefficients
|
|
@@ -396,11 +497,12 @@ class RasterModel(BaseModel):
|
|
|
396
497
|
if flip_y:
|
|
397
498
|
arr = np.flip(arr, axis=0)
|
|
398
499
|
|
|
500
|
+
bounds = [[ymin, xmin], [ymax, xmax]]
|
|
399
501
|
img = folium.raster_layers.ImageOverlay(
|
|
400
502
|
image=arr,
|
|
401
|
-
bounds=
|
|
503
|
+
bounds=bounds,
|
|
402
504
|
opacity=opacity,
|
|
403
|
-
colormap=
|
|
505
|
+
colormap=colormap,
|
|
404
506
|
mercator_project=True,
|
|
405
507
|
)
|
|
406
508
|
|
|
@@ -408,26 +510,39 @@ class RasterModel(BaseModel):
|
|
|
408
510
|
|
|
409
511
|
# Add a colorbar legend
|
|
410
512
|
if BRANCA_INSTALLED:
|
|
411
|
-
|
|
412
|
-
from matplotlib.colors import to_hex
|
|
413
|
-
|
|
414
|
-
# Determine legend data range in original units
|
|
415
|
-
vmin = float(min_val) if np.isfinite(min_val) else 0.0
|
|
416
|
-
vmax = float(max_val) if np.isfinite(max_val) else 1.0
|
|
417
|
-
if vmax <= vmin:
|
|
418
|
-
vmax = vmin + 1.0
|
|
419
|
-
|
|
420
|
-
sample_points = np.linspace(0, 1, rgba_map.N)
|
|
421
|
-
colors = [to_hex(rgba_map(x)) for x in sample_points]
|
|
422
|
-
legend = BrancaLinearColormap(colors=colors, vmin=vmin, vmax=vmax)
|
|
513
|
+
cbar = _map_colorbar(colormap=colormap, vmin=_vmin, vmax=_vmax)
|
|
423
514
|
if cbar_label:
|
|
424
|
-
|
|
425
|
-
|
|
515
|
+
cbar.caption = cbar_label
|
|
516
|
+
cbar.add_to(m)
|
|
426
517
|
|
|
427
|
-
m.fit_bounds(
|
|
518
|
+
m.fit_bounds(bounds)
|
|
428
519
|
|
|
429
520
|
return m
|
|
430
521
|
|
|
522
|
+
def normalize(
|
|
523
|
+
self, *, vmin: float | None = None, vmax: float | None = None
|
|
524
|
+
) -> Self:
|
|
525
|
+
"""Normalize the raster values to the range [0, 1].
|
|
526
|
+
|
|
527
|
+
If custom vmin and vmax values are provided, values below vmin will be set to 0,
|
|
528
|
+
and values above vmax will be set to 1.
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
vmin: Minimum value for normalization. Values below this will be set to 0.
|
|
532
|
+
If None, the minimum value in the array is used.
|
|
533
|
+
vmax: Maximum value for normalization. Values above this will be set to 1.
|
|
534
|
+
If None, the maximum value in the array is used.
|
|
535
|
+
"""
|
|
536
|
+
_vmin, _vmax = _get_vmin_vmax(self, vmin=vmin, vmax=vmax)
|
|
537
|
+
|
|
538
|
+
arr = self.arr.copy()
|
|
539
|
+
if _vmax > _vmin:
|
|
540
|
+
arr = (arr - _vmin) / (_vmax - _vmin)
|
|
541
|
+
arr = np.clip(arr, 0, 1)
|
|
542
|
+
else:
|
|
543
|
+
arr = np.zeros_like(arr)
|
|
544
|
+
return self.__class__(arr=arr, raster_meta=self.raster_meta)
|
|
545
|
+
|
|
431
546
|
def to_clipboard(self) -> None:
|
|
432
547
|
"""Copy the raster cell array to the clipboard."""
|
|
433
548
|
import pandas as pd
|
|
@@ -441,8 +556,22 @@ class RasterModel(BaseModel):
|
|
|
441
556
|
cbar_label: str | None = None,
|
|
442
557
|
basemap: bool = False,
|
|
443
558
|
cmap: str = "viridis",
|
|
559
|
+
suppressed: Collection[float] | float = tuple(),
|
|
560
|
+
**kwargs: Any,
|
|
444
561
|
) -> Axes:
|
|
445
|
-
"""Plot the raster on a matplotlib axis.
|
|
562
|
+
"""Plot the raster on a matplotlib axis.
|
|
563
|
+
|
|
564
|
+
Args:
|
|
565
|
+
ax: A matplotlib axes object to plot on. If None, a new figure will be
|
|
566
|
+
created.
|
|
567
|
+
cbar_label: Label for the colorbar. If None, no label is added.
|
|
568
|
+
basemap: Whether to add a basemap. Currently not implemented.
|
|
569
|
+
cmap: Colormap to use for the plot.
|
|
570
|
+
suppressed: Values to suppress from the plot (i.e. not display). This can be
|
|
571
|
+
useful for zeroes especially.
|
|
572
|
+
**kwargs: Additional keyword arguments to pass to `rasterio.plot.show()`.
|
|
573
|
+
This includes parameters like `alpha` for transparency.
|
|
574
|
+
"""
|
|
446
575
|
if not MATPLOTLIB_INSTALLED:
|
|
447
576
|
msg = "The 'matplotlib' package is required for 'plot()'."
|
|
448
577
|
raise ImportError(msg)
|
|
@@ -450,6 +579,8 @@ class RasterModel(BaseModel):
|
|
|
450
579
|
from matplotlib import pyplot as plt
|
|
451
580
|
from mpl_toolkits.axes_grid1 import make_axes_locatable
|
|
452
581
|
|
|
582
|
+
suppressed = np.array(suppressed)
|
|
583
|
+
|
|
453
584
|
if ax is None:
|
|
454
585
|
_, _ax = plt.subplots()
|
|
455
586
|
_ax: Axes
|
|
@@ -459,33 +590,34 @@ class RasterModel(BaseModel):
|
|
|
459
590
|
msg = "Basemap plotting is not yet implemented."
|
|
460
591
|
raise NotImplementedError(msg)
|
|
461
592
|
|
|
462
|
-
|
|
593
|
+
model = self.model_copy()
|
|
594
|
+
model.arr = model.arr.copy()
|
|
463
595
|
|
|
464
|
-
# Get extent of the
|
|
465
|
-
|
|
466
|
-
(
|
|
596
|
+
# Get extent of the unsuppressed values in array index coordinates
|
|
597
|
+
suppressed_mask = np.isin(model.arr, suppressed)
|
|
598
|
+
(x_unsuppressed,) = np.nonzero((~suppressed_mask).any(axis=0))
|
|
599
|
+
(y_unsuppressed,) = np.nonzero((~suppressed_mask).any(axis=1))
|
|
467
600
|
|
|
468
|
-
if len(
|
|
469
|
-
msg = "Raster contains no
|
|
601
|
+
if len(x_unsuppressed) == 0 or len(y_unsuppressed) == 0:
|
|
602
|
+
msg = "Raster contains no unsuppressed values; cannot plot."
|
|
470
603
|
raise ValueError(msg)
|
|
471
604
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
605
|
+
# N.B. these are array index coordinates, so np.min and np.max are safe since
|
|
606
|
+
# they cannot encounter NaN values.
|
|
607
|
+
min_x_unsuppressed = np.min(x_unsuppressed)
|
|
608
|
+
max_x_unsuppressed = np.max(x_unsuppressed)
|
|
609
|
+
min_y_unsuppressed = np.min(y_unsuppressed)
|
|
610
|
+
max_y_unsuppressed = np.max(y_unsuppressed)
|
|
476
611
|
|
|
477
612
|
# Transform to raster CRS
|
|
478
|
-
x1, y1 = self.raster_meta.transform * (
|
|
479
|
-
x2, y2 = self.raster_meta.transform * (
|
|
613
|
+
x1, y1 = self.raster_meta.transform * (min_x_unsuppressed, min_y_unsuppressed) # type: ignore[reportAssignmentType] overloaded tuple size in affine
|
|
614
|
+
x2, y2 = self.raster_meta.transform * (max_x_unsuppressed, max_y_unsuppressed) # type: ignore[reportAssignmentType]
|
|
480
615
|
xmin, xmax = sorted([x1, x2])
|
|
481
616
|
ymin, ymax = sorted([y1, y2])
|
|
482
617
|
|
|
483
|
-
arr[
|
|
618
|
+
model.arr[suppressed_mask] = np.nan
|
|
484
619
|
|
|
485
|
-
|
|
486
|
-
img, *_ = rasterio.plot.show(
|
|
487
|
-
dataset, with_bounds=True, ax=ax, cmap=cmap
|
|
488
|
-
).get_images()
|
|
620
|
+
img, *_ = model.rio_show(ax=ax, cmap=cmap, with_bounds=True, **kwargs)
|
|
489
621
|
|
|
490
622
|
ax.set_xlim(xmin, xmax)
|
|
491
623
|
ax.set_ylim(ymin, ymax)
|
|
@@ -501,6 +633,20 @@ class RasterModel(BaseModel):
|
|
|
501
633
|
fig.colorbar(img, label=cbar_label, cax=cax)
|
|
502
634
|
return ax
|
|
503
635
|
|
|
636
|
+
def rio_show(self, **kwargs: Any) -> list[AxesImage]:
|
|
637
|
+
"""Plot the raster using rasterio's built-in plotting function.
|
|
638
|
+
|
|
639
|
+
This is useful for lower-level access to rasterio's plotting capabilities.
|
|
640
|
+
Generally, the `plot()` method is preferred for most use cases.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
**kwargs: Keyword arguments to pass to `rasterio.plot.show()`. This includes
|
|
644
|
+
parameters like `alpha` for transparency, and `with_bounds` to control
|
|
645
|
+
whether to plot in spatial coordinates or array index coordinates.
|
|
646
|
+
"""
|
|
647
|
+
with self.to_rasterio_dataset() as dataset:
|
|
648
|
+
return rasterio.plot.show(dataset, **kwargs).get_images()
|
|
649
|
+
|
|
504
650
|
def as_geodataframe(self, name: str = "value") -> gpd.GeoDataFrame:
|
|
505
651
|
"""Create a GeoDataFrame representation of the raster."""
|
|
506
652
|
import geopandas as gpd
|
|
@@ -561,7 +707,7 @@ class RasterModel(BaseModel):
|
|
|
561
707
|
|
|
562
708
|
@classmethod
|
|
563
709
|
def example(cls) -> Self:
|
|
564
|
-
"""Create an example
|
|
710
|
+
"""Create an example Raster."""
|
|
565
711
|
# Peaks dataset style example
|
|
566
712
|
n = 256
|
|
567
713
|
x = np.linspace(-3, 3, n)
|
|
@@ -573,6 +719,96 @@ class RasterModel(BaseModel):
|
|
|
573
719
|
raster_meta = RasterMeta.example()
|
|
574
720
|
return cls(arr=arr, raster_meta=raster_meta)
|
|
575
721
|
|
|
722
|
+
@overload
|
|
723
|
+
def apply(
|
|
724
|
+
self,
|
|
725
|
+
func: Callable[[np.ndarray], np.ndarray],
|
|
726
|
+
*,
|
|
727
|
+
raw: Literal[True],
|
|
728
|
+
) -> Self: ...
|
|
729
|
+
@overload
|
|
730
|
+
def apply(
|
|
731
|
+
self,
|
|
732
|
+
func: Callable[[float], float] | Callable[[np.ndarray], np.ndarray],
|
|
733
|
+
*,
|
|
734
|
+
raw: Literal[False] = False,
|
|
735
|
+
) -> Self: ...
|
|
736
|
+
def apply(self, func, *, raw=False) -> Self:
|
|
737
|
+
"""Apply a function element-wise to the raster array.
|
|
738
|
+
|
|
739
|
+
Creates a new raster instance with the same metadata (CRS, transform, etc.)
|
|
740
|
+
but with the data array transformed by the provided function. The original
|
|
741
|
+
raster is not modified.
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
func: The function to apply to the raster array. If `raw` is True, this
|
|
745
|
+
function should accept and return a NumPy array. If `raw` is False,
|
|
746
|
+
this function should accept and return a single float value.
|
|
747
|
+
raw: If True, the function is applied directly to the entire array at
|
|
748
|
+
once. If False, the function is applied element-wise to each cell
|
|
749
|
+
in the array using `np.vectorize()`. Default is False.
|
|
750
|
+
"""
|
|
751
|
+
new_raster = self.model_copy()
|
|
752
|
+
if raw:
|
|
753
|
+
new_arr = func(self.arr)
|
|
754
|
+
else:
|
|
755
|
+
new_arr = np.vectorize(func)(self.arr)
|
|
756
|
+
new_raster.arr = np.asarray(new_arr)
|
|
757
|
+
return new_raster
|
|
758
|
+
|
|
759
|
+
def max(self) -> float:
|
|
760
|
+
"""Get the maximum value in the raster, ignoring NaN values.
|
|
761
|
+
|
|
762
|
+
Returns:
|
|
763
|
+
The maximum value in the raster. Returns NaN if all values are NaN.
|
|
764
|
+
"""
|
|
765
|
+
return float(np.nanmax(self.arr))
|
|
766
|
+
|
|
767
|
+
def min(self) -> float:
|
|
768
|
+
"""Get the minimum value in the raster, ignoring NaN values.
|
|
769
|
+
|
|
770
|
+
Returns:
|
|
771
|
+
The minimum value in the raster. Returns NaN if all values are NaN.
|
|
772
|
+
"""
|
|
773
|
+
return float(np.nanmin(self.arr))
|
|
774
|
+
|
|
775
|
+
def mean(self) -> float:
|
|
776
|
+
"""Get the mean value in the raster, ignoring NaN values.
|
|
777
|
+
|
|
778
|
+
Returns:
|
|
779
|
+
The mean value in the raster. Returns NaN if all values are NaN.
|
|
780
|
+
"""
|
|
781
|
+
return float(np.nanmean(self.arr))
|
|
782
|
+
|
|
783
|
+
def std(self) -> float:
|
|
784
|
+
"""Get the standard deviation of values in the raster, ignoring NaN values.
|
|
785
|
+
|
|
786
|
+
Returns:
|
|
787
|
+
The standard deviation of the raster. Returns NaN if all values are NaN.
|
|
788
|
+
"""
|
|
789
|
+
return float(np.nanstd(self.arr))
|
|
790
|
+
|
|
791
|
+
def quantile(self, q: float) -> float:
|
|
792
|
+
"""Get the specified quantile value in the raster, ignoring NaN values.
|
|
793
|
+
|
|
794
|
+
Args:
|
|
795
|
+
q: Quantile to compute, must be between 0 and 1 inclusive.
|
|
796
|
+
|
|
797
|
+
Returns:
|
|
798
|
+
The quantile value. Returns NaN if all values are NaN.
|
|
799
|
+
"""
|
|
800
|
+
return float(np.nanquantile(self.arr, q))
|
|
801
|
+
|
|
802
|
+
def median(self) -> float:
|
|
803
|
+
"""Get the median value in the raster, ignoring NaN values.
|
|
804
|
+
|
|
805
|
+
This is equivalent to quantile(0.5).
|
|
806
|
+
|
|
807
|
+
Returns:
|
|
808
|
+
The median value in the raster. Returns NaN if all values are NaN.
|
|
809
|
+
"""
|
|
810
|
+
return float(np.nanmedian(self.arr))
|
|
811
|
+
|
|
576
812
|
def fillna(self, value: float) -> Self:
|
|
577
813
|
"""Fill NaN values in the raster with a specified value.
|
|
578
814
|
|
|
@@ -599,12 +835,14 @@ class RasterModel(BaseModel):
|
|
|
599
835
|
return coords[:, :, 0], coords[:, :, 1]
|
|
600
836
|
|
|
601
837
|
def contour(
|
|
602
|
-
self,
|
|
838
|
+
self, levels: list[float] | NDArray, *, smoothing: bool = True
|
|
603
839
|
) -> gpd.GeoDataFrame:
|
|
604
840
|
"""Create contour lines from the raster data, optionally with smoothing.
|
|
605
841
|
|
|
606
|
-
The contour lines are returned as a GeoDataFrame with the contours
|
|
607
|
-
|
|
842
|
+
The contour lines are returned as a GeoDataFrame with the contours dissolved
|
|
843
|
+
by level, resulting in one row per contour level. Each row contains a
|
|
844
|
+
(Multi)LineString geometry representing all contour lines for that level,
|
|
845
|
+
and the contour level value in a column named 'level'.
|
|
608
846
|
|
|
609
847
|
Consider calling `blur()` before this method to smooth the raster data before
|
|
610
848
|
contouring, to denoise the contours.
|
|
@@ -622,12 +860,20 @@ class RasterModel(BaseModel):
|
|
|
622
860
|
all_levels = []
|
|
623
861
|
all_geoms = []
|
|
624
862
|
for level in levels:
|
|
863
|
+
# If this is the maximum or minimum level, perturb it ever-so-slightly to
|
|
864
|
+
# ensure we get contours at the edges of the raster
|
|
865
|
+
perturbed_level = level
|
|
866
|
+
if level == self.max():
|
|
867
|
+
perturbed_level -= CONTOUR_PERTURB_EPS
|
|
868
|
+
elif level == self.min():
|
|
869
|
+
perturbed_level += CONTOUR_PERTURB_EPS
|
|
870
|
+
|
|
625
871
|
contours = skimage.measure.find_contours(
|
|
626
872
|
self.arr,
|
|
627
|
-
level=
|
|
873
|
+
level=perturbed_level,
|
|
628
874
|
)
|
|
629
875
|
|
|
630
|
-
#
|
|
876
|
+
# Construct shapely LineString objects
|
|
631
877
|
# Convert to CRS from array index coordinates to raster CRS
|
|
632
878
|
geoms = [
|
|
633
879
|
LineString(
|
|
@@ -656,7 +902,8 @@ class RasterModel(BaseModel):
|
|
|
656
902
|
crs=self.raster_meta.crs,
|
|
657
903
|
)
|
|
658
904
|
|
|
659
|
-
|
|
905
|
+
# Dissolve contours by level to merge all contour lines of the same level
|
|
906
|
+
return contour_gdf.dissolve(by="level", as_index=False)
|
|
660
907
|
|
|
661
908
|
def blur(self, sigma: float) -> Self:
|
|
662
909
|
"""Apply a Gaussian blur to the raster data.
|
|
@@ -696,12 +943,78 @@ class RasterModel(BaseModel):
|
|
|
696
943
|
|
|
697
944
|
return raster
|
|
698
945
|
|
|
946
|
+
def pad(self, width: float, *, value: float = np.nan) -> Self:
|
|
947
|
+
"""Extend the raster by adding a constant fill value around the edges.
|
|
948
|
+
|
|
949
|
+
By default, the padding value is NaN, but this can be changed via the
|
|
950
|
+
`value` parameter.
|
|
951
|
+
|
|
952
|
+
This grows the raster by adding padding around all edges. New cells are
|
|
953
|
+
filled with the constant `value`.
|
|
954
|
+
|
|
955
|
+
If the width is not an exact multiple of the cell size, the padding may be
|
|
956
|
+
slightly larger than the specified width, i.e. the value is rounded up to
|
|
957
|
+
the nearest whole number of cells.
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
width: The width of the padding, in the same units as the raster CRS
|
|
961
|
+
(e.g. meters). This defines how far from the edge the padding
|
|
962
|
+
extends.
|
|
963
|
+
value: The constant value to use for padding. Default is NaN.
|
|
964
|
+
"""
|
|
965
|
+
cell_size = self.raster_meta.cell_size
|
|
966
|
+
|
|
967
|
+
# Calculate number of cells to pad in each direction
|
|
968
|
+
pad_cells = int(np.ceil(width / cell_size))
|
|
969
|
+
|
|
970
|
+
# Get current bounds
|
|
971
|
+
xmin, ymin, xmax, ymax = self.bounds
|
|
972
|
+
|
|
973
|
+
# Calculate new bounds with padding
|
|
974
|
+
new_xmin = xmin - (pad_cells * cell_size)
|
|
975
|
+
new_ymin = ymin - (pad_cells * cell_size)
|
|
976
|
+
new_xmax = xmax + (pad_cells * cell_size)
|
|
977
|
+
new_ymax = ymax + (pad_cells * cell_size)
|
|
978
|
+
|
|
979
|
+
# Create padded array
|
|
980
|
+
new_height = self.arr.shape[0] + 2 * pad_cells
|
|
981
|
+
new_width = self.arr.shape[1] + 2 * pad_cells
|
|
982
|
+
|
|
983
|
+
# Create new array filled with the padding value
|
|
984
|
+
padded_arr = np.full((new_height, new_width), value, dtype=self.arr.dtype)
|
|
985
|
+
|
|
986
|
+
# Copy original array into the center of the padded array
|
|
987
|
+
padded_arr[
|
|
988
|
+
pad_cells : pad_cells + self.arr.shape[0],
|
|
989
|
+
pad_cells : pad_cells + self.arr.shape[1],
|
|
990
|
+
] = self.arr
|
|
991
|
+
|
|
992
|
+
# Create new transform for the padded raster
|
|
993
|
+
new_transform = rasterio.transform.from_bounds(
|
|
994
|
+
west=new_xmin,
|
|
995
|
+
south=new_ymin,
|
|
996
|
+
east=new_xmax,
|
|
997
|
+
north=new_ymax,
|
|
998
|
+
width=new_width,
|
|
999
|
+
height=new_height,
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
# Create new raster metadata
|
|
1003
|
+
new_meta = RasterMeta(
|
|
1004
|
+
cell_size=cell_size,
|
|
1005
|
+
crs=self.raster_meta.crs,
|
|
1006
|
+
transform=new_transform,
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
return self.__class__(arr=padded_arr, raster_meta=new_meta)
|
|
1010
|
+
|
|
699
1011
|
def crop(
|
|
700
1012
|
self,
|
|
701
1013
|
bounds: tuple[float, float, float, float],
|
|
1014
|
+
*,
|
|
702
1015
|
strategy: Literal["underflow", "overflow"] = "underflow",
|
|
703
1016
|
) -> Self:
|
|
704
|
-
"""Crop the raster to the specified bounds.
|
|
1017
|
+
"""Crop the raster to the specified bounds as (minx, miny, maxx, maxy).
|
|
705
1018
|
|
|
706
1019
|
Args:
|
|
707
1020
|
bounds: A tuple of (minx, miny, maxx, maxy) defining the bounds to crop to.
|
|
@@ -712,7 +1025,7 @@ class RasterModel(BaseModel):
|
|
|
712
1025
|
remains covered with cells.
|
|
713
1026
|
|
|
714
1027
|
Returns:
|
|
715
|
-
A new
|
|
1028
|
+
A new Raster instance cropped to the specified bounds.
|
|
716
1029
|
"""
|
|
717
1030
|
|
|
718
1031
|
minx, miny, maxx, maxy = bounds
|
|
@@ -746,7 +1059,7 @@ class RasterModel(BaseModel):
|
|
|
746
1059
|
raise NotImplementedError(msg)
|
|
747
1060
|
|
|
748
1061
|
# Crop the array
|
|
749
|
-
cropped_arr = arr[np.ix_(
|
|
1062
|
+
cropped_arr = arr[np.ix_(y_idx, x_idx)]
|
|
750
1063
|
|
|
751
1064
|
# Check the shape of the cropped array
|
|
752
1065
|
if cropped_arr.size == 0:
|
|
@@ -772,6 +1085,141 @@ class RasterModel(BaseModel):
|
|
|
772
1085
|
)
|
|
773
1086
|
return cls(arr=cropped_arr, raster_meta=new_meta)
|
|
774
1087
|
|
|
1088
|
+
def taper_border(self, width: float, *, limit: float = 0.0) -> Self:
|
|
1089
|
+
"""Taper values to a limiting value around the border of the raster.
|
|
1090
|
+
|
|
1091
|
+
By default, the borders are tapered to zero, but this can be changed via the
|
|
1092
|
+
`limit` parameter.
|
|
1093
|
+
|
|
1094
|
+
This keeps the raster size the same, overwriting values in the border area.
|
|
1095
|
+
To instead grow the raster, consider using `pad()` followed by `taper_border()`.
|
|
1096
|
+
|
|
1097
|
+
The tapering is linear from the cell centres around the border of the raster,
|
|
1098
|
+
so the value at the edge of the raster will be equal to `limit`.
|
|
1099
|
+
|
|
1100
|
+
Args:
|
|
1101
|
+
width: The width of the taper, in the same units as the raster CRS
|
|
1102
|
+
(e.g. meters). This defines how far from the edge the tapering
|
|
1103
|
+
starts.
|
|
1104
|
+
limit: The limiting value to taper to at the edges. Default is zero.
|
|
1105
|
+
"""
|
|
1106
|
+
|
|
1107
|
+
# Determine the width in cell units (possibly fractional)
|
|
1108
|
+
cell_size = self.raster_meta.cell_size
|
|
1109
|
+
width_in_cells = width / cell_size
|
|
1110
|
+
|
|
1111
|
+
# Calculate the distance from the edge in cell units
|
|
1112
|
+
arr_height, arr_width = self.arr.shape
|
|
1113
|
+
y_indices, x_indices = np.indices((int(arr_height), int(arr_width)))
|
|
1114
|
+
dist_from_left = x_indices
|
|
1115
|
+
dist_from_right = arr_width - 1 - x_indices
|
|
1116
|
+
dist_from_top = y_indices
|
|
1117
|
+
dist_from_bottom = arr_height - 1 - y_indices
|
|
1118
|
+
dist_from_edge = np.minimum.reduce(
|
|
1119
|
+
[dist_from_left, dist_from_right, dist_from_top, dist_from_bottom]
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
# Mask the arrays to only the area within the width from the edge, rounding up
|
|
1123
|
+
mask = dist_from_edge < np.ceil(width_in_cells)
|
|
1124
|
+
masked_dist_arr = np.where(mask, dist_from_edge, np.nan)
|
|
1125
|
+
masked_arr = np.where(mask, self.arr, np.nan)
|
|
1126
|
+
|
|
1127
|
+
# Calculate the tapering factor based on the distance from the edge
|
|
1128
|
+
taper_factor = np.clip(masked_dist_arr / width_in_cells, 0.0, 1.0)
|
|
1129
|
+
tapered_values = limit + (masked_arr - limit) * taper_factor
|
|
1130
|
+
|
|
1131
|
+
# Create the new raster array
|
|
1132
|
+
new_arr = self.arr.copy()
|
|
1133
|
+
new_arr[mask] = tapered_values[mask]
|
|
1134
|
+
new_raster = self.model_copy()
|
|
1135
|
+
new_raster.arr = new_arr
|
|
1136
|
+
|
|
1137
|
+
return new_raster
|
|
1138
|
+
|
|
1139
|
+
def clip(
|
|
1140
|
+
self,
|
|
1141
|
+
polygon: Polygon | MultiPolygon,
|
|
1142
|
+
*,
|
|
1143
|
+
strategy: Literal["centres"] = "centres",
|
|
1144
|
+
) -> Self:
|
|
1145
|
+
"""Clip the raster to the specified polygon, replacing cells outside with NaN.
|
|
1146
|
+
|
|
1147
|
+
The clipping strategy determines how to handle cells that are partially
|
|
1148
|
+
within the polygon. Currently, only the 'centres' strategy is supported, which
|
|
1149
|
+
retains cells whose centres fall within the polygon.
|
|
1150
|
+
|
|
1151
|
+
Args:
|
|
1152
|
+
polygon: A shapely Polygon or MultiPolygon defining the area to clip to.
|
|
1153
|
+
strategy: The clipping strategy to use. Currently only 'centres' is
|
|
1154
|
+
supported, which retains cells whose centres fall within the
|
|
1155
|
+
polygon.
|
|
1156
|
+
|
|
1157
|
+
Returns:
|
|
1158
|
+
A new Raster with cells outside the polygon set to NaN.
|
|
1159
|
+
"""
|
|
1160
|
+
if strategy != "centres":
|
|
1161
|
+
msg = f"Unsupported clipping strategy: {strategy}"
|
|
1162
|
+
raise NotImplementedError(msg)
|
|
1163
|
+
|
|
1164
|
+
raster = self.model_copy()
|
|
1165
|
+
|
|
1166
|
+
mask = rasterio.features.rasterize(
|
|
1167
|
+
[(polygon, 1)],
|
|
1168
|
+
fill=0,
|
|
1169
|
+
out_shape=self.shape,
|
|
1170
|
+
transform=self.meta.transform,
|
|
1171
|
+
dtype=np.uint8,
|
|
1172
|
+
)
|
|
1173
|
+
|
|
1174
|
+
raster.arr = np.where(mask, raster.arr, np.nan)
|
|
1175
|
+
|
|
1176
|
+
return raster
|
|
1177
|
+
|
|
1178
|
+
def trim_nan(self) -> Self:
|
|
1179
|
+
"""Crop the raster by trimming away all-NaN slices at the edges.
|
|
1180
|
+
|
|
1181
|
+
This effectively trims the raster to the smallest bounding box that contains all
|
|
1182
|
+
of the non-NaN values. Note that this does not guarantee no NaN values at all
|
|
1183
|
+
around the edges, only that there won't be entire edges which are all-NaN.
|
|
1184
|
+
|
|
1185
|
+
Consider using `.extrapolate()` for further cleanup of NaN values.
|
|
1186
|
+
"""
|
|
1187
|
+
arr = self.arr
|
|
1188
|
+
|
|
1189
|
+
# Check if the entire array is NaN
|
|
1190
|
+
if np.all(np.isnan(arr)):
|
|
1191
|
+
msg = "Cannot crop raster: all values are NaN"
|
|
1192
|
+
raise ValueError(msg)
|
|
1193
|
+
|
|
1194
|
+
# Find rows and columns that are not all NaN
|
|
1195
|
+
nan_row_mask = np.all(np.isnan(arr), axis=1)
|
|
1196
|
+
nan_col_mask = np.all(np.isnan(arr), axis=0)
|
|
1197
|
+
|
|
1198
|
+
# Find the bounding indices
|
|
1199
|
+
(row_indices,) = np.where(~nan_row_mask)
|
|
1200
|
+
(col_indices,) = np.where(~nan_col_mask)
|
|
1201
|
+
|
|
1202
|
+
min_row, max_row = row_indices[0], row_indices[-1]
|
|
1203
|
+
min_col, max_col = col_indices[0], col_indices[-1]
|
|
1204
|
+
|
|
1205
|
+
# Crop the array
|
|
1206
|
+
cropped_arr = arr[min_row : max_row + 1, min_col : max_col + 1]
|
|
1207
|
+
|
|
1208
|
+
# Shift the transform by the number of pixels cropped (min_col, min_row)
|
|
1209
|
+
new_transform = (
|
|
1210
|
+
self.raster_meta.transform
|
|
1211
|
+
* rasterio.transform.Affine.translation(min_col, min_row)
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
# Create new metadata
|
|
1215
|
+
new_meta = RasterMeta(
|
|
1216
|
+
cell_size=self.raster_meta.cell_size,
|
|
1217
|
+
crs=self.raster_meta.crs,
|
|
1218
|
+
transform=new_transform,
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
return self.__class__(arr=cropped_arr, raster_meta=new_meta)
|
|
1222
|
+
|
|
775
1223
|
def resample(
|
|
776
1224
|
self, new_cell_size: float, *, method: Literal["bilinear"] = "bilinear"
|
|
777
1225
|
) -> Self:
|
|
@@ -819,26 +1267,55 @@ class RasterModel(BaseModel):
|
|
|
819
1267
|
|
|
820
1268
|
return cls(arr=new_arr, raster_meta=new_raster_meta)
|
|
821
1269
|
|
|
822
|
-
@field_validator("arr")
|
|
823
|
-
@classmethod
|
|
824
|
-
def check_2d_array(cls, v: NDArray) -> NDArray:
|
|
825
|
-
"""Validator to ensure the cell array is 2D."""
|
|
826
|
-
if v.ndim != 2:
|
|
827
|
-
msg = "Cell array must be 2D"
|
|
828
|
-
raise RasterCellArrayShapeError(msg)
|
|
829
|
-
return v
|
|
830
1270
|
|
|
1271
|
+
def _map_colorbar(
|
|
1272
|
+
*,
|
|
1273
|
+
colormap: Callable[[float], tuple[float, float, float, float]],
|
|
1274
|
+
vmin: float,
|
|
1275
|
+
vmax: float,
|
|
1276
|
+
) -> BrancaLinearColormap:
|
|
1277
|
+
from branca.colormap import LinearColormap as BrancaLinearColormap
|
|
1278
|
+
from matplotlib.colors import ListedColormap, to_hex
|
|
1279
|
+
|
|
1280
|
+
# Determine legend data range in original units
|
|
1281
|
+
vmin = float(vmin) if np.isfinite(vmin) else 0.0
|
|
1282
|
+
vmax = float(vmax) if np.isfinite(vmax) else 1.0
|
|
1283
|
+
if vmax <= vmin:
|
|
1284
|
+
vmax = vmin + 1.0
|
|
1285
|
+
|
|
1286
|
+
if isinstance(colormap, ListedColormap):
|
|
1287
|
+
n = colormap.N
|
|
1288
|
+
else:
|
|
1289
|
+
n = 256
|
|
1290
|
+
|
|
1291
|
+
sample_points = np.linspace(0, 1, n)
|
|
1292
|
+
colors = [to_hex(colormap(x)) for x in sample_points]
|
|
1293
|
+
return BrancaLinearColormap(colors=colors, vmin=vmin, vmax=vmax)
|
|
831
1294
|
|
|
832
|
-
def _get_xy_tuple(xy: Any) -> tuple[float, float]:
|
|
833
|
-
"""Convert Point or coordinate tuple to coordinate tuple.
|
|
834
1295
|
|
|
835
|
-
|
|
836
|
-
|
|
1296
|
+
def _get_vmin_vmax(
|
|
1297
|
+
raster: Raster, *, vmin: float | None = None, vmax: float | None = None
|
|
1298
|
+
) -> tuple[float, float]:
|
|
1299
|
+
"""Get maximum and minimum values from a raster array, ignoring NaNs.
|
|
837
1300
|
|
|
838
|
-
|
|
839
|
-
A coordinate tuple (x, y).
|
|
1301
|
+
Allows for custom over-ride vmin and vmax values to be provided.
|
|
840
1302
|
"""
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1303
|
+
with warnings.catch_warnings():
|
|
1304
|
+
warnings.filterwarnings(
|
|
1305
|
+
"ignore",
|
|
1306
|
+
message="All-NaN slice encountered",
|
|
1307
|
+
category=RuntimeWarning,
|
|
1308
|
+
)
|
|
1309
|
+
if vmin is None:
|
|
1310
|
+
_vmin = raster.min()
|
|
1311
|
+
else:
|
|
1312
|
+
_vmin = vmin
|
|
1313
|
+
if vmax is None:
|
|
1314
|
+
_vmax = raster.max()
|
|
1315
|
+
else:
|
|
1316
|
+
_vmax = vmax
|
|
1317
|
+
|
|
1318
|
+
return _vmin, _vmax
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
RasterModel = Raster
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rastr
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Geospatial Raster datatype library for Python.
|
|
5
5
|
Project-URL: Source Code, https://github.com/tonkintaylor/rastr
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/tonkintaylor/rastr/issues
|
|
7
7
|
Project-URL: Releases, https://github.com/tonkintaylor/rastr/releases
|
|
8
|
-
Project-URL: Source Archive, https://github.com/tonkintaylor/rastr/archive/
|
|
8
|
+
Project-URL: Source Archive, https://github.com/tonkintaylor/rastr/archive/fde05e3c098da7ff9e77a37332d55fb7dbacd873.zip
|
|
9
9
|
Author-email: Tonkin & Taylor Limited <Sub-DisciplineData+AnalyticsStaff@tonkintaylor.co.nz>, Nathan McDougall <nmcdougall@tonkintaylor.co.nz>, Ben Karl <bkarl@tonkintaylor.co.nz>
|
|
10
10
|
License-Expression: MIT
|
|
11
11
|
License-File: LICENSE
|
|
@@ -75,10 +75,10 @@ from pyproj.crs.crs import CRS
|
|
|
75
75
|
from rasterio.transform import from_origin
|
|
76
76
|
from rastr.create import full_raster
|
|
77
77
|
from rastr.meta import RasterMeta
|
|
78
|
-
from rastr.raster import
|
|
78
|
+
from rastr.raster import Raster
|
|
79
79
|
|
|
80
80
|
# Create an example raster
|
|
81
|
-
raster =
|
|
81
|
+
raster = Raster.example()
|
|
82
82
|
|
|
83
83
|
# Basic arithmetic operations
|
|
84
84
|
doubled = raster * 2
|
|
@@ -131,6 +131,12 @@ Current version limitations:
|
|
|
131
131
|
- Square cells only (rectangular cell support planned).
|
|
132
132
|
- Only float dtypes (integer support planned).
|
|
133
133
|
|
|
134
|
+
## Similar Projects
|
|
135
|
+
|
|
136
|
+
- [rasters](https://github.com/python-rasters/rasters) is a project with similar goals of providing a dedicated raster datatype in Python with higher-level interfaces for GIS operations. Unlike `rastr`, it has support for multi-band rasters, and has some more advanced functionality for Earth Science applications. Both projects are relatively new and under active development.
|
|
137
|
+
- [rasterio](https://rasterio.readthedocs.io/) is a core dependency of `rastr` and provides low-level raster I/O and processing capabilities.
|
|
138
|
+
- [rioxarray](https://corteva.github.io/rioxarray/stable/getting_started/getting_started.html) extends [`xarray`](https://docs.xarray.dev/en/stable/index.html) for raster data with geospatial support via `rasterio`.
|
|
139
|
+
|
|
134
140
|
### Contributing
|
|
135
141
|
|
|
136
142
|
See the
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
rastr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
rastr/_version.py,sha256=
|
|
3
|
-
rastr/create.py,sha256=
|
|
4
|
-
rastr/io.py,sha256=
|
|
2
|
+
rastr/_version.py,sha256=fvHpBU3KZKRinkriKdtAt3crenOyysELF-M9y3ozg3U,704
|
|
3
|
+
rastr/create.py,sha256=7fmg4GKeTXdM5w8oqrCD9eDrc7PU87oHYGPNA-IZ8Cc,13759
|
|
4
|
+
rastr/io.py,sha256=llR2wFyrJVjEG6HN82UAJLVPs_H8nvDxmbEZLjJYjno,2927
|
|
5
5
|
rastr/meta.py,sha256=5iDvGkYe8iMMkPV6gSL04jNcLRhuRNFqe9AppUpp55E,2928
|
|
6
|
-
rastr/raster.py,sha256=
|
|
6
|
+
rastr/raster.py,sha256=_1Wr78B0_V9r4oh7X-h5lT1QXlnBsSBW6ZkICGSjUFw,47514
|
|
7
7
|
rastr/arr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
8
|
rastr/arr/fill.py,sha256=80ucb36el9s042fDSwK1SZJhp_GNJNMM0fpQTWmJvgE,1001
|
|
9
9
|
rastr/gis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
10
|
rastr/gis/fishnet.py,sha256=Ic-0HV61ST8OxwhyoMyV_ybihs2xuhgAY-3n4CknAt8,2670
|
|
11
11
|
rastr/gis/smooth.py,sha256=3HQDQHQM5_LeNk21R8Eb8VpF727JcXq21HO9JMvcpW4,4810
|
|
12
|
-
rastr-0.
|
|
13
|
-
rastr-0.
|
|
14
|
-
rastr-0.
|
|
15
|
-
rastr-0.
|
|
12
|
+
rastr-0.5.0.dist-info/METADATA,sha256=1-sja-_hUE6ukT7KqsDH-nzPZNWVHVeXCABmM9rzTg8,5701
|
|
13
|
+
rastr-0.5.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
14
|
+
rastr-0.5.0.dist-info/licenses/LICENSE,sha256=7qUsx93G2ATTRLZiSYuQofwAX_uWvrqnAiMK8PzxvNc,1080
|
|
15
|
+
rastr-0.5.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|