rastr 0.5.0__py3-none-any.whl → 0.7.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/__init__.py +7 -0
- rastr/_version.py +2 -2
- rastr/arr/fill.py +3 -2
- rastr/create.py +42 -53
- rastr/gis/crs.py +67 -0
- rastr/gis/fishnet.py +14 -2
- rastr/gis/interpolate.py +50 -0
- rastr/gis/smooth.py +77 -58
- rastr/io.py +37 -11
- rastr/meta.py +98 -0
- rastr/raster.py +193 -27
- {rastr-0.5.0.dist-info → rastr-0.7.0.dist-info}/METADATA +4 -4
- rastr-0.7.0.dist-info/RECORD +17 -0
- rastr-0.5.0.dist-info/RECORD +0 -15
- {rastr-0.5.0.dist-info → rastr-0.7.0.dist-info}/WHEEL +0 -0
- {rastr-0.5.0.dist-info → rastr-0.7.0.dist-info}/licenses/LICENSE +0 -0
rastr/__init__.py
CHANGED
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.7.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 7, 0)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
rastr/arr/fill.py
CHANGED
|
@@ -8,7 +8,7 @@ if TYPE_CHECKING:
|
|
|
8
8
|
from numpy.typing import NDArray
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def fillna_nearest_neighbours(arr: NDArray
|
|
11
|
+
def fillna_nearest_neighbours(arr: NDArray) -> NDArray:
|
|
12
12
|
"""Fill NaN values in an N-dimensional array with their nearest neighbours' values.
|
|
13
13
|
|
|
14
14
|
The nearest neighbour is determined using the Euclidean distance between array
|
|
@@ -28,4 +28,5 @@ def fillna_nearest_neighbours(arr: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
|
28
28
|
# Interpolate at the array indices
|
|
29
29
|
interp = NearestNDInterpolator(nonnan_idxs, arr[nonnan_mask])
|
|
30
30
|
filled_arr = interp(*np.indices(arr.shape))
|
|
31
|
-
|
|
31
|
+
# Preserve the original dtype
|
|
32
|
+
return filled_arr.astype(arr.dtype)
|
rastr/create.py
CHANGED
|
@@ -11,12 +11,14 @@ from affine import Affine
|
|
|
11
11
|
from pyproj import CRS
|
|
12
12
|
from shapely.geometry import Point
|
|
13
13
|
|
|
14
|
+
from rastr.gis.crs import get_affine_sign
|
|
14
15
|
from rastr.gis.fishnet import create_point_grid, get_point_grid_shape
|
|
16
|
+
from rastr.gis.interpolate import interpn_kernel
|
|
15
17
|
from rastr.meta import RasterMeta
|
|
16
18
|
from rastr.raster import Raster
|
|
17
19
|
|
|
18
20
|
if TYPE_CHECKING:
|
|
19
|
-
from collections.abc import Iterable
|
|
21
|
+
from collections.abc import Collection, Iterable
|
|
20
22
|
|
|
21
23
|
import geopandas as gpd
|
|
22
24
|
from numpy.typing import ArrayLike
|
|
@@ -141,7 +143,7 @@ def rasterize_gdf(
|
|
|
141
143
|
gdf: gpd.GeoDataFrame,
|
|
142
144
|
*,
|
|
143
145
|
raster_meta: RasterMeta,
|
|
144
|
-
target_cols:
|
|
146
|
+
target_cols: Collection[str],
|
|
145
147
|
) -> list[Raster]:
|
|
146
148
|
"""Rasterize geometries from a GeoDataFrame.
|
|
147
149
|
|
|
@@ -183,9 +185,10 @@ def rasterize_gdf(
|
|
|
183
185
|
shape = get_point_grid_shape(bounds=expanded_bounds, cell_size=cell_size)
|
|
184
186
|
|
|
185
187
|
# Create the affine transform for rasterization
|
|
188
|
+
xs, ys = get_affine_sign(raster_meta.crs)
|
|
186
189
|
transform = Affine.translation(
|
|
187
190
|
expanded_bounds[0], expanded_bounds[3]
|
|
188
|
-
) * Affine.scale(cell_size,
|
|
191
|
+
) * Affine.scale(xs * cell_size, ys * cell_size)
|
|
189
192
|
|
|
190
193
|
# Create rasters for each target column using rasterio.features.rasterize
|
|
191
194
|
rasters = []
|
|
@@ -212,7 +215,9 @@ def rasterize_gdf(
|
|
|
212
215
|
return rasters
|
|
213
216
|
|
|
214
217
|
|
|
215
|
-
def _validate_columns_exist(
|
|
218
|
+
def _validate_columns_exist(
|
|
219
|
+
gdf: gpd.GeoDataFrame, target_cols: Collection[str]
|
|
220
|
+
) -> None:
|
|
216
221
|
"""Validate that all target columns exist in the GeoDataFrame.
|
|
217
222
|
|
|
218
223
|
Args:
|
|
@@ -228,7 +233,9 @@ def _validate_columns_exist(gdf: gpd.GeoDataFrame, target_cols: list[str]) -> No
|
|
|
228
233
|
raise MissingColumnsError(msg)
|
|
229
234
|
|
|
230
235
|
|
|
231
|
-
def _validate_columns_numeric(
|
|
236
|
+
def _validate_columns_numeric(
|
|
237
|
+
gdf: gpd.GeoDataFrame, target_cols: Collection[str]
|
|
238
|
+
) -> None:
|
|
232
239
|
"""Validate that all target columns contain numeric data.
|
|
233
240
|
|
|
234
241
|
Args:
|
|
@@ -308,14 +315,31 @@ def raster_from_point_cloud(
|
|
|
308
315
|
Raises:
|
|
309
316
|
ValueError: If any (x, y) points are duplicated, or if they are all collinear.
|
|
310
317
|
"""
|
|
311
|
-
from scipy.interpolate import LinearNDInterpolator
|
|
312
|
-
from scipy.spatial import KDTree, QhullError
|
|
313
|
-
|
|
314
|
-
x = np.asarray(x).ravel()
|
|
315
|
-
y = np.asarray(y).ravel()
|
|
316
|
-
z = np.asarray(z).ravel()
|
|
317
318
|
crs = CRS.from_user_input(crs)
|
|
319
|
+
x, y, z = _validate_xyz(
|
|
320
|
+
np.asarray(x).ravel(), np.asarray(y).ravel(), np.asarray(z).ravel()
|
|
321
|
+
)
|
|
318
322
|
|
|
323
|
+
raster_meta, shape = RasterMeta.infer(x, y, cell_size=cell_size, crs=crs)
|
|
324
|
+
arr = interpn_kernel(
|
|
325
|
+
points=np.column_stack((x, y)),
|
|
326
|
+
values=z,
|
|
327
|
+
xi=np.column_stack(_get_grid(raster_meta, shape=shape)),
|
|
328
|
+
).reshape(shape)
|
|
329
|
+
|
|
330
|
+
# We only support float rasters for now; we should preserve the input dtype if
|
|
331
|
+
# possible
|
|
332
|
+
if z.dtype in (np.float16, np.float32, np.float64):
|
|
333
|
+
arr = arr.astype(z.dtype)
|
|
334
|
+
else:
|
|
335
|
+
arr = arr.astype(np.float64)
|
|
336
|
+
|
|
337
|
+
return Raster(arr=arr, raster_meta=raster_meta)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _validate_xyz(
|
|
341
|
+
x: np.ndarray, y: np.ndarray, z: np.ndarray
|
|
342
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
319
343
|
# Validate input arrays
|
|
320
344
|
if len(x) != len(y) or len(x) != len(z):
|
|
321
345
|
msg = "Length of x, y, and z must be equal."
|
|
@@ -339,53 +363,18 @@ def raster_from_point_cloud(
|
|
|
339
363
|
msg = "Duplicate (x, y) points found. Each (x, y) point must be unique."
|
|
340
364
|
raise ValueError(msg)
|
|
341
365
|
|
|
342
|
-
|
|
343
|
-
if cell_size is None:
|
|
344
|
-
# Half the 5th percentile of nearest neighbor distances between the (x,y) points
|
|
345
|
-
tree = KDTree(xy_points)
|
|
346
|
-
distances, _ = tree.query(xy_points, k=2)
|
|
347
|
-
distances: np.ndarray
|
|
348
|
-
cell_size = float(np.percentile(distances[distances > 0], 5)) / 2
|
|
349
|
-
|
|
350
|
-
# Compute bounds from data
|
|
351
|
-
minx, miny, maxx, maxy = np.min(x), np.min(y), np.max(x), np.max(y)
|
|
352
|
-
|
|
353
|
-
# Compute grid shape
|
|
354
|
-
width = int(np.ceil((maxx - minx) / cell_size))
|
|
355
|
-
height = int(np.ceil((maxy - miny) / cell_size))
|
|
356
|
-
shape = (height, width)
|
|
366
|
+
return x, y, z
|
|
357
367
|
|
|
358
|
-
# Compute transform: upper left corner is (minx, maxy)
|
|
359
|
-
transform = Affine.translation(minx, maxy) * Affine.scale(cell_size, -cell_size)
|
|
360
368
|
|
|
361
|
-
|
|
369
|
+
def _get_grid(
|
|
370
|
+
raster_meta: RasterMeta, *, shape: tuple[int, int]
|
|
371
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
372
|
+
"""Get coordinates for raster cell centres based on raster metadata and shape."""
|
|
362
373
|
rows, cols = np.indices(shape)
|
|
363
374
|
xs, ys = rasterio.transform.xy(
|
|
364
|
-
transform=transform, rows=rows, cols=cols, offset="center"
|
|
375
|
+
transform=raster_meta.transform, rows=rows, cols=cols, offset="center"
|
|
365
376
|
)
|
|
366
377
|
grid_x = np.array(xs).ravel()
|
|
367
378
|
grid_y = np.array(ys).ravel()
|
|
368
379
|
|
|
369
|
-
|
|
370
|
-
try:
|
|
371
|
-
interpolator = LinearNDInterpolator(
|
|
372
|
-
points=xy_points, values=z, fill_value=np.nan
|
|
373
|
-
)
|
|
374
|
-
except QhullError as err:
|
|
375
|
-
msg = (
|
|
376
|
-
"Failed to interpolate. This may be due to insufficient or "
|
|
377
|
-
"degenerate input points. Ensure that the (x, y) points are not all "
|
|
378
|
-
"collinear (i.e. that the convex hull is non-degenerate)."
|
|
379
|
-
)
|
|
380
|
-
raise ValueError(msg) from err
|
|
381
|
-
|
|
382
|
-
grid_values = np.array(interpolator(np.column_stack((grid_x, grid_y))))
|
|
383
|
-
|
|
384
|
-
arr = grid_values.reshape(shape).astype(np.float32)
|
|
385
|
-
|
|
386
|
-
raster_meta = RasterMeta(
|
|
387
|
-
cell_size=cell_size,
|
|
388
|
-
crs=crs,
|
|
389
|
-
transform=transform,
|
|
390
|
-
)
|
|
391
|
-
return Raster(arr=arr, raster_meta=raster_meta)
|
|
380
|
+
return grid_x, grid_y
|
rastr/gis/crs.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pyproj import CRS
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_affine_sign(crs: CRS | str) -> tuple[Literal[+1, -1], Literal[+1, -1]]:
|
|
10
|
+
"""Return (x_sign, y_sign) for an Affine scale, given a CRS.
|
|
11
|
+
|
|
12
|
+
Some coordinate systems may use unconventional axis directions, in which case
|
|
13
|
+
the correct direction may not be possible to infer correctly. In these cases,
|
|
14
|
+
the assumption is that x increases to the right, and y increases upwards.
|
|
15
|
+
"""
|
|
16
|
+
crs = CRS.from_user_input(crs)
|
|
17
|
+
|
|
18
|
+
# Try to detect horizontal axis directions from CRS metadata
|
|
19
|
+
dir_x, dir_y, *_ = [(a.direction or "").lower() for a in crs.axis_info]
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
if _is_conventional_direction(dir_x):
|
|
23
|
+
x_sign = +1
|
|
24
|
+
else:
|
|
25
|
+
x_sign = -1
|
|
26
|
+
except NotImplementedError:
|
|
27
|
+
msg = (
|
|
28
|
+
f"Could not determine x-axis direction from CRS axis info '{dir_x}'. "
|
|
29
|
+
"Falling back to +1 (increasing to the right)."
|
|
30
|
+
)
|
|
31
|
+
warnings.warn(msg, stacklevel=2)
|
|
32
|
+
x_sign = +1
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
if _is_conventional_direction(dir_y):
|
|
36
|
+
y_sign = -1
|
|
37
|
+
else:
|
|
38
|
+
y_sign = +1
|
|
39
|
+
except NotImplementedError:
|
|
40
|
+
msg = (
|
|
41
|
+
f"Could not determine y-axis direction from CRS axis info '{dir_y}'. "
|
|
42
|
+
"Falling back to -1 (increasing upwards)."
|
|
43
|
+
)
|
|
44
|
+
warnings.warn(msg, stacklevel=2)
|
|
45
|
+
y_sign = -1
|
|
46
|
+
|
|
47
|
+
return x_sign, y_sign
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_conventional_direction(direction: str) -> bool:
|
|
51
|
+
"""Return True if the axis direction indicates positive increase."""
|
|
52
|
+
if (
|
|
53
|
+
"north" in direction
|
|
54
|
+
or "up" in direction
|
|
55
|
+
or "east" in direction
|
|
56
|
+
or "right" in direction
|
|
57
|
+
):
|
|
58
|
+
return True
|
|
59
|
+
elif (
|
|
60
|
+
"south" in direction
|
|
61
|
+
or "down" in direction
|
|
62
|
+
or "west" in direction
|
|
63
|
+
or "left" in direction
|
|
64
|
+
):
|
|
65
|
+
return False
|
|
66
|
+
else:
|
|
67
|
+
raise NotImplementedError
|
rastr/gis/fishnet.py
CHANGED
|
@@ -41,8 +41,20 @@ def get_point_grid_shape(
|
|
|
41
41
|
"""Calculate the shape of the point grid based on bounds and cell size."""
|
|
42
42
|
|
|
43
43
|
xmin, ymin, xmax, ymax = bounds
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
ncols_exact = (xmax - xmin) / cell_size
|
|
45
|
+
nrows_exact = (ymax - ymin) / cell_size
|
|
46
|
+
|
|
47
|
+
# Use round for values very close to integers to avoid floating-point
|
|
48
|
+
# sensitivity while maintaining ceil behavior for truly fractional values
|
|
49
|
+
if np.isclose(ncols_exact, np.round(ncols_exact)):
|
|
50
|
+
ncols = int(np.round(ncols_exact))
|
|
51
|
+
else:
|
|
52
|
+
ncols = int(np.ceil(ncols_exact))
|
|
53
|
+
|
|
54
|
+
if np.isclose(nrows_exact, np.round(nrows_exact)):
|
|
55
|
+
nrows = int(np.round(nrows_exact))
|
|
56
|
+
else:
|
|
57
|
+
nrows = int(np.ceil(nrows_exact))
|
|
46
58
|
|
|
47
59
|
return nrows, ncols
|
|
48
60
|
|
rastr/gis/interpolate.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def interpn_kernel(
|
|
12
|
+
points: np.ndarray,
|
|
13
|
+
values: np.ndarray,
|
|
14
|
+
*,
|
|
15
|
+
xi: np.ndarray,
|
|
16
|
+
kernel: Callable[[np.ndarray], np.ndarray] | None = None,
|
|
17
|
+
) -> np.ndarray:
|
|
18
|
+
"""Interpolate scattered data to new points, with optional kernel transformation.
|
|
19
|
+
|
|
20
|
+
For example, you could provide a kernel to transform cartesian coordinate points
|
|
21
|
+
to polar coordinates before interpolation, giving interpolation which follows the
|
|
22
|
+
circular pattern of the data.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
points: Array of shape (n_points, n_dimensions) representing the input points.
|
|
26
|
+
values: Array of shape (n_points,) representing the values at each input point.
|
|
27
|
+
xi: Array of shape (m_points, n_dimensions) representing the points to
|
|
28
|
+
interpolate to.
|
|
29
|
+
kernel: Optional function to transform points (and xi) before interpolation.
|
|
30
|
+
"""
|
|
31
|
+
from scipy.interpolate import LinearNDInterpolator
|
|
32
|
+
from scipy.spatial import QhullError
|
|
33
|
+
|
|
34
|
+
if kernel is not None:
|
|
35
|
+
xi = kernel(xi)
|
|
36
|
+
points = kernel(points)
|
|
37
|
+
try:
|
|
38
|
+
interpolator = LinearNDInterpolator(
|
|
39
|
+
points=points, values=values, fill_value=np.nan
|
|
40
|
+
)
|
|
41
|
+
except QhullError as err:
|
|
42
|
+
msg = (
|
|
43
|
+
"Failed to interpolate. This may be due to insufficient or "
|
|
44
|
+
"degenerate input points. Ensure that the (x, y) points are not all "
|
|
45
|
+
"collinear (i.e. that the convex hull is non-degenerate)."
|
|
46
|
+
)
|
|
47
|
+
raise ValueError(msg) from err
|
|
48
|
+
|
|
49
|
+
grid_values = np.array(interpolator(xi))
|
|
50
|
+
return grid_values
|
rastr/gis/smooth.py
CHANGED
|
@@ -5,16 +5,17 @@ Fork + Port of <https://github.com/philipschall/shapelysmooth> (Public domain)
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
from typing import TYPE_CHECKING,
|
|
8
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
9
9
|
|
|
10
10
|
import numpy as np
|
|
11
|
+
from numpy.lib.stride_tricks import sliding_window_view
|
|
11
12
|
from shapely.geometry import LineString, Polygon
|
|
12
13
|
from typing_extensions import assert_never
|
|
13
14
|
|
|
14
15
|
if TYPE_CHECKING:
|
|
15
16
|
from numpy.typing import NDArray
|
|
16
17
|
|
|
17
|
-
T
|
|
18
|
+
T = TypeVar("T", bound=LineString | Polygon)
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
class InputeTypeError(TypeError):
|
|
@@ -38,12 +39,12 @@ def catmull_rom_smooth(geometry: T, alpha: float = 0.5, subdivs: int = 10) -> T:
|
|
|
38
39
|
coords, interior_coords = _get_coords(geometry)
|
|
39
40
|
coords_smoothed = _catmull_rom(coords, alpha=alpha, subdivs=subdivs)
|
|
40
41
|
if isinstance(geometry, LineString):
|
|
41
|
-
return
|
|
42
|
+
return geometry.__class__(coords_smoothed)
|
|
42
43
|
elif isinstance(geometry, Polygon):
|
|
43
44
|
interior_coords_smoothed = [
|
|
44
45
|
_catmull_rom(c, alpha=alpha, subdivs=subdivs) for c in interior_coords
|
|
45
46
|
]
|
|
46
|
-
return
|
|
47
|
+
return geometry.__class__(coords_smoothed, holes=interior_coords_smoothed)
|
|
47
48
|
else:
|
|
48
49
|
assert_never(geometry)
|
|
49
50
|
|
|
@@ -55,7 +56,8 @@ def _catmull_rom(
|
|
|
55
56
|
subdivs: int = 8,
|
|
56
57
|
) -> list[tuple[float, float]]:
|
|
57
58
|
arr = np.asarray(coords, dtype=float)
|
|
58
|
-
|
|
59
|
+
n = arr.shape[0]
|
|
60
|
+
if n < 2:
|
|
59
61
|
return arr.tolist()
|
|
60
62
|
|
|
61
63
|
is_closed = np.allclose(arr[0], arr[-1])
|
|
@@ -70,63 +72,80 @@ def _catmull_rom(
|
|
|
70
72
|
]
|
|
71
73
|
)
|
|
72
74
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
)
|
|
75
|
+
# Shape of (segments, 4, D)
|
|
76
|
+
segments = sliding_window_view(arr, (4, arr.shape[1]))[:, 0, :]
|
|
77
|
+
|
|
78
|
+
# Distances and tangent values
|
|
79
|
+
diffs = np.diff(segments, axis=1)
|
|
80
|
+
dists = np.linalg.norm(diffs, axis=2)
|
|
81
|
+
tangents = np.concatenate(
|
|
82
|
+
[np.zeros((len(dists), 1)), np.cumsum(dists**alpha, axis=1)], axis=1
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Build ts per segment
|
|
86
|
+
if subdivs > 1:
|
|
87
|
+
seg_lens = (tangents[:, 2] - tangents[:, 1]) / subdivs
|
|
88
|
+
u = np.linspace(1, subdivs - 1, subdivs - 1)
|
|
89
|
+
ts = tangents[:, [1]] + seg_lens[:, None] * u # (N-3, subdivs-1)
|
|
90
|
+
else:
|
|
91
|
+
ts = np.empty((len(segments), 0))
|
|
92
|
+
|
|
93
|
+
# Vectorize over segments
|
|
94
|
+
out_segments = []
|
|
95
|
+
for seg, tang, tvals in zip(segments, tangents, ts, strict=True):
|
|
96
|
+
if tvals.size:
|
|
97
|
+
out_segments.append(
|
|
98
|
+
_recursive_eval(seg, np.asarray(tang), np.asarray(tvals))
|
|
99
|
+
)
|
|
100
|
+
if out_segments:
|
|
101
|
+
all_midpoints = np.vstack(out_segments)
|
|
102
|
+
else:
|
|
103
|
+
all_midpoints = np.empty((0, arr.shape[1]))
|
|
104
|
+
|
|
105
|
+
# Gather final output in order
|
|
106
|
+
result = [tuple(arr[1])]
|
|
107
|
+
idx = 0
|
|
108
|
+
for k in range(len(segments)):
|
|
109
|
+
block = all_midpoints[idx : idx + max(subdivs - 1, 0)]
|
|
110
|
+
result.extend(map(tuple, block))
|
|
111
|
+
result.append(tuple(segments[k, 2]))
|
|
112
|
+
idx += max(subdivs - 1, 0)
|
|
113
|
+
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _recursive_eval(slice4: NDArray, tangents: NDArray, ts: NDArray) -> NDArray:
|
|
97
118
|
"""De Boor/De Casteljau-style recursive linear interpolation over 4 control points.
|
|
98
119
|
|
|
99
120
|
Parameterized by the non-uniform 'tangents' values.
|
|
100
121
|
"""
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
out.append(tuple(points[0]))
|
|
129
|
-
return out
|
|
122
|
+
slice4 = np.asarray(slice4, dtype=float)
|
|
123
|
+
tangents = np.asarray(tangents, dtype=float)
|
|
124
|
+
ts = np.asarray(ts, dtype=float)
|
|
125
|
+
bigm = ts.shape[0]
|
|
126
|
+
bigd = slice4.shape[1]
|
|
127
|
+
|
|
128
|
+
# Initialize points for all ts, shape (M, 4, D)
|
|
129
|
+
points = np.broadcast_to(slice4, (bigm, 4, bigd)).copy()
|
|
130
|
+
|
|
131
|
+
# Recursive interpolation, but vectorized across all ts
|
|
132
|
+
for r in range(1, 4):
|
|
133
|
+
idx = max(r - 2, 0)
|
|
134
|
+
denom = tangents[r - idx : 4 - idx] - tangents[idx : 4 - r + idx]
|
|
135
|
+
denom = np.where(denom == 0, np.finfo(float).eps, denom) # avoid div 0
|
|
136
|
+
|
|
137
|
+
# Compute weights for all parameter values at once
|
|
138
|
+
left_w = (tangents[r - idx : 4 - idx][None, :] - ts[:, None]) / denom
|
|
139
|
+
right_w = 1 - left_w
|
|
140
|
+
|
|
141
|
+
# Weighted sums between consecutive points
|
|
142
|
+
points = (
|
|
143
|
+
left_w[..., None] * points[:, 0 : 4 - r, :]
|
|
144
|
+
+ right_w[..., None] * points[:, 1 : 5 - r, :]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Result is first (and only) point at this level
|
|
148
|
+
return points[:, 0, :]
|
|
130
149
|
|
|
131
150
|
|
|
132
151
|
def _get_coords(
|
rastr/io.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import TYPE_CHECKING
|
|
4
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
5
5
|
|
|
6
6
|
import numpy as np
|
|
7
7
|
import rasterio
|
|
@@ -14,28 +14,48 @@ from rastr.raster import Raster
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
15
|
from numpy.typing import NDArray
|
|
16
16
|
|
|
17
|
+
R = TypeVar("R", bound=Raster)
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
def read_raster_inmem(
|
|
19
|
-
raster_path: Path | str,
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
raster_path: Path | str,
|
|
22
|
+
*,
|
|
23
|
+
crs: CRS | str | None = None,
|
|
24
|
+
cls: type[R] = Raster,
|
|
25
|
+
) -> R:
|
|
26
|
+
"""Read raster data from a file and return an in-memory Raster object.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
raster_path: Path to the raster file.
|
|
30
|
+
crs: Optional CRS to override the raster's native CRS.
|
|
31
|
+
cls: The Raster subclass to instantiate. This is mostly for internal use,
|
|
32
|
+
but can be useful if you have a custom `Raster` subclass.
|
|
33
|
+
"""
|
|
22
34
|
crs = CRS.from_user_input(crs) if crs is not None else None
|
|
23
35
|
|
|
24
36
|
with rasterio.open(raster_path, mode="r") as dst:
|
|
25
37
|
# Read the entire array
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
raw_arr: NDArray = dst.read()
|
|
39
|
+
raw_arr = raw_arr.squeeze()
|
|
40
|
+
|
|
28
41
|
# Extract metadata
|
|
29
42
|
cell_size = dst.res[0]
|
|
30
43
|
if crs is None:
|
|
31
44
|
crs = CRS.from_user_input(dst.crs)
|
|
32
45
|
transform = dst.transform
|
|
33
46
|
nodata = dst.nodata
|
|
47
|
+
|
|
48
|
+
# Cast integers to float16 to handle NaN values
|
|
49
|
+
if np.issubdtype(raw_arr.dtype, np.integer):
|
|
50
|
+
arr = raw_arr.astype(np.float16)
|
|
51
|
+
else:
|
|
52
|
+
arr = raw_arr
|
|
53
|
+
|
|
34
54
|
if nodata is not None:
|
|
35
|
-
arr[
|
|
55
|
+
arr[raw_arr == nodata] = np.nan
|
|
36
56
|
|
|
37
57
|
raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
|
|
38
|
-
raster_obj =
|
|
58
|
+
raster_obj = cls(arr=arr, raster_meta=raster_meta)
|
|
39
59
|
return raster_obj
|
|
40
60
|
|
|
41
61
|
|
|
@@ -81,10 +101,16 @@ def read_raster_mosaic_inmem(
|
|
|
81
101
|
crs = CRS.from_user_input(sources[0].crs)
|
|
82
102
|
|
|
83
103
|
nodata = sources[0].nodata
|
|
84
|
-
|
|
85
|
-
arr[arr == nodata] = np.nan
|
|
104
|
+
raw_arr = arr.squeeze()
|
|
86
105
|
|
|
87
|
-
|
|
106
|
+
# Cast integers to float16 to handle NaN values
|
|
107
|
+
if np.issubdtype(raw_arr.dtype, np.integer):
|
|
108
|
+
arr = raw_arr.astype(np.float16)
|
|
109
|
+
else:
|
|
110
|
+
arr = raw_arr
|
|
111
|
+
|
|
112
|
+
if nodata is not None:
|
|
113
|
+
arr[raw_arr == nodata] = np.nan
|
|
88
114
|
|
|
89
115
|
raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
|
|
90
116
|
raster_obj = Raster(arr=arr, raster_meta=raster_meta)
|
rastr/meta.py
CHANGED
|
@@ -7,6 +7,8 @@ from affine import Affine
|
|
|
7
7
|
from pydantic import BaseModel, InstanceOf
|
|
8
8
|
from pyproj import CRS
|
|
9
9
|
|
|
10
|
+
from rastr.gis.crs import get_affine_sign
|
|
11
|
+
|
|
10
12
|
if TYPE_CHECKING:
|
|
11
13
|
from numpy.typing import NDArray
|
|
12
14
|
from typing_extensions import Self
|
|
@@ -85,3 +87,99 @@ class RasterMeta(BaseModel, extra="forbid"):
|
|
|
85
87
|
y_idx = np.arange(n_rows) + 0.5
|
|
86
88
|
_, y_coords = self.transform * (x_idx, y_idx) # type: ignore[reportAssignmentType] overloaded tuple size in affine
|
|
87
89
|
return y_coords
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def infer(
|
|
93
|
+
cls,
|
|
94
|
+
x: np.ndarray,
|
|
95
|
+
y: np.ndarray,
|
|
96
|
+
*,
|
|
97
|
+
cell_size: float | None = None,
|
|
98
|
+
crs: CRS,
|
|
99
|
+
) -> tuple[Self, tuple[int, int]]:
|
|
100
|
+
"""Automatically get recommended raster metadata (and shape) using data bounds.
|
|
101
|
+
|
|
102
|
+
The cell size can be provided, or a heuristic will be used based on the spacing
|
|
103
|
+
of the (x, y) points.
|
|
104
|
+
"""
|
|
105
|
+
# Heuristic for cell size if not provided
|
|
106
|
+
if cell_size is None:
|
|
107
|
+
cell_size = infer_cell_size(x, y)
|
|
108
|
+
|
|
109
|
+
shape = infer_shape(x, y, cell_size=cell_size)
|
|
110
|
+
transform = infer_transform(x, y, cell_size=cell_size, crs=crs)
|
|
111
|
+
|
|
112
|
+
raster_meta = cls(
|
|
113
|
+
cell_size=cell_size,
|
|
114
|
+
crs=crs,
|
|
115
|
+
transform=transform,
|
|
116
|
+
)
|
|
117
|
+
return raster_meta, shape
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def infer_transform(
|
|
121
|
+
x: np.ndarray,
|
|
122
|
+
y: np.ndarray,
|
|
123
|
+
*,
|
|
124
|
+
cell_size: float | None = None,
|
|
125
|
+
crs: CRS,
|
|
126
|
+
) -> Affine:
|
|
127
|
+
"""Infer a suitable raster transform based on the bounds of (x, y) data points."""
|
|
128
|
+
if cell_size is None:
|
|
129
|
+
cell_size = infer_cell_size(x, y)
|
|
130
|
+
|
|
131
|
+
(xs, ys) = get_affine_sign(crs)
|
|
132
|
+
return Affine.translation(*infer_origin(x, y)) * Affine.scale(
|
|
133
|
+
xs * cell_size, ys * cell_size
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def infer_origin(x: np.ndarray, y: np.ndarray) -> tuple[float, float]:
|
|
138
|
+
"""Infer a suitable raster origin based on the bounds of (x, y) data points."""
|
|
139
|
+
# Compute bounds from data
|
|
140
|
+
minx, _miny, _maxx, maxy = np.min(x), np.min(y), np.max(x), np.max(y)
|
|
141
|
+
|
|
142
|
+
origin = (minx, maxy)
|
|
143
|
+
return origin
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def infer_shape(
|
|
147
|
+
x: np.ndarray, y: np.ndarray, *, cell_size: float | None = None
|
|
148
|
+
) -> tuple[int, int]:
|
|
149
|
+
"""Infer a suitable raster shape based on the bounds of (x, y) data points."""
|
|
150
|
+
if cell_size is None:
|
|
151
|
+
cell_size = infer_cell_size(x, y)
|
|
152
|
+
|
|
153
|
+
# Compute bounds from data
|
|
154
|
+
minx, miny, maxx, maxy = np.min(x), np.min(y), np.max(x), np.max(y)
|
|
155
|
+
|
|
156
|
+
# Compute grid shape
|
|
157
|
+
width = int(np.ceil((maxx - minx) / cell_size))
|
|
158
|
+
height = int(np.ceil((maxy - miny) / cell_size))
|
|
159
|
+
shape = (height, width)
|
|
160
|
+
|
|
161
|
+
return shape
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def infer_cell_size(x: np.ndarray, y: np.ndarray) -> float:
|
|
165
|
+
"""Infer a suitable cell size based on the spacing of (x, y) data points.
|
|
166
|
+
|
|
167
|
+
When points are distributed regularly, this corresponds to roughly half the distance
|
|
168
|
+
between neighboring points.
|
|
169
|
+
|
|
170
|
+
When distributed irregularly, the size is more influenced by the densest clusters of
|
|
171
|
+
points, i.e. the cell size will be small enough to capture the detail in these
|
|
172
|
+
clusters.
|
|
173
|
+
|
|
174
|
+
This is based on a heuristic which has been found to work well in practice.
|
|
175
|
+
"""
|
|
176
|
+
from scipy.spatial import KDTree
|
|
177
|
+
|
|
178
|
+
# Half the 5th percentile of nearest neighbor distances between the (x,y) points
|
|
179
|
+
xy_points = np.column_stack((x, y))
|
|
180
|
+
tree = KDTree(xy_points)
|
|
181
|
+
distances, _ = tree.query(xy_points, k=2)
|
|
182
|
+
distances: np.ndarray
|
|
183
|
+
cell_size = float(np.percentile(distances[distances > 0], 5)) / 2
|
|
184
|
+
|
|
185
|
+
return cell_size
|
rastr/raster.py
CHANGED
|
@@ -108,6 +108,16 @@ class Raster(BaseModel):
|
|
|
108
108
|
"""Set the transform via meta."""
|
|
109
109
|
self.meta.transform = value
|
|
110
110
|
|
|
111
|
+
@property
|
|
112
|
+
def cell_size(self) -> float:
|
|
113
|
+
"""Convenience property to access the cell size via meta."""
|
|
114
|
+
return self.meta.cell_size
|
|
115
|
+
|
|
116
|
+
@cell_size.setter
|
|
117
|
+
def cell_size(self, value: float) -> None:
|
|
118
|
+
"""Set the cell size via meta."""
|
|
119
|
+
self.meta.cell_size = value
|
|
120
|
+
|
|
111
121
|
def __init__(
|
|
112
122
|
self,
|
|
113
123
|
*,
|
|
@@ -663,8 +673,15 @@ class Raster(BaseModel):
|
|
|
663
673
|
|
|
664
674
|
return raster_gdf
|
|
665
675
|
|
|
666
|
-
def to_file(self, path: Path | str) -> None:
|
|
667
|
-
"""Write the raster to a GeoTIFF file.
|
|
676
|
+
def to_file(self, path: Path | str, **kwargs: Any) -> None:
|
|
677
|
+
"""Write the raster to a GeoTIFF file.
|
|
678
|
+
|
|
679
|
+
Args:
|
|
680
|
+
path: Path to output file.
|
|
681
|
+
**kwargs: Additional keyword arguments to pass to `rasterio.open()`. If
|
|
682
|
+
`nodata` is provided, NaN values in the raster will be replaced
|
|
683
|
+
with the nodata value.
|
|
684
|
+
"""
|
|
668
685
|
|
|
669
686
|
path = Path(path)
|
|
670
687
|
|
|
@@ -679,6 +696,15 @@ class Raster(BaseModel):
|
|
|
679
696
|
msg = f"Unsupported file extension: {suffix}"
|
|
680
697
|
raise ValueError(msg)
|
|
681
698
|
|
|
699
|
+
# Handle nodata: use provided value or default to np.nan
|
|
700
|
+
if "nodata" in kwargs:
|
|
701
|
+
# Replace NaN values with the nodata value
|
|
702
|
+
nodata_value = kwargs.pop("nodata")
|
|
703
|
+
arr_to_write = np.where(np.isnan(self.arr), nodata_value, self.arr)
|
|
704
|
+
else:
|
|
705
|
+
nodata_value = np.nan
|
|
706
|
+
arr_to_write = self.arr
|
|
707
|
+
|
|
682
708
|
with rasterio.open(
|
|
683
709
|
path,
|
|
684
710
|
"w",
|
|
@@ -689,17 +715,18 @@ class Raster(BaseModel):
|
|
|
689
715
|
dtype=self.arr.dtype,
|
|
690
716
|
crs=self.raster_meta.crs,
|
|
691
717
|
transform=self.raster_meta.transform,
|
|
692
|
-
nodata=
|
|
718
|
+
nodata=nodata_value,
|
|
719
|
+
**kwargs,
|
|
693
720
|
) as dst:
|
|
694
721
|
try:
|
|
695
|
-
dst.write(
|
|
722
|
+
dst.write(arr_to_write, 1)
|
|
696
723
|
except CPLE_BaseError as err:
|
|
697
724
|
msg = f"Failed to write raster to file: {err}"
|
|
698
725
|
raise OSError(msg) from err
|
|
699
726
|
|
|
700
727
|
def __str__(self) -> str:
|
|
701
728
|
cls = self.__class__
|
|
702
|
-
mean =
|
|
729
|
+
mean = self.mean()
|
|
703
730
|
return f"{cls.__name__}(shape={self.arr.shape}, {mean=})"
|
|
704
731
|
|
|
705
732
|
def __repr__(self) -> str:
|
|
@@ -719,6 +746,34 @@ class Raster(BaseModel):
|
|
|
719
746
|
raster_meta = RasterMeta.example()
|
|
720
747
|
return cls(arr=arr, raster_meta=raster_meta)
|
|
721
748
|
|
|
749
|
+
@classmethod
|
|
750
|
+
def full_like(cls, other: Raster, *, fill_value: float) -> Self:
|
|
751
|
+
"""Create a raster with the same metadata as another but filled with a constant.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
other: The raster to copy metadata from.
|
|
755
|
+
fill_value: The constant value to fill all cells with.
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
A new raster with the same shape and metadata as `other`, but with all cells
|
|
759
|
+
set to `fill_value`.
|
|
760
|
+
"""
|
|
761
|
+
arr = np.full(other.shape, fill_value, dtype=np.float32)
|
|
762
|
+
return cls(arr=arr, raster_meta=other.raster_meta)
|
|
763
|
+
|
|
764
|
+
@classmethod
|
|
765
|
+
def read_file(cls, filename: Path | str, crs: CRS | str | None = None) -> Self:
|
|
766
|
+
"""Read raster data from a file and return an in-memory Raster object.
|
|
767
|
+
|
|
768
|
+
Args:
|
|
769
|
+
filename: Path to the raster file.
|
|
770
|
+
crs: Optional coordinate reference system to override the file's CRS.
|
|
771
|
+
"""
|
|
772
|
+
# Import here to avoid circular import (rastr.io imports Raster)
|
|
773
|
+
from rastr.io import read_raster_inmem # noqa: PLC0415
|
|
774
|
+
|
|
775
|
+
return read_raster_inmem(filename, crs=crs, cls=cls)
|
|
776
|
+
|
|
722
777
|
@overload
|
|
723
778
|
def apply(
|
|
724
779
|
self,
|
|
@@ -819,6 +874,78 @@ class Raster(BaseModel):
|
|
|
819
874
|
new_raster.arr = filled_arr
|
|
820
875
|
return new_raster
|
|
821
876
|
|
|
877
|
+
def replace(
|
|
878
|
+
self, to_replace: float | dict[float, float], value: float | None = None
|
|
879
|
+
) -> Self:
|
|
880
|
+
"""Replace values in the raster with other values.
|
|
881
|
+
|
|
882
|
+
Creates a new raster with the specified values replaced. This is useful for
|
|
883
|
+
operations like replacing zeros with NaNs, or vice versa.
|
|
884
|
+
|
|
885
|
+
The method supports two interfaces:
|
|
886
|
+
1. Single replacement: `raster.replace(to_replace=0, value=np.nan)`
|
|
887
|
+
2. Multiple replacements using a dictionary:
|
|
888
|
+
`raster.replace({0: np.nan, -999: np.nan})`
|
|
889
|
+
|
|
890
|
+
Args:
|
|
891
|
+
to_replace: Value to be replaced, or a dictionary mapping values to
|
|
892
|
+
their replacements.
|
|
893
|
+
value: Replacement value. Required when to_replace is a float, must be
|
|
894
|
+
None when to_replace is a dict.
|
|
895
|
+
|
|
896
|
+
Examples:
|
|
897
|
+
>>> # Replace a single value
|
|
898
|
+
>>> raster.replace(to_replace=0, value=np.nan)
|
|
899
|
+
>>> # Replace multiple values
|
|
900
|
+
>>> raster.replace({0: np.nan, -999: np.nan})
|
|
901
|
+
"""
|
|
902
|
+
# Determine the replacement map
|
|
903
|
+
if isinstance(to_replace, dict):
|
|
904
|
+
if value is not None:
|
|
905
|
+
msg = "value must be None when to_replace is a dict"
|
|
906
|
+
raise ValueError(msg)
|
|
907
|
+
map_ = to_replace
|
|
908
|
+
else:
|
|
909
|
+
if value is None:
|
|
910
|
+
msg = "value must be specified when to_replace is a float"
|
|
911
|
+
raise ValueError(msg)
|
|
912
|
+
map_ = {to_replace: value}
|
|
913
|
+
|
|
914
|
+
# Start with a copy of the array
|
|
915
|
+
replaced_arr = self.arr.copy()
|
|
916
|
+
|
|
917
|
+
# Check if we need to convert to float (if assigning NaN to non-float array)
|
|
918
|
+
needs_float = any(
|
|
919
|
+
np.isnan(new_val) for new_val in map_.values()
|
|
920
|
+
) and not np.issubdtype(replaced_arr.dtype, np.floating)
|
|
921
|
+
if needs_float:
|
|
922
|
+
replaced_arr = replaced_arr.astype(float)
|
|
923
|
+
|
|
924
|
+
# Apply each replacement based on the original array values
|
|
925
|
+
# to prevent chained replacements
|
|
926
|
+
for old_val, new_val in map_.items():
|
|
927
|
+
# Handle NaN specially since NaN != NaN
|
|
928
|
+
if np.isnan(old_val):
|
|
929
|
+
mask = np.isnan(self.arr)
|
|
930
|
+
else:
|
|
931
|
+
mask = self.arr == old_val
|
|
932
|
+
|
|
933
|
+
replaced_arr[mask] = new_val
|
|
934
|
+
|
|
935
|
+
new_raster = self.model_copy()
|
|
936
|
+
new_raster.arr = replaced_arr
|
|
937
|
+
return new_raster
|
|
938
|
+
|
|
939
|
+
def copy(self) -> Self: # type: ignore[override]
|
|
940
|
+
"""Create a copy of the raster.
|
|
941
|
+
|
|
942
|
+
This method wraps `model_copy()` for convenience.
|
|
943
|
+
|
|
944
|
+
Returns:
|
|
945
|
+
A new Raster instance.
|
|
946
|
+
"""
|
|
947
|
+
return self.model_copy(deep=True)
|
|
948
|
+
|
|
822
949
|
def get_xy(self) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
823
950
|
"""Get the x and y coordinates of the raster cell centres in meshgrid format.
|
|
824
951
|
|
|
@@ -835,7 +962,7 @@ class Raster(BaseModel):
|
|
|
835
962
|
return coords[:, :, 0], coords[:, :, 1]
|
|
836
963
|
|
|
837
964
|
def contour(
|
|
838
|
-
self, levels:
|
|
965
|
+
self, levels: Collection[float] | NDArray, *, smoothing: bool = True
|
|
839
966
|
) -> gpd.GeoDataFrame:
|
|
840
967
|
"""Create contour lines from the raster data, optionally with smoothing.
|
|
841
968
|
|
|
@@ -848,8 +975,8 @@ class Raster(BaseModel):
|
|
|
848
975
|
contouring, to denoise the contours.
|
|
849
976
|
|
|
850
977
|
Args:
|
|
851
|
-
levels: A
|
|
852
|
-
will be generated for each level in this sequence.
|
|
978
|
+
levels: A collection or array of contour levels to generate. The contour
|
|
979
|
+
lines will be generated for each level in this sequence.
|
|
853
980
|
smoothing: Defaults to true, which corresponds to applying a smoothing
|
|
854
981
|
algorithm to the contour lines. At the moment, this is the
|
|
855
982
|
Catmull-Rom spline algorithm. If set to False, the raw
|
|
@@ -905,19 +1032,40 @@ class Raster(BaseModel):
|
|
|
905
1032
|
# Dissolve contours by level to merge all contour lines of the same level
|
|
906
1033
|
return contour_gdf.dissolve(by="level", as_index=False)
|
|
907
1034
|
|
|
908
|
-
def blur(self, sigma: float) -> Self:
|
|
1035
|
+
def blur(self, sigma: float, *, preserve_nan: bool = True) -> Self:
|
|
909
1036
|
"""Apply a Gaussian blur to the raster data.
|
|
910
1037
|
|
|
911
1038
|
Args:
|
|
912
1039
|
sigma: Standard deviation for Gaussian kernel, in units of geographic
|
|
913
1040
|
coordinate distance (e.g. meters). A larger sigma results in a more
|
|
914
1041
|
blurred image.
|
|
1042
|
+
preserve_nan: If True, applies NaN-safe blurring by extrapolating NaN values
|
|
1043
|
+
before blurring and restoring them afterwards. This prevents
|
|
1044
|
+
NaNs from spreading into valid data during the blur operation.
|
|
915
1045
|
"""
|
|
916
1046
|
from scipy.ndimage import gaussian_filter
|
|
917
1047
|
|
|
918
1048
|
cell_sigma = sigma / self.raster_meta.cell_size
|
|
919
1049
|
|
|
920
|
-
|
|
1050
|
+
if preserve_nan:
|
|
1051
|
+
# Save the original NaN mask
|
|
1052
|
+
nan_mask = np.isnan(self.arr)
|
|
1053
|
+
|
|
1054
|
+
# If there are no NaNs, just apply regular blur
|
|
1055
|
+
if not np.any(nan_mask):
|
|
1056
|
+
blurred_array = gaussian_filter(self.arr, sigma=cell_sigma)
|
|
1057
|
+
else:
|
|
1058
|
+
# Extrapolate to fill NaN values temporarily
|
|
1059
|
+
extrapolated_arr = fillna_nearest_neighbours(arr=self.arr)
|
|
1060
|
+
|
|
1061
|
+
# Apply blur to the extrapolated array
|
|
1062
|
+
blurred_array = gaussian_filter(extrapolated_arr, sigma=cell_sigma)
|
|
1063
|
+
|
|
1064
|
+
# Restore original NaN values
|
|
1065
|
+
blurred_array = np.where(nan_mask, np.nan, blurred_array)
|
|
1066
|
+
else:
|
|
1067
|
+
blurred_array = gaussian_filter(self.arr, sigma=cell_sigma)
|
|
1068
|
+
|
|
921
1069
|
new_raster = self.model_copy()
|
|
922
1070
|
new_raster.arr = blurred_array
|
|
923
1071
|
return new_raster
|
|
@@ -1175,29 +1323,27 @@ class Raster(BaseModel):
|
|
|
1175
1323
|
|
|
1176
1324
|
return raster
|
|
1177
1325
|
|
|
1178
|
-
def
|
|
1179
|
-
"""Crop the raster by trimming away
|
|
1326
|
+
def _trim_value(self, *, value_mask: NDArray[np.bool_], value_name: str) -> Self:
|
|
1327
|
+
"""Crop the raster by trimming away slices matching the mask at the edges.
|
|
1180
1328
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
Consider using `.extrapolate()` for further cleanup of NaN values.
|
|
1329
|
+
Args:
|
|
1330
|
+
value_mask: Boolean mask where True indicates values to trim
|
|
1331
|
+
value_name: Name of the value type for error messages (e.g., 'NaN', 'zero')
|
|
1186
1332
|
"""
|
|
1187
1333
|
arr = self.arr
|
|
1188
1334
|
|
|
1189
|
-
# Check if the entire array
|
|
1190
|
-
if np.all(
|
|
1191
|
-
msg = "Cannot crop raster: all values are
|
|
1335
|
+
# Check if the entire array matches the mask
|
|
1336
|
+
if np.all(value_mask):
|
|
1337
|
+
msg = f"Cannot crop raster: all values are {value_name}"
|
|
1192
1338
|
raise ValueError(msg)
|
|
1193
1339
|
|
|
1194
|
-
# Find rows and columns that are not all
|
|
1195
|
-
|
|
1196
|
-
|
|
1340
|
+
# Find rows and columns that are not all matching the mask
|
|
1341
|
+
row_mask = np.all(value_mask, axis=1)
|
|
1342
|
+
col_mask = np.all(value_mask, axis=0)
|
|
1197
1343
|
|
|
1198
1344
|
# Find the bounding indices
|
|
1199
|
-
(row_indices,) = np.where(~
|
|
1200
|
-
(col_indices,) = np.where(~
|
|
1345
|
+
(row_indices,) = np.where(~row_mask)
|
|
1346
|
+
(col_indices,) = np.where(~col_mask)
|
|
1201
1347
|
|
|
1202
1348
|
min_row, max_row = row_indices[0], row_indices[-1]
|
|
1203
1349
|
min_col, max_col = col_indices[0], col_indices[-1]
|
|
@@ -1220,6 +1366,26 @@ class Raster(BaseModel):
|
|
|
1220
1366
|
|
|
1221
1367
|
return self.__class__(arr=cropped_arr, raster_meta=new_meta)
|
|
1222
1368
|
|
|
1369
|
+
def trim_nan(self) -> Self:
|
|
1370
|
+
"""Crop the raster by trimming away all-NaN slices at the edges.
|
|
1371
|
+
|
|
1372
|
+
This effectively trims the raster to the smallest bounding box that contains all
|
|
1373
|
+
of the non-NaN values. Note that this does not guarantee no NaN values at all
|
|
1374
|
+
around the edges, only that there won't be entire edges which are all-NaN.
|
|
1375
|
+
|
|
1376
|
+
Consider using `.extrapolate()` for further cleanup of NaN values.
|
|
1377
|
+
"""
|
|
1378
|
+
return self._trim_value(value_mask=np.isnan(self.arr), value_name="NaN")
|
|
1379
|
+
|
|
1380
|
+
def trim_zeros(self) -> Self:
|
|
1381
|
+
"""Crop the raster by trimming away all-zero slices at the edges.
|
|
1382
|
+
|
|
1383
|
+
This effectively trims the raster to the smallest bounding box that contains all
|
|
1384
|
+
of the non-zero values. Note that this does not guarantee no zero values at all
|
|
1385
|
+
around the edges, only that there won't be entire edges which are all-zero.
|
|
1386
|
+
"""
|
|
1387
|
+
return self._trim_value(value_mask=(self.arr == 0), value_name="zero")
|
|
1388
|
+
|
|
1223
1389
|
def resample(
|
|
1224
1390
|
self, new_cell_size: float, *, method: Literal["bilinear"] = "bilinear"
|
|
1225
1391
|
) -> Self:
|
|
@@ -1307,11 +1473,11 @@ def _get_vmin_vmax(
|
|
|
1307
1473
|
category=RuntimeWarning,
|
|
1308
1474
|
)
|
|
1309
1475
|
if vmin is None:
|
|
1310
|
-
_vmin = raster.min()
|
|
1476
|
+
_vmin = float(raster.min())
|
|
1311
1477
|
else:
|
|
1312
1478
|
_vmin = vmin
|
|
1313
1479
|
if vmax is None:
|
|
1314
|
-
_vmax = raster.max()
|
|
1480
|
+
_vmax = float(raster.max())
|
|
1315
1481
|
else:
|
|
1316
1482
|
_vmax = vmax
|
|
1317
1483
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rastr
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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/2b485cc676121c82f468dca7733e444c3033abbe.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
|
|
@@ -77,8 +77,8 @@ from rastr.create import full_raster
|
|
|
77
77
|
from rastr.meta import RasterMeta
|
|
78
78
|
from rastr.raster import Raster
|
|
79
79
|
|
|
80
|
-
#
|
|
81
|
-
raster = Raster.
|
|
80
|
+
# Read a raster from a file
|
|
81
|
+
raster = Raster.read_file("path/to/raster.tif")
|
|
82
82
|
|
|
83
83
|
# Basic arithmetic operations
|
|
84
84
|
doubled = raster * 2
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
rastr/__init__.py,sha256=z26KywZdRKwO-N5Qc34SuuGGwH8Y812csKORc3S4SYU,113
|
|
2
|
+
rastr/_version.py,sha256=uLbRjFSUZAgfl7V7O8zKV5Db36k7tz87ZIVq3l2SWs0,704
|
|
3
|
+
rastr/create.py,sha256=jT2X7mgJoMapnRz-M11dJoKFidaf0k_qleR5zxnRAnw,13195
|
|
4
|
+
rastr/io.py,sha256=RPhypnSNhLaWYdGRzctM9aTXbw9_TuMjvhMvDyUZavk,3640
|
|
5
|
+
rastr/meta.py,sha256=lUZVodFzhnzLI1sr7SgiM9XN9D-n7nXvs0voWTJYlMg,5980
|
|
6
|
+
rastr/raster.py,sha256=9yib1g0HOzPepaLCu_ApEbfUynb0IjQzCD__FEOco1c,54219
|
|
7
|
+
rastr/arr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
rastr/arr/fill.py,sha256=ZSd9mcfzYafkAes2G2q8hJGlxhW47kI2brPf--jds3o,1029
|
|
9
|
+
rastr/gis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
rastr/gis/crs.py,sha256=9K57Ys6P32v0uzap-l7L_HbjolJMX-ETuRB_rN30Qz0,1953
|
|
11
|
+
rastr/gis/fishnet.py,sha256=nAiJ_DuSQP326pLM9JmI8A4QwWWgVu7Mae1K1dWjDc4,3108
|
|
12
|
+
rastr/gis/interpolate.py,sha256=DzjtD5ynnwKP7TrwPiK3P0dOy5ZRzME9bV8-7tn5TFk,1697
|
|
13
|
+
rastr/gis/smooth.py,sha256=FeEiO9RNyX9bP_yM2bvgQPLGyF0lHjll20ur52ehp1c,5182
|
|
14
|
+
rastr-0.7.0.dist-info/METADATA,sha256=yc3gYgbIgMLAoUeubfE0rXiJtN1b9YPbz4Yjewrr0QE,5724
|
|
15
|
+
rastr-0.7.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
16
|
+
rastr-0.7.0.dist-info/licenses/LICENSE,sha256=7qUsx93G2ATTRLZiSYuQofwAX_uWvrqnAiMK8PzxvNc,1080
|
|
17
|
+
rastr-0.7.0.dist-info/RECORD,,
|
rastr-0.5.0.dist-info/RECORD
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
rastr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
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
|
-
rastr/meta.py,sha256=5iDvGkYe8iMMkPV6gSL04jNcLRhuRNFqe9AppUpp55E,2928
|
|
6
|
-
rastr/raster.py,sha256=_1Wr78B0_V9r4oh7X-h5lT1QXlnBsSBW6ZkICGSjUFw,47514
|
|
7
|
-
rastr/arr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
rastr/arr/fill.py,sha256=80ucb36el9s042fDSwK1SZJhp_GNJNMM0fpQTWmJvgE,1001
|
|
9
|
-
rastr/gis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
-
rastr/gis/fishnet.py,sha256=Ic-0HV61ST8OxwhyoMyV_ybihs2xuhgAY-3n4CknAt8,2670
|
|
11
|
-
rastr/gis/smooth.py,sha256=3HQDQHQM5_LeNk21R8Eb8VpF727JcXq21HO9JMvcpW4,4810
|
|
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
|