rastr 0.4.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 +514 -92
- {rastr-0.4.0.dist-info → rastr-0.5.0.dist-info}/METADATA +10 -4
- {rastr-0.4.0.dist-info → rastr-0.5.0.dist-info}/RECORD +8 -8
- {rastr-0.4.0.dist-info → rastr-0.5.0.dist-info}/WHEEL +0 -0
- {rastr-0.4.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
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."""
|
|
@@ -80,6 +98,16 @@ class RasterModel(BaseModel):
|
|
|
80
98
|
"""Set the CRS via meta."""
|
|
81
99
|
self.meta.crs = value
|
|
82
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
|
+
|
|
83
111
|
def __init__(
|
|
84
112
|
self,
|
|
85
113
|
*,
|
|
@@ -108,14 +136,25 @@ class RasterModel(BaseModel):
|
|
|
108
136
|
super().__init__(arr=arr, raster_meta=raster_meta)
|
|
109
137
|
|
|
110
138
|
def __eq__(self, other: object) -> bool:
|
|
111
|
-
"""Check equality of two
|
|
112
|
-
if not isinstance(other,
|
|
139
|
+
"""Check equality of two Raster objects."""
|
|
140
|
+
if not isinstance(other, Raster):
|
|
113
141
|
return NotImplemented
|
|
114
142
|
return (
|
|
115
143
|
np.array_equal(self.arr, other.arr)
|
|
116
144
|
and self.raster_meta == other.raster_meta
|
|
117
145
|
)
|
|
118
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
|
+
|
|
119
158
|
__hash__ = BaseModel.__hash__
|
|
120
159
|
|
|
121
160
|
def __add__(self, other: float | Self) -> Self:
|
|
@@ -123,7 +162,7 @@ class RasterModel(BaseModel):
|
|
|
123
162
|
if isinstance(other, float | int):
|
|
124
163
|
new_arr = self.arr + other
|
|
125
164
|
return cls(arr=new_arr, raster_meta=self.raster_meta)
|
|
126
|
-
elif isinstance(other,
|
|
165
|
+
elif isinstance(other, Raster):
|
|
127
166
|
if self.raster_meta != other.raster_meta:
|
|
128
167
|
msg = (
|
|
129
168
|
"Rasters must have the same metadata (e.g. CRS, cell size, etc.) "
|
|
@@ -149,7 +188,7 @@ class RasterModel(BaseModel):
|
|
|
149
188
|
if isinstance(other, float | int):
|
|
150
189
|
new_arr = self.arr * other
|
|
151
190
|
return cls(arr=new_arr, raster_meta=self.raster_meta)
|
|
152
|
-
elif isinstance(other,
|
|
191
|
+
elif isinstance(other, Raster):
|
|
153
192
|
if self.raster_meta != other.raster_meta:
|
|
154
193
|
msg = (
|
|
155
194
|
"Rasters must have the same metadata (e.g. CRS, cell size, etc.) "
|
|
@@ -172,7 +211,7 @@ class RasterModel(BaseModel):
|
|
|
172
211
|
if isinstance(other, float | int):
|
|
173
212
|
new_arr = self.arr / other
|
|
174
213
|
return cls(arr=new_arr, raster_meta=self.raster_meta)
|
|
175
|
-
elif isinstance(other,
|
|
214
|
+
elif isinstance(other, Raster):
|
|
176
215
|
if self.raster_meta != other.raster_meta:
|
|
177
216
|
msg = (
|
|
178
217
|
"Rasters must have the same metadata (e.g. CRS, cell size, etc.) "
|
|
@@ -222,7 +261,7 @@ class RasterModel(BaseModel):
|
|
|
222
261
|
"""Create a rasterio in-memory dataset from the Raster object.
|
|
223
262
|
|
|
224
263
|
Example:
|
|
225
|
-
>>> raster =
|
|
264
|
+
>>> raster = Raster.example()
|
|
226
265
|
>>> with raster.to_rasterio_dataset() as dataset:
|
|
227
266
|
>>> ...
|
|
228
267
|
"""
|
|
@@ -248,12 +287,30 @@ class RasterModel(BaseModel):
|
|
|
248
287
|
finally:
|
|
249
288
|
memfile.close()
|
|
250
289
|
|
|
290
|
+
@overload
|
|
291
|
+
def sample(
|
|
292
|
+
self,
|
|
293
|
+
xy: Collection[tuple[float, float]] | Collection[Point] | ArrayLike,
|
|
294
|
+
*,
|
|
295
|
+
na_action: Literal["raise", "ignore"] = "raise",
|
|
296
|
+
) -> NDArray: ...
|
|
297
|
+
@overload
|
|
251
298
|
def sample(
|
|
252
299
|
self,
|
|
253
|
-
xy:
|
|
300
|
+
xy: tuple[float, float] | Point,
|
|
254
301
|
*,
|
|
255
302
|
na_action: Literal["raise", "ignore"] = "raise",
|
|
256
|
-
) ->
|
|
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:
|
|
257
314
|
"""Sample raster values at GeoSeries locations and return sampled values.
|
|
258
315
|
|
|
259
316
|
Args:
|
|
@@ -270,13 +327,30 @@ class RasterModel(BaseModel):
|
|
|
270
327
|
# https://rdrn.me/optimising-sampling/
|
|
271
328
|
|
|
272
329
|
# Convert shapely Points to coordinate tuples if needed
|
|
273
|
-
if isinstance(xy,
|
|
274
|
-
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
|
|
275
349
|
|
|
276
350
|
xy = np.asarray(xy, dtype=float)
|
|
277
351
|
|
|
278
|
-
# Short-circuit
|
|
279
352
|
if len(xy) == 0:
|
|
353
|
+
# Short-circuit
|
|
280
354
|
return np.array([], dtype=float)
|
|
281
355
|
|
|
282
356
|
# Create in-memory rasterio dataset from the incumbent Raster object
|
|
@@ -326,6 +400,10 @@ class RasterModel(BaseModel):
|
|
|
326
400
|
axis=0,
|
|
327
401
|
)
|
|
328
402
|
|
|
403
|
+
if singleton:
|
|
404
|
+
(raster_value,) = raster_values
|
|
405
|
+
return raster_value
|
|
406
|
+
|
|
329
407
|
return raster_values
|
|
330
408
|
|
|
331
409
|
@property
|
|
@@ -354,13 +432,16 @@ class RasterModel(BaseModel):
|
|
|
354
432
|
]
|
|
355
433
|
)
|
|
356
434
|
|
|
357
|
-
def explore(
|
|
435
|
+
def explore( # noqa: PLR0913 c.f. geopandas.explore which also has many input args
|
|
358
436
|
self,
|
|
359
437
|
*,
|
|
360
438
|
m: Map | None = None,
|
|
361
439
|
opacity: float = 1.0,
|
|
362
|
-
colormap: str
|
|
440
|
+
colormap: str
|
|
441
|
+
| Callable[[float], tuple[float, float, float, float]] = "viridis",
|
|
363
442
|
cbar_label: str | None = None,
|
|
443
|
+
vmin: float | None = None,
|
|
444
|
+
vmax: float | None = None,
|
|
364
445
|
) -> Map:
|
|
365
446
|
"""Display the raster on a folium map."""
|
|
366
447
|
if not FOLIUM_INSTALLED or not MATPLOTLIB_INSTALLED:
|
|
@@ -368,39 +449,44 @@ class RasterModel(BaseModel):
|
|
|
368
449
|
raise ImportError(msg)
|
|
369
450
|
|
|
370
451
|
import folium.raster_layers
|
|
371
|
-
import geopandas as gpd
|
|
372
452
|
import matplotlib as mpl
|
|
373
453
|
|
|
374
454
|
if m is None:
|
|
375
455
|
m = folium.Map()
|
|
376
456
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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]
|
|
380
463
|
|
|
381
|
-
#
|
|
464
|
+
# Transform bounds to WGS84 using pyproj directly
|
|
382
465
|
wgs84_crs = CRS.from_epsg(4326)
|
|
383
|
-
|
|
384
|
-
wgs84_crs
|
|
466
|
+
transformer = Transformer.from_crs(
|
|
467
|
+
self.raster_meta.crs, wgs84_crs, always_xy=True
|
|
385
468
|
)
|
|
386
|
-
xmin, ymin, xmax, ymax = gdf.total_bounds
|
|
387
469
|
|
|
388
|
-
|
|
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]
|
|
389
481
|
|
|
390
|
-
#
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
message="All-NaN slice encountered",
|
|
395
|
-
category=RuntimeWarning,
|
|
396
|
-
)
|
|
397
|
-
min_val = np.nanmin(arr)
|
|
398
|
-
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)
|
|
399
486
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
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
|
|
404
490
|
|
|
405
491
|
# Finally, need to determine whether to flip the image based on negative Affine
|
|
406
492
|
# coefficients
|
|
@@ -411,11 +497,12 @@ class RasterModel(BaseModel):
|
|
|
411
497
|
if flip_y:
|
|
412
498
|
arr = np.flip(arr, axis=0)
|
|
413
499
|
|
|
500
|
+
bounds = [[ymin, xmin], [ymax, xmax]]
|
|
414
501
|
img = folium.raster_layers.ImageOverlay(
|
|
415
502
|
image=arr,
|
|
416
|
-
bounds=
|
|
503
|
+
bounds=bounds,
|
|
417
504
|
opacity=opacity,
|
|
418
|
-
colormap=
|
|
505
|
+
colormap=colormap,
|
|
419
506
|
mercator_project=True,
|
|
420
507
|
)
|
|
421
508
|
|
|
@@ -423,26 +510,39 @@ class RasterModel(BaseModel):
|
|
|
423
510
|
|
|
424
511
|
# Add a colorbar legend
|
|
425
512
|
if BRANCA_INSTALLED:
|
|
426
|
-
|
|
427
|
-
from matplotlib.colors import to_hex
|
|
428
|
-
|
|
429
|
-
# Determine legend data range in original units
|
|
430
|
-
vmin = float(min_val) if np.isfinite(min_val) else 0.0
|
|
431
|
-
vmax = float(max_val) if np.isfinite(max_val) else 1.0
|
|
432
|
-
if vmax <= vmin:
|
|
433
|
-
vmax = vmin + 1.0
|
|
434
|
-
|
|
435
|
-
sample_points = np.linspace(0, 1, rgba_map.N)
|
|
436
|
-
colors = [to_hex(rgba_map(x)) for x in sample_points]
|
|
437
|
-
legend = BrancaLinearColormap(colors=colors, vmin=vmin, vmax=vmax)
|
|
513
|
+
cbar = _map_colorbar(colormap=colormap, vmin=_vmin, vmax=_vmax)
|
|
438
514
|
if cbar_label:
|
|
439
|
-
|
|
440
|
-
|
|
515
|
+
cbar.caption = cbar_label
|
|
516
|
+
cbar.add_to(m)
|
|
441
517
|
|
|
442
|
-
m.fit_bounds(
|
|
518
|
+
m.fit_bounds(bounds)
|
|
443
519
|
|
|
444
520
|
return m
|
|
445
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
|
+
|
|
446
546
|
def to_clipboard(self) -> None:
|
|
447
547
|
"""Copy the raster cell array to the clipboard."""
|
|
448
548
|
import pandas as pd
|
|
@@ -456,8 +556,22 @@ class RasterModel(BaseModel):
|
|
|
456
556
|
cbar_label: str | None = None,
|
|
457
557
|
basemap: bool = False,
|
|
458
558
|
cmap: str = "viridis",
|
|
559
|
+
suppressed: Collection[float] | float = tuple(),
|
|
560
|
+
**kwargs: Any,
|
|
459
561
|
) -> Axes:
|
|
460
|
-
"""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
|
+
"""
|
|
461
575
|
if not MATPLOTLIB_INSTALLED:
|
|
462
576
|
msg = "The 'matplotlib' package is required for 'plot()'."
|
|
463
577
|
raise ImportError(msg)
|
|
@@ -465,6 +579,8 @@ class RasterModel(BaseModel):
|
|
|
465
579
|
from matplotlib import pyplot as plt
|
|
466
580
|
from mpl_toolkits.axes_grid1 import make_axes_locatable
|
|
467
581
|
|
|
582
|
+
suppressed = np.array(suppressed)
|
|
583
|
+
|
|
468
584
|
if ax is None:
|
|
469
585
|
_, _ax = plt.subplots()
|
|
470
586
|
_ax: Axes
|
|
@@ -474,33 +590,34 @@ class RasterModel(BaseModel):
|
|
|
474
590
|
msg = "Basemap plotting is not yet implemented."
|
|
475
591
|
raise NotImplementedError(msg)
|
|
476
592
|
|
|
477
|
-
|
|
593
|
+
model = self.model_copy()
|
|
594
|
+
model.arr = model.arr.copy()
|
|
478
595
|
|
|
479
|
-
# Get extent of the
|
|
480
|
-
|
|
481
|
-
(
|
|
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))
|
|
482
600
|
|
|
483
|
-
if len(
|
|
484
|
-
msg = "Raster contains no
|
|
601
|
+
if len(x_unsuppressed) == 0 or len(y_unsuppressed) == 0:
|
|
602
|
+
msg = "Raster contains no unsuppressed values; cannot plot."
|
|
485
603
|
raise ValueError(msg)
|
|
486
604
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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)
|
|
491
611
|
|
|
492
612
|
# Transform to raster CRS
|
|
493
|
-
x1, y1 = self.raster_meta.transform * (
|
|
494
|
-
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]
|
|
495
615
|
xmin, xmax = sorted([x1, x2])
|
|
496
616
|
ymin, ymax = sorted([y1, y2])
|
|
497
617
|
|
|
498
|
-
arr[
|
|
618
|
+
model.arr[suppressed_mask] = np.nan
|
|
499
619
|
|
|
500
|
-
|
|
501
|
-
img, *_ = rasterio.plot.show(
|
|
502
|
-
dataset, with_bounds=True, ax=ax, cmap=cmap
|
|
503
|
-
).get_images()
|
|
620
|
+
img, *_ = model.rio_show(ax=ax, cmap=cmap, with_bounds=True, **kwargs)
|
|
504
621
|
|
|
505
622
|
ax.set_xlim(xmin, xmax)
|
|
506
623
|
ax.set_ylim(ymin, ymax)
|
|
@@ -516,6 +633,20 @@ class RasterModel(BaseModel):
|
|
|
516
633
|
fig.colorbar(img, label=cbar_label, cax=cax)
|
|
517
634
|
return ax
|
|
518
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
|
+
|
|
519
650
|
def as_geodataframe(self, name: str = "value") -> gpd.GeoDataFrame:
|
|
520
651
|
"""Create a GeoDataFrame representation of the raster."""
|
|
521
652
|
import geopandas as gpd
|
|
@@ -576,7 +707,7 @@ class RasterModel(BaseModel):
|
|
|
576
707
|
|
|
577
708
|
@classmethod
|
|
578
709
|
def example(cls) -> Self:
|
|
579
|
-
"""Create an example
|
|
710
|
+
"""Create an example Raster."""
|
|
580
711
|
# Peaks dataset style example
|
|
581
712
|
n = 256
|
|
582
713
|
x = np.linspace(-3, 3, n)
|
|
@@ -625,6 +756,59 @@ class RasterModel(BaseModel):
|
|
|
625
756
|
new_raster.arr = np.asarray(new_arr)
|
|
626
757
|
return new_raster
|
|
627
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
|
+
|
|
628
812
|
def fillna(self, value: float) -> Self:
|
|
629
813
|
"""Fill NaN values in the raster with a specified value.
|
|
630
814
|
|
|
@@ -676,9 +860,17 @@ class RasterModel(BaseModel):
|
|
|
676
860
|
all_levels = []
|
|
677
861
|
all_geoms = []
|
|
678
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
|
+
|
|
679
871
|
contours = skimage.measure.find_contours(
|
|
680
872
|
self.arr,
|
|
681
|
-
level=
|
|
873
|
+
level=perturbed_level,
|
|
682
874
|
)
|
|
683
875
|
|
|
684
876
|
# Construct shapely LineString objects
|
|
@@ -751,9 +943,75 @@ class RasterModel(BaseModel):
|
|
|
751
943
|
|
|
752
944
|
return raster
|
|
753
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
|
+
|
|
754
1011
|
def crop(
|
|
755
1012
|
self,
|
|
756
1013
|
bounds: tuple[float, float, float, float],
|
|
1014
|
+
*,
|
|
757
1015
|
strategy: Literal["underflow", "overflow"] = "underflow",
|
|
758
1016
|
) -> Self:
|
|
759
1017
|
"""Crop the raster to the specified bounds as (minx, miny, maxx, maxy).
|
|
@@ -767,7 +1025,7 @@ class RasterModel(BaseModel):
|
|
|
767
1025
|
remains covered with cells.
|
|
768
1026
|
|
|
769
1027
|
Returns:
|
|
770
|
-
A new
|
|
1028
|
+
A new Raster instance cropped to the specified bounds.
|
|
771
1029
|
"""
|
|
772
1030
|
|
|
773
1031
|
minx, miny, maxx, maxy = bounds
|
|
@@ -827,6 +1085,141 @@ class RasterModel(BaseModel):
|
|
|
827
1085
|
)
|
|
828
1086
|
return cls(arr=cropped_arr, raster_meta=new_meta)
|
|
829
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
|
+
|
|
830
1223
|
def resample(
|
|
831
1224
|
self, new_cell_size: float, *, method: Literal["bilinear"] = "bilinear"
|
|
832
1225
|
) -> Self:
|
|
@@ -874,26 +1267,55 @@ class RasterModel(BaseModel):
|
|
|
874
1267
|
|
|
875
1268
|
return cls(arr=new_arr, raster_meta=new_raster_meta)
|
|
876
1269
|
|
|
877
|
-
@field_validator("arr")
|
|
878
|
-
@classmethod
|
|
879
|
-
def check_2d_array(cls, v: NDArray) -> NDArray:
|
|
880
|
-
"""Validator to ensure the cell array is 2D."""
|
|
881
|
-
if v.ndim != 2:
|
|
882
|
-
msg = "Cell array must be 2D"
|
|
883
|
-
raise RasterCellArrayShapeError(msg)
|
|
884
|
-
return v
|
|
885
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)
|
|
886
1294
|
|
|
887
|
-
def _get_xy_tuple(xy: Any) -> tuple[float, float]:
|
|
888
|
-
"""Convert Point or coordinate tuple to coordinate tuple.
|
|
889
1295
|
|
|
890
|
-
|
|
891
|
-
|
|
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.
|
|
892
1300
|
|
|
893
|
-
|
|
894
|
-
A coordinate tuple (x, y).
|
|
1301
|
+
Allows for custom over-ride vmin and vmax values to be provided.
|
|
895
1302
|
"""
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|