rastr 0.6.0__py3-none-any.whl → 0.7.1__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.
- rastr/__init__.py +7 -0
- rastr/_version.py +2 -2
- rastr/create.py +34 -49
- rastr/gis/crs.py +67 -0
- rastr/gis/interpolate.py +50 -0
- rastr/gis/smooth.py +77 -54
- rastr/meta.py +98 -0
- rastr/raster.py +63 -1
- {rastr-0.6.0.dist-info → rastr-0.7.1.dist-info}/METADATA +2 -2
- rastr-0.7.1.dist-info/RECORD +17 -0
- rastr-0.6.0.dist-info/RECORD +0 -15
- {rastr-0.6.0.dist-info → rastr-0.7.1.dist-info}/WHEEL +0 -0
- {rastr-0.6.0.dist-info → rastr-0.7.1.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.1'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 7, 1)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
rastr/create.py
CHANGED
|
@@ -11,7 +11,9 @@ 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
|
|
|
@@ -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 = []
|
|
@@ -312,14 +315,31 @@ def raster_from_point_cloud(
|
|
|
312
315
|
Raises:
|
|
313
316
|
ValueError: If any (x, y) points are duplicated, or if they are all collinear.
|
|
314
317
|
"""
|
|
315
|
-
from scipy.interpolate import LinearNDInterpolator
|
|
316
|
-
from scipy.spatial import KDTree, QhullError
|
|
317
|
-
|
|
318
|
-
x = np.asarray(x).ravel()
|
|
319
|
-
y = np.asarray(y).ravel()
|
|
320
|
-
z = np.asarray(z).ravel()
|
|
321
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
|
+
)
|
|
322
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]:
|
|
323
343
|
# Validate input arrays
|
|
324
344
|
if len(x) != len(y) or len(x) != len(z):
|
|
325
345
|
msg = "Length of x, y, and z must be equal."
|
|
@@ -343,53 +363,18 @@ def raster_from_point_cloud(
|
|
|
343
363
|
msg = "Duplicate (x, y) points found. Each (x, y) point must be unique."
|
|
344
364
|
raise ValueError(msg)
|
|
345
365
|
|
|
346
|
-
|
|
347
|
-
if cell_size is None:
|
|
348
|
-
# Half the 5th percentile of nearest neighbor distances between the (x,y) points
|
|
349
|
-
tree = KDTree(xy_points)
|
|
350
|
-
distances, _ = tree.query(xy_points, k=2)
|
|
351
|
-
distances: np.ndarray
|
|
352
|
-
cell_size = float(np.percentile(distances[distances > 0], 5)) / 2
|
|
353
|
-
|
|
354
|
-
# Compute bounds from data
|
|
355
|
-
minx, miny, maxx, maxy = np.min(x), np.min(y), np.max(x), np.max(y)
|
|
356
|
-
|
|
357
|
-
# Compute grid shape
|
|
358
|
-
width = int(np.ceil((maxx - minx) / cell_size))
|
|
359
|
-
height = int(np.ceil((maxy - miny) / cell_size))
|
|
360
|
-
shape = (height, width)
|
|
366
|
+
return x, y, z
|
|
361
367
|
|
|
362
|
-
# Compute transform: upper left corner is (minx, maxy)
|
|
363
|
-
transform = Affine.translation(minx, maxy) * Affine.scale(cell_size, -cell_size)
|
|
364
368
|
|
|
365
|
-
|
|
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."""
|
|
366
373
|
rows, cols = np.indices(shape)
|
|
367
374
|
xs, ys = rasterio.transform.xy(
|
|
368
|
-
transform=transform, rows=rows, cols=cols, offset="center"
|
|
375
|
+
transform=raster_meta.transform, rows=rows, cols=cols, offset="center"
|
|
369
376
|
)
|
|
370
377
|
grid_x = np.array(xs).ravel()
|
|
371
378
|
grid_y = np.array(ys).ravel()
|
|
372
379
|
|
|
373
|
-
|
|
374
|
-
try:
|
|
375
|
-
interpolator = LinearNDInterpolator(
|
|
376
|
-
points=xy_points, values=z, fill_value=np.nan
|
|
377
|
-
)
|
|
378
|
-
except QhullError as err:
|
|
379
|
-
msg = (
|
|
380
|
-
"Failed to interpolate. This may be due to insufficient or "
|
|
381
|
-
"degenerate input points. Ensure that the (x, y) points are not all "
|
|
382
|
-
"collinear (i.e. that the convex hull is non-degenerate)."
|
|
383
|
-
)
|
|
384
|
-
raise ValueError(msg) from err
|
|
385
|
-
|
|
386
|
-
grid_values = np.array(interpolator(np.column_stack((grid_x, grid_y))))
|
|
387
|
-
|
|
388
|
-
arr = grid_values.reshape(shape).astype(np.float32)
|
|
389
|
-
|
|
390
|
-
raster_meta = RasterMeta(
|
|
391
|
-
cell_size=cell_size,
|
|
392
|
-
crs=crs,
|
|
393
|
-
transform=transform,
|
|
394
|
-
)
|
|
395
|
-
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/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
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
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
|
|
|
@@ -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,84 @@ 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
|
-
points
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
|
|
136
|
+
# Compute weights for all parameter values at once
|
|
137
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
138
|
+
left_w = (tangents[r - idx : 4 - idx][None, :] - ts[:, None]) / denom
|
|
139
|
+
|
|
140
|
+
left_w[:, denom == 0] = (
|
|
141
|
+
0.5 # Use 0.5 (midpoint) when points are identical (zero denominator)
|
|
142
|
+
)
|
|
143
|
+
right_w = 1 - left_w
|
|
144
|
+
|
|
145
|
+
# Weighted sums between consecutive points
|
|
146
|
+
points = (
|
|
147
|
+
left_w[..., None] * points[:, 0 : 4 - r, :]
|
|
148
|
+
+ right_w[..., None] * points[:, 1 : 5 - r, :]
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Result is first (and only) point at this level
|
|
152
|
+
return points[:, 0, :]
|
|
130
153
|
|
|
131
154
|
|
|
132
155
|
def _get_coords(
|
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
|
@@ -726,7 +726,7 @@ class Raster(BaseModel):
|
|
|
726
726
|
|
|
727
727
|
def __str__(self) -> str:
|
|
728
728
|
cls = self.__class__
|
|
729
|
-
mean =
|
|
729
|
+
mean = self.mean()
|
|
730
730
|
return f"{cls.__name__}(shape={self.arr.shape}, {mean=})"
|
|
731
731
|
|
|
732
732
|
def __repr__(self) -> str:
|
|
@@ -874,6 +874,68 @@ class Raster(BaseModel):
|
|
|
874
874
|
new_raster.arr = filled_arr
|
|
875
875
|
return new_raster
|
|
876
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
|
+
|
|
877
939
|
def copy(self) -> Self: # type: ignore[override]
|
|
878
940
|
"""Create a copy of the raster.
|
|
879
941
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rastr
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
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/f84ab299005d87e37339d53759e005e85c9e5ad2.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
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
rastr/__init__.py,sha256=z26KywZdRKwO-N5Qc34SuuGGwH8Y812csKORc3S4SYU,113
|
|
2
|
+
rastr/_version.py,sha256=az9tfX88VBVbBBILFx2zOlpMh1RBTXIwP6baQTMKtHc,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=bGNBFs7PYW_J_vzcX0h2VL2R0o4dX3Da_nEm7Pjivlw,5295
|
|
14
|
+
rastr-0.7.1.dist-info/METADATA,sha256=-MvdAkql2szGVK4jAI2Kw3evRhIYq3daQBmvPAfpJ0o,5724
|
|
15
|
+
rastr-0.7.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
16
|
+
rastr-0.7.1.dist-info/licenses/LICENSE,sha256=7qUsx93G2ATTRLZiSYuQofwAX_uWvrqnAiMK8PzxvNc,1080
|
|
17
|
+
rastr-0.7.1.dist-info/RECORD,,
|
rastr-0.6.0.dist-info/RECORD
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
rastr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
rastr/_version.py,sha256=MAYWefOLb6kbIRub18WSzK6ggSjz1LNLy9aDRlX9Ea4,704
|
|
3
|
-
rastr/create.py,sha256=8SpaQPx2bprqwbEqSntHBMb6-v7xSjOB6ZULb9w2hho,13801
|
|
4
|
-
rastr/io.py,sha256=RPhypnSNhLaWYdGRzctM9aTXbw9_TuMjvhMvDyUZavk,3640
|
|
5
|
-
rastr/meta.py,sha256=5iDvGkYe8iMMkPV6gSL04jNcLRhuRNFqe9AppUpp55E,2928
|
|
6
|
-
rastr/raster.py,sha256=Qf56i5NjBPNI8hNgjsfDDzgVhOBNTmL7e0ZGhFrwDs0,51835
|
|
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/fishnet.py,sha256=nAiJ_DuSQP326pLM9JmI8A4QwWWgVu7Mae1K1dWjDc4,3108
|
|
11
|
-
rastr/gis/smooth.py,sha256=6-mQlJVyU2gnSbrROWeNaSVhwxvA-Lf1vhM93fNKu4s,4825
|
|
12
|
-
rastr-0.6.0.dist-info/METADATA,sha256=_Pm8xg0FbV-xD73De_tbhJn17eu0bdy34uKkupABa3g,5724
|
|
13
|
-
rastr-0.6.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
14
|
-
rastr-0.6.0.dist-info/licenses/LICENSE,sha256=7qUsx93G2ATTRLZiSYuQofwAX_uWvrqnAiMK8PzxvNc,1080
|
|
15
|
-
rastr-0.6.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|