rastr 0.1.0__py3-none-any.whl → 0.3.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 +16 -3
- rastr/arr/fill.py +9 -2
- rastr/create.py +155 -35
- rastr/gis/fishnet.py +13 -4
- rastr/gis/smooth.py +7 -4
- rastr/io.py +68 -3
- rastr/meta.py +49 -10
- rastr/raster.py +256 -66
- rastr-0.3.0.dist-info/METADATA +138 -0
- rastr-0.3.0.dist-info/RECORD +15 -0
- rastr-0.1.0.dist-info/METADATA +0 -44
- rastr-0.1.0.dist-info/RECORD +0 -15
- {rastr-0.1.0.dist-info → rastr-0.3.0.dist-info}/WHEEL +0 -0
- {rastr-0.1.0.dist-info → rastr-0.3.0.dist-info}/licenses/LICENSE +0 -0
rastr/_version.py
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
# file generated by setuptools-scm
|
|
2
2
|
# don't change, don't track in version control
|
|
3
3
|
|
|
4
|
-
__all__ = [
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
5
12
|
|
|
6
13
|
TYPE_CHECKING = False
|
|
7
14
|
if TYPE_CHECKING:
|
|
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
|
|
|
9
16
|
from typing import Union
|
|
10
17
|
|
|
11
18
|
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
12
20
|
else:
|
|
13
21
|
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
14
23
|
|
|
15
24
|
version: str
|
|
16
25
|
__version__: str
|
|
17
26
|
__version_tuple__: VERSION_TUPLE
|
|
18
27
|
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
19
30
|
|
|
20
|
-
__version__ = version = '0.
|
|
21
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.3.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 3, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
rastr/arr/fill.py
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
1
5
|
import numpy as np
|
|
2
|
-
|
|
3
|
-
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from numpy.typing import NDArray
|
|
4
9
|
|
|
5
10
|
|
|
6
11
|
def fillna_nearest_neighbours(arr: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
@@ -11,6 +16,8 @@ def fillna_nearest_neighbours(arr: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
|
11
16
|
In the case of tiebreaks, the value from the neighbour with the lowest index is
|
|
12
17
|
imputed.
|
|
13
18
|
"""
|
|
19
|
+
from scipy.interpolate import NearestNDInterpolator
|
|
20
|
+
|
|
14
21
|
nonnan_mask = np.nonzero(~np.isnan(arr))
|
|
15
22
|
nonnan_idxs = np.array(nonnan_mask).transpose()
|
|
16
23
|
|
rastr/create.py
CHANGED
|
@@ -1,18 +1,32 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
3
6
|
|
|
4
|
-
import geopandas as gpd
|
|
5
7
|
import numpy as np
|
|
6
|
-
import pandas as pd
|
|
7
8
|
import rasterio.features
|
|
9
|
+
import rasterio.transform
|
|
8
10
|
from affine import Affine
|
|
9
|
-
from
|
|
10
|
-
from
|
|
11
|
+
from pyproj import CRS
|
|
12
|
+
from shapely.geometry import Point
|
|
11
13
|
|
|
12
14
|
from rastr.gis.fishnet import create_point_grid, get_point_grid_shape
|
|
13
15
|
from rastr.meta import RasterMeta
|
|
14
16
|
from rastr.raster import RasterModel
|
|
15
17
|
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
|
|
21
|
+
import geopandas as gpd
|
|
22
|
+
from numpy.typing import ArrayLike
|
|
23
|
+
from shapely.geometry import Polygon
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
TQDM_INSTALLED = importlib.util.find_spec("tqdm") is not None
|
|
27
|
+
|
|
28
|
+
_T = TypeVar("_T")
|
|
29
|
+
|
|
16
30
|
|
|
17
31
|
class MissingColumnsError(ValueError):
|
|
18
32
|
"""Raised when target columns are missing from the GeoDataFrame."""
|
|
@@ -61,17 +75,10 @@ def raster_distance_from_polygon(
|
|
|
61
75
|
Raises:
|
|
62
76
|
ValueError: If the provided CRS is geographic (lat/lon).
|
|
63
77
|
"""
|
|
64
|
-
if
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
err_msg = "Only one of 'extent_polygon' or 'snap_raster' can be provided. "
|
|
69
|
-
raise ValueError(err_msg)
|
|
70
|
-
|
|
71
|
-
if not show_pbar:
|
|
72
|
-
|
|
73
|
-
def _pbar(x: Iterable) -> None:
|
|
74
|
-
return x # No-op if no progress bar is needed
|
|
78
|
+
if show_pbar and not TQDM_INSTALLED:
|
|
79
|
+
msg = "The 'tqdm' package is not installed. Progress bars will not be shown."
|
|
80
|
+
warnings.warn(msg, UserWarning, stacklevel=2)
|
|
81
|
+
show_pbar = False
|
|
75
82
|
|
|
76
83
|
# Check if the provided CRS is projected (cartesian)
|
|
77
84
|
if raster_meta.crs.is_geographic:
|
|
@@ -80,36 +87,44 @@ def raster_distance_from_polygon(
|
|
|
80
87
|
)
|
|
81
88
|
raise ValueError(err_msg)
|
|
82
89
|
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
if extent_polygon is None and snap_raster is None:
|
|
91
|
+
err_msg = "Either 'extent_polygon' or 'snap_raster' must be provided. "
|
|
92
|
+
raise ValueError(err_msg)
|
|
93
|
+
elif extent_polygon is not None and snap_raster is not None:
|
|
94
|
+
err_msg = "Only one of 'extent_polygon' or 'snap_raster' can be provided. "
|
|
95
|
+
raise ValueError(err_msg)
|
|
96
|
+
elif extent_polygon is None and snap_raster is not None:
|
|
97
|
+
# Calculate the coordinates
|
|
85
98
|
x, y = snap_raster.get_xy()
|
|
86
|
-
|
|
99
|
+
|
|
100
|
+
# Create a mask to identify points for which distance should be calculated
|
|
101
|
+
distance_extent = snap_raster.bbox.difference(polygon)
|
|
102
|
+
elif extent_polygon is not None and snap_raster is None:
|
|
87
103
|
x, y = create_point_grid(
|
|
88
104
|
bounds=extent_polygon.bounds, cell_size=raster_meta.cell_size
|
|
89
105
|
)
|
|
90
|
-
|
|
91
|
-
points = [Point(x, y) for x, y in zip(x.flatten(), y.flatten(), strict=True)]
|
|
92
|
-
|
|
93
|
-
# Create a mask to identify points for which distance should be calculated
|
|
94
|
-
if extent_polygon is not None:
|
|
95
106
|
distance_extent = extent_polygon.difference(polygon)
|
|
96
107
|
else:
|
|
97
|
-
|
|
108
|
+
raise AssertionError
|
|
98
109
|
|
|
99
|
-
|
|
100
|
-
_pbar = partial(tqdm, desc="Finding points within extent")
|
|
101
|
-
mask = [distance_extent.intersects(point) for point in _pbar(points)]
|
|
110
|
+
pts = [Point(x, y) for x, y in zip(x.flatten(), y.flatten(), strict=True)]
|
|
102
111
|
|
|
103
|
-
if show_pbar
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
)
|
|
112
|
+
_pts = _pbar(pts, desc="Finding points within extent") if show_pbar else pts
|
|
113
|
+
mask = [distance_extent.intersects(pt) for pt in _pts]
|
|
114
|
+
|
|
115
|
+
_pts = _pbar(pts, desc="Calculating distances") if show_pbar else pts
|
|
116
|
+
distances = np.where(mask, np.array([polygon.distance(pt) for pt in _pts]), np.nan)
|
|
108
117
|
distance_raster = distances.reshape(x.shape)
|
|
109
118
|
|
|
110
119
|
return RasterModel(arr=distance_raster, raster_meta=raster_meta)
|
|
111
120
|
|
|
112
121
|
|
|
122
|
+
def _pbar(iterable: Iterable[_T], *, desc: str | None = None) -> Iterable[_T]:
|
|
123
|
+
from tqdm import tqdm
|
|
124
|
+
|
|
125
|
+
return tqdm(iterable, desc=desc)
|
|
126
|
+
|
|
127
|
+
|
|
113
128
|
def full_raster(
|
|
114
129
|
raster_meta: RasterMeta,
|
|
115
130
|
*,
|
|
@@ -185,7 +200,8 @@ def rasterize_gdf(
|
|
|
185
200
|
shapes,
|
|
186
201
|
out_shape=shape,
|
|
187
202
|
transform=transform,
|
|
188
|
-
|
|
203
|
+
# Fill gaps with NaN
|
|
204
|
+
fill=np.nan, # type: ignore[reportArgumentType] docstring contradicts inferred annotation
|
|
189
205
|
dtype=np.float32,
|
|
190
206
|
)
|
|
191
207
|
|
|
@@ -222,6 +238,8 @@ def _validate_columns_numeric(gdf: gpd.GeoDataFrame, target_cols: list[str]) ->
|
|
|
222
238
|
Raises:
|
|
223
239
|
NonNumericColumnsError: If any columns contain non-numeric data.
|
|
224
240
|
"""
|
|
241
|
+
import pandas as pd
|
|
242
|
+
|
|
225
243
|
non_numeric_cols = []
|
|
226
244
|
for col in target_cols:
|
|
227
245
|
if not pd.api.types.is_numeric_dtype(gdf[col]):
|
|
@@ -259,3 +277,105 @@ def _validate_no_overlapping_geometries(gdf: gpd.GeoDataFrame) -> None:
|
|
|
259
277
|
"Overlapping geometries can lead to data loss during rasterization."
|
|
260
278
|
)
|
|
261
279
|
raise OverlappingGeometriesError(msg)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def raster_from_point_cloud(
|
|
283
|
+
x: ArrayLike,
|
|
284
|
+
y: ArrayLike,
|
|
285
|
+
z: ArrayLike,
|
|
286
|
+
*,
|
|
287
|
+
crs: CRS | str,
|
|
288
|
+
cell_size: float | None = None,
|
|
289
|
+
) -> RasterModel:
|
|
290
|
+
"""Create a raster from a point cloud via interpolation.
|
|
291
|
+
|
|
292
|
+
Interpolation is only possible within the convex hull of the points. Outside of
|
|
293
|
+
this, cells will be NaN-valued.
|
|
294
|
+
|
|
295
|
+
All (x,y) points must be unique.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
x: X coordinates of points.
|
|
299
|
+
y: Y coordinates of points.
|
|
300
|
+
z: Values at each (x, y) point to assign the raster.
|
|
301
|
+
crs: Coordinate reference system for the (x, y) coordinates.
|
|
302
|
+
cell_size: Desired cell size for the raster. If None, a heuristic is used based
|
|
303
|
+
on the spacing between (x, y) points.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Raster containing the interpolated values.
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
ValueError: If any (x, y) points are duplicated, or if they are all collinear.
|
|
310
|
+
"""
|
|
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
|
+
crs = CRS.from_user_input(crs)
|
|
318
|
+
|
|
319
|
+
# Validate input arrays
|
|
320
|
+
if len(x) != len(y) or len(x) != len(z):
|
|
321
|
+
msg = "Length of x, y, and z must be equal."
|
|
322
|
+
raise ValueError(msg)
|
|
323
|
+
if len(x) < 3:
|
|
324
|
+
msg = "At least three (x, y, z) points are required to triangulate a surface."
|
|
325
|
+
raise ValueError(msg)
|
|
326
|
+
# Check for duplicate (x, y) points
|
|
327
|
+
xy_points = np.column_stack((x, y))
|
|
328
|
+
if len(xy_points) != len(np.unique(xy_points, axis=0)):
|
|
329
|
+
msg = "Duplicate (x, y) points found. Each (x, y) point must be unique."
|
|
330
|
+
raise ValueError(msg)
|
|
331
|
+
|
|
332
|
+
# Heuristic for cell size if not provided
|
|
333
|
+
if cell_size is None:
|
|
334
|
+
# Half the 5th percentile of nearest neighbor distances between the (x,y) points
|
|
335
|
+
tree = KDTree(np.column_stack((x, y)))
|
|
336
|
+
distances, _ = tree.query(np.column_stack((x, y)), k=2)
|
|
337
|
+
distances: np.ndarray
|
|
338
|
+
cell_size = float(np.percentile(distances[distances > 0], 5)) / 2
|
|
339
|
+
|
|
340
|
+
# Compute bounds from data
|
|
341
|
+
minx, miny, maxx, maxy = np.min(x), np.min(y), np.max(x), np.max(y)
|
|
342
|
+
|
|
343
|
+
# Compute grid shape
|
|
344
|
+
width = int(np.ceil((maxx - minx) / cell_size))
|
|
345
|
+
height = int(np.ceil((maxy - miny) / cell_size))
|
|
346
|
+
shape = (height, width)
|
|
347
|
+
|
|
348
|
+
# Compute transform: upper left corner is (minx, maxy)
|
|
349
|
+
transform = Affine.translation(minx, maxy) * Affine.scale(cell_size, -cell_size)
|
|
350
|
+
|
|
351
|
+
# Create grid coordinates for raster cells
|
|
352
|
+
rows, cols = np.indices(shape)
|
|
353
|
+
xs, ys = rasterio.transform.xy(
|
|
354
|
+
transform=transform, rows=rows, cols=cols, offset="center"
|
|
355
|
+
)
|
|
356
|
+
grid_x = np.array(xs).ravel()
|
|
357
|
+
grid_y = np.array(ys).ravel()
|
|
358
|
+
|
|
359
|
+
# Perform interpolation
|
|
360
|
+
try:
|
|
361
|
+
interpolator = LinearNDInterpolator(
|
|
362
|
+
points=xy_points, values=z, fill_value=np.nan
|
|
363
|
+
)
|
|
364
|
+
except QhullError as err:
|
|
365
|
+
msg = (
|
|
366
|
+
"Failed to interpolate. This may be due to insufficient or "
|
|
367
|
+
"degenerate input points. Ensure that the (x, y) points are not all "
|
|
368
|
+
"collinear (i.e. that the convex hull is non-degenerate)."
|
|
369
|
+
)
|
|
370
|
+
raise ValueError(msg) from err
|
|
371
|
+
|
|
372
|
+
grid_values = np.array(interpolator(np.column_stack((grid_x, grid_y))))
|
|
373
|
+
|
|
374
|
+
arr = grid_values.reshape(shape).astype(np.float32)
|
|
375
|
+
|
|
376
|
+
raster_meta = RasterMeta(
|
|
377
|
+
cell_size=cell_size,
|
|
378
|
+
crs=crs,
|
|
379
|
+
transform=transform,
|
|
380
|
+
)
|
|
381
|
+
return RasterModel(arr=arr, raster_meta=raster_meta)
|
rastr/gis/fishnet.py
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
2
5
|
import numpy as np
|
|
3
|
-
from geopandas.array import GeometryArray
|
|
4
6
|
from shapely import BufferCapStyle, BufferJoinStyle
|
|
5
7
|
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from geopandas.array import GeometryArray
|
|
10
|
+
from numpy.typing import NDArray
|
|
11
|
+
|
|
6
12
|
|
|
7
13
|
def create_point_grid(
|
|
8
14
|
*, bounds: tuple[float, float, float, float], cell_size: float
|
|
9
|
-
) -> tuple[
|
|
15
|
+
) -> tuple[NDArray, NDArray]:
|
|
10
16
|
"""Create a regular grid of point coordinates for raster centers.
|
|
11
17
|
|
|
12
18
|
This function replicates the original grid generation logic that uses
|
|
@@ -25,7 +31,8 @@ def create_point_grid(
|
|
|
25
31
|
x_coords = np.arange(xmin + cell_size / 2, xmax + cell_size / 2, cell_size)
|
|
26
32
|
y_coords = np.arange(ymax - cell_size / 2, ymin - cell_size / 2, -cell_size)
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
x_points, y_points = np.meshgrid(x_coords, y_coords) # type: ignore[reportAssignmentType]
|
|
35
|
+
return x_points, y_points
|
|
29
36
|
|
|
30
37
|
|
|
31
38
|
def get_point_grid_shape(
|
|
@@ -58,6 +65,8 @@ def create_fishnet(
|
|
|
58
65
|
Returns:
|
|
59
66
|
Shapely Polygons.
|
|
60
67
|
"""
|
|
68
|
+
import geopandas as gpd
|
|
69
|
+
|
|
61
70
|
# Use the shared helper function to create the point grid
|
|
62
71
|
xx, yy = create_point_grid(bounds=bounds, cell_size=res)
|
|
63
72
|
|
rastr/gis/smooth.py
CHANGED
|
@@ -5,12 +5,15 @@ Fork + Port of <https://github.com/philipschall/shapelysmooth> (Public domain)
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
from typing import TypeAlias
|
|
8
|
+
from typing import TYPE_CHECKING, TypeAlias
|
|
9
9
|
|
|
10
10
|
import numpy as np
|
|
11
11
|
from shapely.geometry import LineString, Polygon
|
|
12
12
|
from typing_extensions import assert_never
|
|
13
13
|
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from numpy.typing import NDArray
|
|
16
|
+
|
|
14
17
|
T: TypeAlias = LineString | Polygon
|
|
15
18
|
|
|
16
19
|
|
|
@@ -46,7 +49,7 @@ def catmull_rom_smooth(geometry: T, alpha: float = 0.5, subdivs: int = 10) -> T:
|
|
|
46
49
|
|
|
47
50
|
|
|
48
51
|
def _catmull_rom(
|
|
49
|
-
coords:
|
|
52
|
+
coords: NDArray,
|
|
50
53
|
*,
|
|
51
54
|
alpha: float = 0.5,
|
|
52
55
|
subdivs: int = 8,
|
|
@@ -89,7 +92,7 @@ def _catmull_rom(
|
|
|
89
92
|
|
|
90
93
|
|
|
91
94
|
def _recursive_eval(
|
|
92
|
-
slice4:
|
|
95
|
+
slice4: NDArray, tangents: list[float], ts: NDArray
|
|
93
96
|
) -> list[tuple[float, float]]:
|
|
94
97
|
"""De Boor/De Casteljau-style recursive linear interpolation over 4 control points.
|
|
95
98
|
|
|
@@ -128,7 +131,7 @@ def _recursive_eval(
|
|
|
128
131
|
|
|
129
132
|
def _get_coords(
|
|
130
133
|
geometry: LineString | Polygon,
|
|
131
|
-
) -> tuple[
|
|
134
|
+
) -> tuple[NDArray, list[NDArray]]:
|
|
132
135
|
if isinstance(geometry, LineString):
|
|
133
136
|
return np.array(geometry.coords), []
|
|
134
137
|
elif isinstance(geometry, Polygon):
|
rastr/io.py
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
2
5
|
|
|
3
6
|
import numpy as np
|
|
4
7
|
import rasterio
|
|
5
|
-
|
|
6
|
-
from pyproj.crs import CRS
|
|
8
|
+
import rasterio.merge
|
|
9
|
+
from pyproj.crs.crs import CRS
|
|
7
10
|
|
|
8
11
|
from rastr.meta import RasterMeta
|
|
9
12
|
from rastr.raster import RasterModel
|
|
10
13
|
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from numpy.typing import NDArray
|
|
16
|
+
|
|
11
17
|
|
|
12
|
-
def read_raster_inmem(
|
|
18
|
+
def read_raster_inmem(
|
|
19
|
+
raster_path: Path | str, *, crs: CRS | str | None = None
|
|
20
|
+
) -> RasterModel:
|
|
13
21
|
"""Read raster data from a file and return an in-memory Raster object."""
|
|
22
|
+
crs = CRS.from_user_input(crs) if crs is not None else None
|
|
23
|
+
|
|
14
24
|
with rasterio.open(raster_path, mode="r") as dst:
|
|
15
25
|
# Read the entire array
|
|
16
26
|
arr: NDArray[np.float64] = dst.read()
|
|
@@ -27,3 +37,58 @@ def read_raster_inmem(raster_path: Path, crs: CRS | None = None) -> RasterModel:
|
|
|
27
37
|
raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
|
|
28
38
|
raster_obj = RasterModel(arr=arr, raster_meta=raster_meta)
|
|
29
39
|
return raster_obj
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_raster_mosaic_inmem(
|
|
43
|
+
mosaic_dir: Path | str, *, glob: str = "*.tif", crs: CRS | None = None
|
|
44
|
+
) -> RasterModel:
|
|
45
|
+
"""Read a raster mosaic from a directory and return an in-memory Raster object.
|
|
46
|
+
|
|
47
|
+
This assumes that all rasters have the same metadata, e.g. coordinate system,
|
|
48
|
+
cell size, etc.
|
|
49
|
+
"""
|
|
50
|
+
mosaic_dir = Path(mosaic_dir)
|
|
51
|
+
raster_paths = list(mosaic_dir.glob(glob))
|
|
52
|
+
if not raster_paths:
|
|
53
|
+
msg = f"No raster files found in {mosaic_dir} matching {glob}"
|
|
54
|
+
raise FileNotFoundError(msg)
|
|
55
|
+
|
|
56
|
+
# Sort raster_paths in alphabetical order by stem
|
|
57
|
+
raster_paths.sort(key=lambda p: p.stem)
|
|
58
|
+
|
|
59
|
+
# Open all TIFF datasets using context managers to ensure proper closure
|
|
60
|
+
sources = []
|
|
61
|
+
try:
|
|
62
|
+
for raster_path in raster_paths:
|
|
63
|
+
src = rasterio.open(raster_path)
|
|
64
|
+
sources.append(src)
|
|
65
|
+
|
|
66
|
+
# Merge into a single mosaic array & transform
|
|
67
|
+
arr, transform = rasterio.merge.merge(sources)
|
|
68
|
+
|
|
69
|
+
# Copy metadata from the first dataset
|
|
70
|
+
out_meta = sources[0].meta.copy()
|
|
71
|
+
out_meta.update(
|
|
72
|
+
{
|
|
73
|
+
"driver": "GTiff",
|
|
74
|
+
"height": arr.shape[1],
|
|
75
|
+
"width": arr.shape[2],
|
|
76
|
+
"transform": transform,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
cell_size = sources[0].res[0]
|
|
80
|
+
if crs is None:
|
|
81
|
+
crs = CRS.from_user_input(sources[0].crs)
|
|
82
|
+
|
|
83
|
+
nodata = sources[0].nodata
|
|
84
|
+
if nodata is not None:
|
|
85
|
+
arr[arr == nodata] = np.nan
|
|
86
|
+
|
|
87
|
+
arr = arr.squeeze().astype(np.float64)
|
|
88
|
+
|
|
89
|
+
raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
|
|
90
|
+
raster_obj = RasterModel(arr=arr, raster_meta=raster_meta)
|
|
91
|
+
return raster_obj
|
|
92
|
+
finally:
|
|
93
|
+
for src in sources:
|
|
94
|
+
src.close()
|
rastr/meta.py
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
1
5
|
import numpy as np
|
|
2
6
|
from affine import Affine
|
|
3
7
|
from pydantic import BaseModel, InstanceOf
|
|
4
8
|
from pyproj import CRS
|
|
5
|
-
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from numpy.typing import NDArray
|
|
12
|
+
from typing_extensions import Self
|
|
6
13
|
|
|
7
14
|
|
|
8
15
|
class RasterMeta(BaseModel, extra="forbid"):
|
|
@@ -11,6 +18,8 @@ class RasterMeta(BaseModel, extra="forbid"):
|
|
|
11
18
|
Attributes:
|
|
12
19
|
cell_size: Cell size in meters.
|
|
13
20
|
crs: Coordinate reference system.
|
|
21
|
+
transform: The affine transformation associated with the raster. This is based
|
|
22
|
+
on the CRS, the cell size, as well as the offset/origin.
|
|
14
23
|
"""
|
|
15
24
|
|
|
16
25
|
cell_size: float
|
|
@@ -26,7 +35,7 @@ class RasterMeta(BaseModel, extra="forbid"):
|
|
|
26
35
|
transform=Affine.scale(2.0, 2.0),
|
|
27
36
|
)
|
|
28
37
|
|
|
29
|
-
def get_cell_centre_coords(self, shape: tuple[int, int]) ->
|
|
38
|
+
def get_cell_centre_coords(self, shape: tuple[int, int]) -> NDArray:
|
|
30
39
|
"""Return an array of (x, y) coordinates for the center of each cell.
|
|
31
40
|
|
|
32
41
|
The coordinates will be in the coordinate system defined by the
|
|
@@ -36,13 +45,43 @@ class RasterMeta(BaseModel, extra="forbid"):
|
|
|
36
45
|
shape: (rows, cols) of the raster array.
|
|
37
46
|
|
|
38
47
|
Returns:
|
|
39
|
-
|
|
40
|
-
cell center.
|
|
48
|
+
(x, y) coordinates for each cell center, with shape (rows, cols, 2)
|
|
41
49
|
"""
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
xv, yv = np.meshgrid(x_idx, y_idx)
|
|
46
|
-
x_coords, y_coords = self.transform * (xv + 0.5, yv + 0.5)
|
|
47
|
-
coords = np.stack([x_coords, y_coords], axis=-1)
|
|
50
|
+
x_coords = self.get_cell_x_coords(shape[1]) # cols for x-coordinates
|
|
51
|
+
y_coords = self.get_cell_y_coords(shape[0]) # rows for y-coordinates
|
|
52
|
+
coords = np.stack(np.meshgrid(x_coords, y_coords), axis=-1)
|
|
48
53
|
return coords
|
|
54
|
+
|
|
55
|
+
def get_cell_x_coords(self, n_columns: int) -> NDArray:
|
|
56
|
+
"""Return an array of x coordinates for the center of each cell.
|
|
57
|
+
|
|
58
|
+
The coordinates will be in the coordinate system defined by the
|
|
59
|
+
raster's transform.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
n_columns: Number of columns in the raster array.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
x_coordinates at cell centers, with shape (n_columns,)
|
|
66
|
+
"""
|
|
67
|
+
x_idx = np.arange(n_columns) + 0.5
|
|
68
|
+
y_idx = np.zeros_like(x_idx) # Use y=0 for a single row
|
|
69
|
+
x_coords, _ = self.transform * (x_idx, y_idx) # type: ignore[reportAssignmentType] overloaded tuple size in affine
|
|
70
|
+
return x_coords
|
|
71
|
+
|
|
72
|
+
def get_cell_y_coords(self, n_rows: int) -> NDArray:
|
|
73
|
+
"""Return an array of y coordinates for the center of each cell.
|
|
74
|
+
|
|
75
|
+
The coordinates will be in the coordinate system defined by the
|
|
76
|
+
raster's transform.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
n_rows: Number of rows in the raster array.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
y_coordinates at cell centers, with shape (n_rows,)
|
|
83
|
+
"""
|
|
84
|
+
x_idx = np.zeros(n_rows) # Use x=0 for a single column
|
|
85
|
+
y_idx = np.arange(n_rows) + 0.5
|
|
86
|
+
_, y_coords = self.transform * (x_idx, y_idx) # type: ignore[reportAssignmentType] overloaded tuple size in affine
|
|
87
|
+
return y_coords
|