rastr 0.1.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 +0 -0
- rastr/_version.py +21 -0
- rastr/arr/__init__.py +0 -0
- rastr/arr/fill.py +24 -0
- rastr/create.py +261 -0
- rastr/gis/__init__.py +0 -0
- rastr/gis/fishnet.py +72 -0
- rastr/gis/smooth.py +139 -0
- rastr/io.py +29 -0
- rastr/meta.py +48 -0
- rastr/raster.py +654 -0
- rastr-0.1.0.dist-info/METADATA +44 -0
- rastr-0.1.0.dist-info/RECORD +15 -0
- rastr-0.1.0.dist-info/WHEEL +4 -0
- rastr-0.1.0.dist-info/licenses/LICENSE +21 -0
rastr/__init__.py
ADDED
|
File without changes
|
rastr/_version.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
|
|
5
|
+
|
|
6
|
+
TYPE_CHECKING = False
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from typing import Tuple
|
|
9
|
+
from typing import Union
|
|
10
|
+
|
|
11
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
12
|
+
else:
|
|
13
|
+
VERSION_TUPLE = object
|
|
14
|
+
|
|
15
|
+
version: str
|
|
16
|
+
__version__: str
|
|
17
|
+
__version_tuple__: VERSION_TUPLE
|
|
18
|
+
version_tuple: VERSION_TUPLE
|
|
19
|
+
|
|
20
|
+
__version__ = version = '0.1.0'
|
|
21
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
rastr/arr/__init__.py
ADDED
|
File without changes
|
rastr/arr/fill.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from numpy.typing import NDArray
|
|
3
|
+
from scipy.interpolate import NearestNDInterpolator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fillna_nearest_neighbours(arr: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
7
|
+
"""Fill NaN values in an N-dimensional array with their nearest neighbours' values.
|
|
8
|
+
|
|
9
|
+
The nearest neighbour is determined using the Euclidean distance between array
|
|
10
|
+
indices, so there is equal weighting given in all directions (i.e. across all axes).
|
|
11
|
+
In the case of tiebreaks, the value from the neighbour with the lowest index is
|
|
12
|
+
imputed.
|
|
13
|
+
"""
|
|
14
|
+
nonnan_mask = np.nonzero(~np.isnan(arr))
|
|
15
|
+
nonnan_idxs = np.array(nonnan_mask).transpose()
|
|
16
|
+
|
|
17
|
+
if nonnan_idxs.size == 0:
|
|
18
|
+
# Everything is NaN
|
|
19
|
+
return arr
|
|
20
|
+
|
|
21
|
+
# Interpolate at the array indices
|
|
22
|
+
interp = NearestNDInterpolator(nonnan_idxs, arr[nonnan_mask])
|
|
23
|
+
filled_arr = interp(*np.indices(arr.shape))
|
|
24
|
+
return filled_arr
|
rastr/create.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
from functools import partial
|
|
3
|
+
|
|
4
|
+
import geopandas as gpd
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import rasterio.features
|
|
8
|
+
from affine import Affine
|
|
9
|
+
from shapely.geometry import Point, Polygon
|
|
10
|
+
from tqdm.notebook import tqdm
|
|
11
|
+
|
|
12
|
+
from rastr.gis.fishnet import create_point_grid, get_point_grid_shape
|
|
13
|
+
from rastr.meta import RasterMeta
|
|
14
|
+
from rastr.raster import RasterModel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MissingColumnsError(ValueError):
|
|
18
|
+
"""Raised when target columns are missing from the GeoDataFrame."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NonNumericColumnsError(ValueError):
|
|
22
|
+
"""Raised when target columns contain non-numeric data."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RasterizationError(ValueError):
|
|
26
|
+
"""Base exception for rasterization errors."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OverlappingGeometriesError(RasterizationError):
|
|
30
|
+
"""Raised when geometries overlap, which could lead to data loss."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def raster_distance_from_polygon(
|
|
34
|
+
polygon: Polygon,
|
|
35
|
+
*,
|
|
36
|
+
raster_meta: RasterMeta,
|
|
37
|
+
extent_polygon: Polygon | None = None,
|
|
38
|
+
snap_raster: RasterModel | None = None,
|
|
39
|
+
show_pbar: bool = False,
|
|
40
|
+
) -> RasterModel:
|
|
41
|
+
"""Make a raster where each cell's value is its centre's distance to a polygon.
|
|
42
|
+
|
|
43
|
+
The raster should use a projected coordinate system.
|
|
44
|
+
|
|
45
|
+
Parameters:
|
|
46
|
+
polygon: Polygon to measure distances to.
|
|
47
|
+
raster_meta: Raster configuration (giving cell_size, CRS, etc.).
|
|
48
|
+
extent_polygon: Polygon for raster cell extent; The bounding box of this
|
|
49
|
+
polygon is the bounding box of the output raster. Cells outside
|
|
50
|
+
this polygon but within the bounding box will be NaN-valued, and
|
|
51
|
+
cells will not be generated centred outside the bounding box of
|
|
52
|
+
this polygon.
|
|
53
|
+
snap_raster: An alternative to using the extent_polygon. If provided, the raster
|
|
54
|
+
must have the exact same cell alignment as the snap_raster.
|
|
55
|
+
show_pbar: Whether to show a progress bar during the distance calculation.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Array storing the distance between cell centres and the polygon. Cell are
|
|
59
|
+
NaN-valued if they are within the polygon or outside the extent polygon.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValueError: If the provided CRS is geographic (lat/lon).
|
|
63
|
+
"""
|
|
64
|
+
if extent_polygon is None and snap_raster is None:
|
|
65
|
+
err_msg = "Either 'extent_polygon' or 'snap_raster' must be provided. "
|
|
66
|
+
raise ValueError(err_msg)
|
|
67
|
+
elif extent_polygon is not None and snap_raster is not None:
|
|
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
|
|
75
|
+
|
|
76
|
+
# Check if the provided CRS is projected (cartesian)
|
|
77
|
+
if raster_meta.crs.is_geographic:
|
|
78
|
+
err_msg = (
|
|
79
|
+
"The provided CRS is geographic (lat/lon). Please use a projected CRS."
|
|
80
|
+
)
|
|
81
|
+
raise ValueError(err_msg)
|
|
82
|
+
|
|
83
|
+
# Calculate the coordinates
|
|
84
|
+
if snap_raster is not None:
|
|
85
|
+
x, y = snap_raster.get_xy()
|
|
86
|
+
else:
|
|
87
|
+
x, y = create_point_grid(
|
|
88
|
+
bounds=extent_polygon.bounds, cell_size=raster_meta.cell_size
|
|
89
|
+
)
|
|
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
|
+
distance_extent = extent_polygon.difference(polygon)
|
|
96
|
+
else:
|
|
97
|
+
distance_extent = snap_raster.bbox.difference(polygon)
|
|
98
|
+
|
|
99
|
+
if show_pbar:
|
|
100
|
+
_pbar = partial(tqdm, desc="Finding points within extent")
|
|
101
|
+
mask = [distance_extent.intersects(point) for point in _pbar(points)]
|
|
102
|
+
|
|
103
|
+
if show_pbar:
|
|
104
|
+
_pbar = partial(tqdm, desc="Calculating distances")
|
|
105
|
+
distances = np.where(
|
|
106
|
+
mask, np.array([polygon.distance(point) for point in _pbar(points)]), np.nan
|
|
107
|
+
)
|
|
108
|
+
distance_raster = distances.reshape(x.shape)
|
|
109
|
+
|
|
110
|
+
return RasterModel(arr=distance_raster, raster_meta=raster_meta)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def full_raster(
|
|
114
|
+
raster_meta: RasterMeta,
|
|
115
|
+
*,
|
|
116
|
+
bounds: tuple[float, float, float, float],
|
|
117
|
+
fill_value: float = np.nan,
|
|
118
|
+
) -> RasterModel:
|
|
119
|
+
"""Create a raster with a specified fill value for all cells."""
|
|
120
|
+
shape = get_point_grid_shape(bounds=bounds, cell_size=raster_meta.cell_size)
|
|
121
|
+
arr = np.full(shape, fill_value, dtype=np.float32)
|
|
122
|
+
return RasterModel(arr=arr, raster_meta=raster_meta)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def rasterize_gdf(
|
|
126
|
+
gdf: gpd.GeoDataFrame,
|
|
127
|
+
*,
|
|
128
|
+
raster_meta: RasterMeta,
|
|
129
|
+
target_cols: list[str],
|
|
130
|
+
) -> list[RasterModel]:
|
|
131
|
+
"""Rasterize geometries from a GeoDataFrame.
|
|
132
|
+
|
|
133
|
+
Supports polygons, points, linestrings, and other geometry types.
|
|
134
|
+
Gaps will be set as NaN.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
gdf: The geometries to rasterize (polygons, points, linestrings, etc.).
|
|
138
|
+
raster_meta: Metadata for the created rasters.
|
|
139
|
+
target_cols: A list of columns from the GeoDataFrame containing numeric
|
|
140
|
+
datatypes. Each column will correspond to a separate raster
|
|
141
|
+
in the output.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Rasters for each column in `target_cols`.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
MissingColumnsError: If any of the target columns are not found in the
|
|
148
|
+
GeoDataFrame.
|
|
149
|
+
NonNumericColumnsError: If any of the target columns contain non-numeric data.
|
|
150
|
+
OverlappingGeometriesError: If any geometries overlap, which could lead to
|
|
151
|
+
data loss in the rasterization process.
|
|
152
|
+
"""
|
|
153
|
+
# Validate inputs using helper functions
|
|
154
|
+
_validate_columns_exist(gdf, target_cols)
|
|
155
|
+
_validate_columns_numeric(gdf, target_cols)
|
|
156
|
+
_validate_no_overlapping_geometries(gdf)
|
|
157
|
+
|
|
158
|
+
# Get the bounds from the GeoDataFrame and expand them to include potential gaps
|
|
159
|
+
bounds = gdf.total_bounds
|
|
160
|
+
min_x, min_y, max_x, max_y = bounds
|
|
161
|
+
cell_size = raster_meta.cell_size
|
|
162
|
+
|
|
163
|
+
# Expand bounds by at least one cell size to ensure there are potential gaps
|
|
164
|
+
buffer = cell_size
|
|
165
|
+
expanded_bounds = (min_x - buffer, min_y - buffer, max_x + buffer, max_y + buffer)
|
|
166
|
+
|
|
167
|
+
# Create point grid to get raster dimensions and transform
|
|
168
|
+
shape = get_point_grid_shape(bounds=expanded_bounds, cell_size=cell_size)
|
|
169
|
+
|
|
170
|
+
# Create the affine transform for rasterization
|
|
171
|
+
transform = Affine.translation(
|
|
172
|
+
expanded_bounds[0], expanded_bounds[3]
|
|
173
|
+
) * Affine.scale(cell_size, -cell_size)
|
|
174
|
+
|
|
175
|
+
# Create rasters for each target column using rasterio.features.rasterize
|
|
176
|
+
rasters = []
|
|
177
|
+
for col in target_cols:
|
|
178
|
+
# Create (geometry, value) pairs for rasterization
|
|
179
|
+
shapes = [
|
|
180
|
+
(geom, value) for geom, value in zip(gdf.geometry, gdf[col], strict=True)
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
# Rasterize the geometries with their values
|
|
184
|
+
raster_array = rasterio.features.rasterize(
|
|
185
|
+
shapes,
|
|
186
|
+
out_shape=shape,
|
|
187
|
+
transform=transform,
|
|
188
|
+
fill=np.nan, # Fill gaps with NaN
|
|
189
|
+
dtype=np.float32,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Create RasterModel
|
|
193
|
+
raster = RasterModel(arr=raster_array, raster_meta=raster_meta)
|
|
194
|
+
rasters.append(raster)
|
|
195
|
+
|
|
196
|
+
return rasters
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _validate_columns_exist(gdf: gpd.GeoDataFrame, target_cols: list[str]) -> None:
|
|
200
|
+
"""Validate that all target columns exist in the GeoDataFrame.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
gdf: The GeoDataFrame to check.
|
|
204
|
+
target_cols: List of column names to validate.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
MissingColumnsError: If any columns are missing.
|
|
208
|
+
"""
|
|
209
|
+
missing_cols = [col for col in target_cols if col not in gdf.columns]
|
|
210
|
+
if missing_cols:
|
|
211
|
+
msg = f"Target columns not found in GeoDataFrame: {missing_cols}"
|
|
212
|
+
raise MissingColumnsError(msg)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _validate_columns_numeric(gdf: gpd.GeoDataFrame, target_cols: list[str]) -> None:
|
|
216
|
+
"""Validate that all target columns contain numeric data.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
gdf: The GeoDataFrame to check.
|
|
220
|
+
target_cols: List of column names to validate.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
NonNumericColumnsError: If any columns contain non-numeric data.
|
|
224
|
+
"""
|
|
225
|
+
non_numeric_cols = []
|
|
226
|
+
for col in target_cols:
|
|
227
|
+
if not pd.api.types.is_numeric_dtype(gdf[col]):
|
|
228
|
+
non_numeric_cols.append(col)
|
|
229
|
+
if non_numeric_cols:
|
|
230
|
+
msg = f"Target columns must contain numeric data: {non_numeric_cols}"
|
|
231
|
+
raise NonNumericColumnsError(msg)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _validate_no_overlapping_geometries(gdf: gpd.GeoDataFrame) -> None:
|
|
235
|
+
"""Validate that geometries do not overlap.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
gdf: The GeoDataFrame to check for overlapping geometries.
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
OverlappingGeometriesError: If any geometries overlap.
|
|
242
|
+
"""
|
|
243
|
+
# Check for overlaps by testing each geometry against all others
|
|
244
|
+
geometries = gdf.geometry.to_numpy()
|
|
245
|
+
|
|
246
|
+
for i in range(len(geometries)):
|
|
247
|
+
for j in range(i + 1, len(geometries)):
|
|
248
|
+
geom_i = geometries[i]
|
|
249
|
+
geom_j = geometries[j]
|
|
250
|
+
|
|
251
|
+
# Skip invalid geometries
|
|
252
|
+
if not geom_i.is_valid or not geom_j.is_valid:
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
# Check if geometries overlap (not just touch)
|
|
256
|
+
if geom_i.overlaps(geom_j):
|
|
257
|
+
msg = (
|
|
258
|
+
f"Overlapping geometries detected at indices {i} and {j}. "
|
|
259
|
+
"Overlapping geometries can lead to data loss during rasterization."
|
|
260
|
+
)
|
|
261
|
+
raise OverlappingGeometriesError(msg)
|
rastr/gis/__init__.py
ADDED
|
File without changes
|
rastr/gis/fishnet.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import geopandas as gpd
|
|
2
|
+
import numpy as np
|
|
3
|
+
from geopandas.array import GeometryArray
|
|
4
|
+
from shapely import BufferCapStyle, BufferJoinStyle
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create_point_grid(
|
|
8
|
+
*, bounds: tuple[float, float, float, float], cell_size: float
|
|
9
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
10
|
+
"""Create a regular grid of point coordinates for raster centers.
|
|
11
|
+
|
|
12
|
+
This function replicates the original grid generation logic that uses
|
|
13
|
+
np.arange to ensure compatibility with existing code.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
bounds: (xmin, ymin, xmax, ymax) bounding box.
|
|
17
|
+
cell_size: Size of each grid cell.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Tuple of (x_coords, y_coords) meshgrids for raster cell centers.
|
|
21
|
+
"""
|
|
22
|
+
xmin, ymin, xmax, ymax = bounds
|
|
23
|
+
|
|
24
|
+
# Use the original logic with np.arange for exact compatibility
|
|
25
|
+
x_coords = np.arange(xmin + cell_size / 2, xmax + cell_size / 2, cell_size)
|
|
26
|
+
y_coords = np.arange(ymax - cell_size / 2, ymin - cell_size / 2, -cell_size)
|
|
27
|
+
|
|
28
|
+
return np.meshgrid(x_coords, y_coords)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_point_grid_shape(
|
|
32
|
+
*, bounds: tuple[float, float, float, float], cell_size: float
|
|
33
|
+
) -> tuple[int, int]:
|
|
34
|
+
"""Calculate the shape of the point grid based on bounds and cell size."""
|
|
35
|
+
|
|
36
|
+
xmin, ymin, xmax, ymax = bounds
|
|
37
|
+
ncols = int(np.ceil((xmax - xmin) / cell_size))
|
|
38
|
+
nrows = int(np.ceil((ymax - ymin) / cell_size))
|
|
39
|
+
|
|
40
|
+
return nrows, ncols
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_fishnet(
|
|
44
|
+
*, bounds: tuple[float, float, float, float], res: float
|
|
45
|
+
) -> GeometryArray:
|
|
46
|
+
"""Generate a fishnet of polygons from bounds.
|
|
47
|
+
|
|
48
|
+
The function generates a grid of polygons within the specified bounds, where each
|
|
49
|
+
cell has dimensions defined by `res`. If the resolution does not perfectly divide
|
|
50
|
+
the bounds' dimensions (i.e., if `res` is not a factor of (xmax - xmin) or
|
|
51
|
+
(ymax - ymin)), the grid is still generated such that it fully covers the bounds.
|
|
52
|
+
This can result in cells that extend beyond the specified bounds.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
bounds: (xmin, ymin, xmax, ymax)
|
|
56
|
+
res: resolution (cell size)
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Shapely Polygons.
|
|
60
|
+
"""
|
|
61
|
+
# Use the shared helper function to create the point grid
|
|
62
|
+
xx, yy = create_point_grid(bounds=bounds, cell_size=res)
|
|
63
|
+
|
|
64
|
+
# Create points from the grid coordinates
|
|
65
|
+
points = gpd.points_from_xy(xx.ravel(), yy.ravel())
|
|
66
|
+
|
|
67
|
+
# Buffer the points to create square polygons
|
|
68
|
+
polygons = points.buffer(
|
|
69
|
+
res / 2, cap_style=BufferCapStyle.square, join_style=BufferJoinStyle.mitre
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return polygons
|
rastr/gis/smooth.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Utilities for smoothing geometries.
|
|
2
|
+
|
|
3
|
+
Fork + Port of <https://github.com/philipschall/shapelysmooth> (Public domain)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TypeAlias
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from shapely.geometry import LineString, Polygon
|
|
12
|
+
from typing_extensions import assert_never
|
|
13
|
+
|
|
14
|
+
T: TypeAlias = LineString | Polygon
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InputeTypeError(TypeError):
|
|
18
|
+
"""Raised when the input geometry is of the incorrect type."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def catmull_rom_smooth(geometry: T, alpha: float = 0.5, subdivs: int = 10) -> T:
|
|
22
|
+
"""Polyline smoothing using Catmull-Rom splines.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
geometry: The geometry to smooth
|
|
26
|
+
alpha: The tension parameter, between 0 and 1 inclusive. Defaults to 0.5.
|
|
27
|
+
- For uniform Catmull-Rom splines, alpha = 0.
|
|
28
|
+
- For centripetal Catmull-Rom splines, alpha = 0.5.
|
|
29
|
+
- For chordal Catmull-Rom splines, alpha = 1.0.
|
|
30
|
+
subdivs:
|
|
31
|
+
Number of subdivisions of each polyline segment. Default value: 10.
|
|
32
|
+
|
|
33
|
+
Returns: The smoothed geometry.
|
|
34
|
+
"""
|
|
35
|
+
coords, interior_coords = _get_coords(geometry)
|
|
36
|
+
coords_smoothed = _catmull_rom(coords, alpha=alpha, subdivs=subdivs)
|
|
37
|
+
if isinstance(geometry, LineString):
|
|
38
|
+
return type(geometry)(coords_smoothed)
|
|
39
|
+
elif isinstance(geometry, Polygon):
|
|
40
|
+
interior_coords_smoothed = [
|
|
41
|
+
_catmull_rom(c, alpha=alpha, subdivs=subdivs) for c in interior_coords
|
|
42
|
+
]
|
|
43
|
+
return type(geometry)(coords_smoothed, holes=interior_coords_smoothed)
|
|
44
|
+
else:
|
|
45
|
+
assert_never(geometry)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _catmull_rom(
|
|
49
|
+
coords: np.ndarray,
|
|
50
|
+
*,
|
|
51
|
+
alpha: float = 0.5,
|
|
52
|
+
subdivs: int = 8,
|
|
53
|
+
) -> list[tuple[float, float]]:
|
|
54
|
+
arr = np.asarray(coords, dtype=float)
|
|
55
|
+
if arr.shape[0] < 2:
|
|
56
|
+
return arr.tolist()
|
|
57
|
+
|
|
58
|
+
is_closed = np.allclose(arr[0], arr[-1])
|
|
59
|
+
if is_closed:
|
|
60
|
+
arr = np.vstack([arr[-2], arr, arr[2]])
|
|
61
|
+
else:
|
|
62
|
+
arr = np.vstack(
|
|
63
|
+
[
|
|
64
|
+
2.0 * arr[0] + 1.0 * arr[1],
|
|
65
|
+
arr,
|
|
66
|
+
2.0 * arr[-1] + 0.0 * arr[-2],
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
new_ls = [tuple(arr[1])]
|
|
71
|
+
for k in range(len(arr) - 3):
|
|
72
|
+
slice4 = arr[k : k + 4]
|
|
73
|
+
tangents = [0.0]
|
|
74
|
+
for j in range(3):
|
|
75
|
+
dist = float(np.linalg.norm(slice4[j + 1] - slice4[j]))
|
|
76
|
+
tangents.append(float(tangents[-1] + dist**alpha))
|
|
77
|
+
|
|
78
|
+
# Resample: subdivs-1 samples strictly between t1 and t2
|
|
79
|
+
seg_len = (tangents[2] - tangents[1]) / float(subdivs)
|
|
80
|
+
if subdivs > 1:
|
|
81
|
+
ts = np.linspace(tangents[1] + seg_len, tangents[2] - seg_len, subdivs - 1)
|
|
82
|
+
else:
|
|
83
|
+
ts = np.array([])
|
|
84
|
+
|
|
85
|
+
interpolants = _recursive_eval(slice4, tangents, ts)
|
|
86
|
+
new_ls.extend(interpolants)
|
|
87
|
+
new_ls.append(tuple(slice4[2]))
|
|
88
|
+
return new_ls
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _recursive_eval(
|
|
92
|
+
slice4: np.ndarray, tangents: list[float], ts: np.ndarray
|
|
93
|
+
) -> list[tuple[float, float]]:
|
|
94
|
+
"""De Boor/De Casteljau-style recursive linear interpolation over 4 control points.
|
|
95
|
+
|
|
96
|
+
Parameterized by the non-uniform 'tangents' values.
|
|
97
|
+
"""
|
|
98
|
+
# N.B. comments are LLM-generated
|
|
99
|
+
|
|
100
|
+
out = []
|
|
101
|
+
for tp in ts:
|
|
102
|
+
# Start with the 4 control points for this segment
|
|
103
|
+
points = slice4.copy()
|
|
104
|
+
# Perform 3 levels of linear interpolation (De Casteljau's algorithm)
|
|
105
|
+
for r in range(1, 4):
|
|
106
|
+
idx = max(r - 2, 0)
|
|
107
|
+
new_points = []
|
|
108
|
+
# Interpolate between points at this level
|
|
109
|
+
for i in range(4 - r):
|
|
110
|
+
# Compute denominator for parameterization
|
|
111
|
+
denom = tangents[i + r - idx] - tangents[i + idx]
|
|
112
|
+
if denom == 0:
|
|
113
|
+
# If degenerate (coincident tangents), use midpoint
|
|
114
|
+
left_w = right_w = 0.5
|
|
115
|
+
else:
|
|
116
|
+
# Otherwise, compute weights for linear interpolation
|
|
117
|
+
left_w = (tangents[i + r - idx] - tp) / denom
|
|
118
|
+
right_w = (tp - tangents[i + idx]) / denom
|
|
119
|
+
# Weighted average of the two points
|
|
120
|
+
pt = left_w * points[i] + right_w * points[i + 1]
|
|
121
|
+
new_points.append(pt)
|
|
122
|
+
# Move to the next level with the new set of points
|
|
123
|
+
points = np.array(new_points)
|
|
124
|
+
# The final point is the interpolated value for this parameter tp
|
|
125
|
+
out.append(tuple(points[0]))
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _get_coords(
|
|
130
|
+
geometry: LineString | Polygon,
|
|
131
|
+
) -> tuple[np.ndarray, list[np.ndarray]]:
|
|
132
|
+
if isinstance(geometry, LineString):
|
|
133
|
+
return np.array(geometry.coords), []
|
|
134
|
+
elif isinstance(geometry, Polygon):
|
|
135
|
+
return np.array(geometry.exterior.coords), [
|
|
136
|
+
np.array(hole.coords) for hole in geometry.interiors
|
|
137
|
+
]
|
|
138
|
+
else:
|
|
139
|
+
assert_never(geometry)
|
rastr/io.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import rasterio
|
|
5
|
+
from numpy.typing import NDArray
|
|
6
|
+
from pyproj.crs import CRS
|
|
7
|
+
|
|
8
|
+
from rastr.meta import RasterMeta
|
|
9
|
+
from rastr.raster import RasterModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_raster_inmem(raster_path: Path, crs: CRS | None = None) -> RasterModel:
|
|
13
|
+
"""Read raster data from a file and return an in-memory Raster object."""
|
|
14
|
+
with rasterio.open(raster_path, mode="r") as dst:
|
|
15
|
+
# Read the entire array
|
|
16
|
+
arr: NDArray[np.float64] = dst.read()
|
|
17
|
+
arr = arr.squeeze().astype(np.float64)
|
|
18
|
+
# Extract metadata
|
|
19
|
+
cell_size = dst.res[0]
|
|
20
|
+
if crs is None:
|
|
21
|
+
crs = CRS.from_user_input(dst.crs)
|
|
22
|
+
transform = dst.transform
|
|
23
|
+
nodata = dst.nodata
|
|
24
|
+
if nodata is not None:
|
|
25
|
+
arr[arr == nodata] = np.nan
|
|
26
|
+
|
|
27
|
+
raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
|
|
28
|
+
raster_obj = RasterModel(arr=arr, raster_meta=raster_meta)
|
|
29
|
+
return raster_obj
|
rastr/meta.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from affine import Affine
|
|
3
|
+
from pydantic import BaseModel, InstanceOf
|
|
4
|
+
from pyproj import CRS
|
|
5
|
+
from typing_extensions import Self
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RasterMeta(BaseModel, extra="forbid"):
|
|
9
|
+
"""Raster metadata.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
cell_size: Cell size in meters.
|
|
13
|
+
crs: Coordinate reference system.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
cell_size: float
|
|
17
|
+
crs: InstanceOf[CRS]
|
|
18
|
+
transform: InstanceOf[Affine]
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def example(cls) -> Self:
|
|
22
|
+
"""Create an example RasterMeta object."""
|
|
23
|
+
return cls(
|
|
24
|
+
cell_size=2.0,
|
|
25
|
+
crs=CRS.from_epsg(2193),
|
|
26
|
+
transform=Affine.scale(2.0, 2.0),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def get_cell_centre_coords(self, shape: tuple[int, int]) -> np.ndarray:
|
|
30
|
+
"""Return an array of (x, y) coordinates for the center of each cell.
|
|
31
|
+
|
|
32
|
+
The coordinates will be in the coordinate system defined by the
|
|
33
|
+
raster's transform.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
shape: (rows, cols) of the raster array.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
np.ndarray of shape (rows, cols, 2) with (x, y) coordinates for each
|
|
40
|
+
cell center.
|
|
41
|
+
"""
|
|
42
|
+
rows, cols = shape
|
|
43
|
+
x_idx = np.arange(cols)
|
|
44
|
+
y_idx = np.arange(rows)
|
|
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)
|
|
48
|
+
return coords
|
rastr/raster.py
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"""Raster data structure."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Generator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Literal
|
|
7
|
+
|
|
8
|
+
import geopandas as gpd
|
|
9
|
+
import matplotlib as mpl
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import rasterio.plot
|
|
13
|
+
import rasterio.sample
|
|
14
|
+
import rasterio.transform
|
|
15
|
+
import skimage.measure
|
|
16
|
+
import xyzservices.providers as xyz
|
|
17
|
+
from matplotlib import pyplot as plt
|
|
18
|
+
from matplotlib.axes import Axes
|
|
19
|
+
from mpl_toolkits.axes_grid1 import make_axes_locatable
|
|
20
|
+
from numpy.typing import NDArray
|
|
21
|
+
from pydantic import BaseModel, InstanceOf, field_validator
|
|
22
|
+
from pyproj.crs.crs import CRS
|
|
23
|
+
from rasterio.enums import Resampling
|
|
24
|
+
from rasterio.io import BufferedDatasetWriter, DatasetReader, DatasetWriter, MemoryFile
|
|
25
|
+
from scipy.ndimage import gaussian_filter
|
|
26
|
+
from shapely.geometry import LineString, Polygon
|
|
27
|
+
from typing_extensions import Self
|
|
28
|
+
|
|
29
|
+
from rastr.arr.fill import fillna_nearest_neighbours
|
|
30
|
+
from rastr.gis.fishnet import create_fishnet
|
|
31
|
+
from rastr.gis.smooth import catmull_rom_smooth
|
|
32
|
+
from rastr.meta import RasterMeta
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from folium import Map
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
import folium
|
|
39
|
+
import folium.raster_layers
|
|
40
|
+
from folium import Map
|
|
41
|
+
except ImportError:
|
|
42
|
+
FOLIUM_INSTALLED = False
|
|
43
|
+
else:
|
|
44
|
+
FOLIUM_INSTALLED = True
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
from rasterio._err import CPLE_BaseError
|
|
48
|
+
except ImportError:
|
|
49
|
+
CPLE_BaseError = Exception # Fallback if private module import fails
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
CTX_BASEMAP_SOURCE = xyz.Esri.WorldImagery # pyright: ignore[reportAttributeAccessIssue]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RasterCellArrayShapeError(ValueError):
|
|
56
|
+
"""Custom error for invalid raster cell array shapes."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RasterModel(BaseModel):
|
|
60
|
+
"""2-dimensional raster and metadata."""
|
|
61
|
+
|
|
62
|
+
arr: InstanceOf[np.ndarray]
|
|
63
|
+
raster_meta: RasterMeta
|
|
64
|
+
|
|
65
|
+
def __eq__(self, other: object) -> bool:
|
|
66
|
+
"""Check equality of two RasterModel objects."""
|
|
67
|
+
if not isinstance(other, RasterModel):
|
|
68
|
+
return NotImplemented
|
|
69
|
+
return (
|
|
70
|
+
np.array_equal(self.arr, other.arr)
|
|
71
|
+
and self.raster_meta == other.raster_meta
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
__hash__ = BaseModel.__hash__
|
|
75
|
+
|
|
76
|
+
def __add__(self, other: float | Self) -> Self:
|
|
77
|
+
if isinstance(other, float | int):
|
|
78
|
+
new_arr = self.arr + other
|
|
79
|
+
return RasterModel(arr=new_arr, raster_meta=self.raster_meta)
|
|
80
|
+
elif isinstance(other, RasterModel):
|
|
81
|
+
if self.raster_meta != other.raster_meta:
|
|
82
|
+
msg = (
|
|
83
|
+
"Rasters must have the same metadata (e.g. CRS, cell size, etc.) "
|
|
84
|
+
"to be added"
|
|
85
|
+
)
|
|
86
|
+
raise ValueError(msg)
|
|
87
|
+
if self.arr.shape != other.arr.shape:
|
|
88
|
+
msg = (
|
|
89
|
+
"Rasters must have the same shape to be added:\n"
|
|
90
|
+
f"{self.arr.shape} != {other.arr.shape}"
|
|
91
|
+
)
|
|
92
|
+
raise ValueError(msg)
|
|
93
|
+
new_arr = self.arr + other.arr
|
|
94
|
+
return RasterModel(arr=new_arr, raster_meta=self.raster_meta)
|
|
95
|
+
else:
|
|
96
|
+
return NotImplemented
|
|
97
|
+
|
|
98
|
+
def __radd__(self, other: float) -> Self:
|
|
99
|
+
return self + other
|
|
100
|
+
|
|
101
|
+
def __mul__(self, other: float | Self) -> Self:
|
|
102
|
+
if isinstance(other, float | int):
|
|
103
|
+
new_arr = self.arr * other
|
|
104
|
+
return RasterModel(arr=new_arr, raster_meta=self.raster_meta)
|
|
105
|
+
elif isinstance(other, RasterModel):
|
|
106
|
+
if self.raster_meta != other.raster_meta:
|
|
107
|
+
msg = (
|
|
108
|
+
"Rasters must have the same metadata (e.g. CRS, cell size, etc.) "
|
|
109
|
+
"to be multiplied"
|
|
110
|
+
)
|
|
111
|
+
raise ValueError(msg)
|
|
112
|
+
if self.arr.shape != other.arr.shape:
|
|
113
|
+
msg = "Rasters must have the same shape to be multiplied"
|
|
114
|
+
raise ValueError(msg)
|
|
115
|
+
new_arr = self.arr * other.arr
|
|
116
|
+
return RasterModel(arr=new_arr, raster_meta=self.raster_meta)
|
|
117
|
+
else:
|
|
118
|
+
return NotImplemented
|
|
119
|
+
|
|
120
|
+
def __rmul__(self, other: float) -> Self:
|
|
121
|
+
return self * other
|
|
122
|
+
|
|
123
|
+
def __truediv__(self, other: float | Self) -> Self:
|
|
124
|
+
if isinstance(other, float | int):
|
|
125
|
+
new_arr = self.arr / other
|
|
126
|
+
return RasterModel(arr=new_arr, raster_meta=self.raster_meta)
|
|
127
|
+
elif isinstance(other, RasterModel):
|
|
128
|
+
if self.raster_meta != other.raster_meta:
|
|
129
|
+
msg = (
|
|
130
|
+
"Rasters must have the same metadata (e.g. CRS, cell size, etc.) "
|
|
131
|
+
"to be divided"
|
|
132
|
+
)
|
|
133
|
+
raise ValueError(msg)
|
|
134
|
+
if self.arr.shape != other.arr.shape:
|
|
135
|
+
msg = "Rasters must have the same shape to be divided"
|
|
136
|
+
raise ValueError(msg)
|
|
137
|
+
new_arr = self.arr / other.arr
|
|
138
|
+
return RasterModel(arr=new_arr, raster_meta=self.raster_meta)
|
|
139
|
+
else:
|
|
140
|
+
return NotImplemented
|
|
141
|
+
|
|
142
|
+
def __rtruediv__(self, other: float) -> Self:
|
|
143
|
+
return self / other
|
|
144
|
+
|
|
145
|
+
def __sub__(self, other: float | Self) -> Self:
|
|
146
|
+
return self + (-other)
|
|
147
|
+
|
|
148
|
+
def __rsub__(self, other: float) -> Self:
|
|
149
|
+
return -self + other
|
|
150
|
+
|
|
151
|
+
def __neg__(self) -> Self:
|
|
152
|
+
return RasterModel(arr=-self.arr, raster_meta=self.raster_meta)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def cell_centre_coords(self) -> NDArray[np.float64]:
|
|
156
|
+
"""Get the coordinates of the cell centres in the raster."""
|
|
157
|
+
return self.raster_meta.get_cell_centre_coords(self.arr.shape)
|
|
158
|
+
|
|
159
|
+
@contextmanager
|
|
160
|
+
def to_rasterio_dataset(
|
|
161
|
+
self,
|
|
162
|
+
) -> Generator[DatasetReader | BufferedDatasetWriter | DatasetWriter]:
|
|
163
|
+
"""Create a rasterio in-memory dataset from the Raster object.
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
>>> raster = RasterModel.example()
|
|
167
|
+
>>> with raster.to_rasterio_dataset() as dataset:
|
|
168
|
+
>>> ...
|
|
169
|
+
"""
|
|
170
|
+
memfile = MemoryFile()
|
|
171
|
+
|
|
172
|
+
height, width = self.arr.shape
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
with memfile.open(
|
|
176
|
+
driver="GTiff",
|
|
177
|
+
height=height,
|
|
178
|
+
width=width,
|
|
179
|
+
count=1, # Assuming a single band; adjust as necessary
|
|
180
|
+
dtype=self.arr.dtype,
|
|
181
|
+
crs=self.raster_meta.crs.to_wkt(),
|
|
182
|
+
transform=self.raster_meta.transform,
|
|
183
|
+
) as dataset:
|
|
184
|
+
dataset.write(self.arr, 1)
|
|
185
|
+
|
|
186
|
+
# Yield the dataset for reading
|
|
187
|
+
with memfile.open() as dataset:
|
|
188
|
+
yield dataset
|
|
189
|
+
finally:
|
|
190
|
+
memfile.close()
|
|
191
|
+
|
|
192
|
+
def sample(
|
|
193
|
+
self,
|
|
194
|
+
xy: list[tuple[float, float]],
|
|
195
|
+
*,
|
|
196
|
+
na_action: Literal["raise", "ignore"] = "raise",
|
|
197
|
+
) -> NDArray[np.float64]:
|
|
198
|
+
"""Sample raster values at GeoSeries locations and return sampled values.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
xy: A list of (x, y) coordinates to sample the raster at.
|
|
202
|
+
na_action: Action to take when a NaN value is encountered in the input xy.
|
|
203
|
+
Options are "raise" (raise an error) or "ignore" (replace with
|
|
204
|
+
NaN).
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
A list of sampled raster values for each geometry in the GeoSeries.
|
|
208
|
+
"""
|
|
209
|
+
# If this function is too slow, consider the optimizations detailed here:
|
|
210
|
+
# https://rdrn.me/optimising-sampling/
|
|
211
|
+
|
|
212
|
+
# Short-circuit
|
|
213
|
+
if len(xy) == 0:
|
|
214
|
+
return np.array([], dtype=float)
|
|
215
|
+
|
|
216
|
+
# Create in-memory rasterio dataset from the incumbent Raster object
|
|
217
|
+
with self.to_rasterio_dataset() as dataset:
|
|
218
|
+
if dataset.count != 1:
|
|
219
|
+
msg = "Only single band rasters are supported."
|
|
220
|
+
raise NotImplementedError(msg)
|
|
221
|
+
|
|
222
|
+
xy_arr = np.array(xy)
|
|
223
|
+
|
|
224
|
+
# Determine the indexes of any x,y coordinates where either is NaN.
|
|
225
|
+
# We will drop these indexes for the purposes of calling .sample, but
|
|
226
|
+
# then we will add NaN values back in at the end, inserting NaN into the
|
|
227
|
+
# results array.
|
|
228
|
+
xy_is_nan = np.isnan(xy_arr).any(axis=1)
|
|
229
|
+
xy_nan_idxs = list(np.atleast_1d(np.squeeze(np.nonzero(xy_is_nan))))
|
|
230
|
+
xy_arr = xy_arr[~xy_is_nan]
|
|
231
|
+
|
|
232
|
+
if na_action == "raise" and len(xy_nan_idxs) > 0:
|
|
233
|
+
nan_error_msg = "NaN value found in input coordinates"
|
|
234
|
+
raise ValueError(nan_error_msg)
|
|
235
|
+
|
|
236
|
+
# Sample the raster in-memory dataset (e.g. PGA values) at the coordinates
|
|
237
|
+
samples = list(
|
|
238
|
+
rasterio.sample.sample_gen(
|
|
239
|
+
dataset,
|
|
240
|
+
xy_arr,
|
|
241
|
+
indexes=1, # Single band raster, N.B. rasterio is 1-indexed
|
|
242
|
+
masked=True,
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Convert the sampled values to a NumPy array and set masked values to NaN
|
|
247
|
+
raster_values = np.array(
|
|
248
|
+
[s.data[0] if not s.mask else np.nan for s in samples]
|
|
249
|
+
).astype(float)
|
|
250
|
+
|
|
251
|
+
if len(xy_nan_idxs) > 0:
|
|
252
|
+
# Insert NaN values back into the results array
|
|
253
|
+
# This is tricky because all the indexes get offset once we remove
|
|
254
|
+
# elements.
|
|
255
|
+
offset_xy_nan_idxs = xy_nan_idxs - np.arange(len(xy_nan_idxs))
|
|
256
|
+
raster_values = np.insert(
|
|
257
|
+
raster_values,
|
|
258
|
+
offset_xy_nan_idxs,
|
|
259
|
+
np.nan,
|
|
260
|
+
axis=0,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return raster_values
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def bounds(self) -> tuple[float, float, float, float]:
|
|
267
|
+
"""Bounding box of the raster as (xmin, ymin, xmax, ymax)"""
|
|
268
|
+
x1, y1, x2, y2 = rasterio.transform.array_bounds(
|
|
269
|
+
height=self.arr.shape[0],
|
|
270
|
+
width=self.arr.shape[1],
|
|
271
|
+
transform=self.raster_meta.transform,
|
|
272
|
+
)
|
|
273
|
+
xmin, xmax = sorted([x1, x2])
|
|
274
|
+
ymin, ymax = sorted([y1, y2])
|
|
275
|
+
return (xmin, ymin, xmax, ymax)
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def bbox(self) -> Polygon:
|
|
279
|
+
"""Bounding box of the raster as a shapely polygon."""
|
|
280
|
+
xmin, ymin, xmax, ymax = self.bounds
|
|
281
|
+
return Polygon(
|
|
282
|
+
[
|
|
283
|
+
(xmin, ymin),
|
|
284
|
+
(xmin, ymax),
|
|
285
|
+
(xmax, ymax),
|
|
286
|
+
(xmax, ymin),
|
|
287
|
+
(xmin, ymin),
|
|
288
|
+
]
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def explore(
|
|
292
|
+
self,
|
|
293
|
+
*,
|
|
294
|
+
m: Map | None = None,
|
|
295
|
+
opacity: float = 1.0,
|
|
296
|
+
colormap: str = "viridis",
|
|
297
|
+
) -> Map:
|
|
298
|
+
"""Display the raster on a folium map."""
|
|
299
|
+
if not FOLIUM_INSTALLED:
|
|
300
|
+
msg = "The 'folium' package is required for 'explore()'."
|
|
301
|
+
raise ImportError(msg)
|
|
302
|
+
|
|
303
|
+
if m is None:
|
|
304
|
+
m = folium.Map()
|
|
305
|
+
|
|
306
|
+
rbga_map: Callable[[float], tuple[float, float, float, float]] = mpl.colormaps[
|
|
307
|
+
colormap
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
wgs84_crs = CRS.from_epsg(4326)
|
|
311
|
+
gdf = gpd.GeoDataFrame(geometry=[self.bbox], crs=self.raster_meta.crs).to_crs(
|
|
312
|
+
wgs84_crs
|
|
313
|
+
)
|
|
314
|
+
xmin, ymin, xmax, ymax = gdf.total_bounds
|
|
315
|
+
|
|
316
|
+
arr = np.array(self.arr)
|
|
317
|
+
|
|
318
|
+
# Normalize the data to the range [0, 1] as this is the cmap range
|
|
319
|
+
min_val = np.nanmin(arr)
|
|
320
|
+
max_val = np.nanmax(arr)
|
|
321
|
+
if max_val > min_val: # Prevent division by zero
|
|
322
|
+
arr = (arr - min_val) / (max_val - min_val)
|
|
323
|
+
else:
|
|
324
|
+
arr = np.zeros_like(arr) # In case all values are the same
|
|
325
|
+
|
|
326
|
+
# Finally, need to determine whether to flip the image based on negative Affine
|
|
327
|
+
# coefficients
|
|
328
|
+
flip_x = self.raster_meta.transform.a < 0
|
|
329
|
+
flip_y = self.raster_meta.transform.e > 0
|
|
330
|
+
if flip_x:
|
|
331
|
+
arr = np.flip(self.arr, axis=1)
|
|
332
|
+
if flip_y:
|
|
333
|
+
arr = np.flip(self.arr, axis=0)
|
|
334
|
+
|
|
335
|
+
img = folium.raster_layers.ImageOverlay(
|
|
336
|
+
image=arr,
|
|
337
|
+
bounds=[[ymin, xmin], [ymax, xmax]],
|
|
338
|
+
opacity=opacity,
|
|
339
|
+
colormap=rbga_map,
|
|
340
|
+
mercator_project=True,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
img.add_to(m)
|
|
344
|
+
|
|
345
|
+
m.fit_bounds([[ymin, xmin], [ymax, xmax]])
|
|
346
|
+
|
|
347
|
+
return m
|
|
348
|
+
|
|
349
|
+
def to_clipboard(self) -> None:
|
|
350
|
+
"""Copy the raster cell array to the clipboard."""
|
|
351
|
+
pd.DataFrame(self.arr).to_clipboard(index=False, header=False)
|
|
352
|
+
|
|
353
|
+
def plot(
|
|
354
|
+
self,
|
|
355
|
+
*,
|
|
356
|
+
ax: Axes | None = None,
|
|
357
|
+
cbar_label: str | None = None,
|
|
358
|
+
basemap: bool = False,
|
|
359
|
+
cmap: str = "viridis",
|
|
360
|
+
) -> Axes:
|
|
361
|
+
"""Plot the raster on a matplotlib axis."""
|
|
362
|
+
if ax is None:
|
|
363
|
+
_, ax = plt.subplots()
|
|
364
|
+
ax: Axes
|
|
365
|
+
|
|
366
|
+
if basemap:
|
|
367
|
+
msg = "Basemap plotting is not yet implemented."
|
|
368
|
+
raise NotImplementedError(msg)
|
|
369
|
+
|
|
370
|
+
arr = self.arr.copy()
|
|
371
|
+
|
|
372
|
+
# Get extent of the non-zero values in array index coordinates
|
|
373
|
+
(x_nonzero,) = np.nonzero(arr.any(axis=0))
|
|
374
|
+
(y_nonzero,) = np.nonzero(arr.any(axis=1))
|
|
375
|
+
|
|
376
|
+
if len(x_nonzero) == 0 or len(y_nonzero) == 0:
|
|
377
|
+
msg = "Raster contains no non-zero values; cannot plot."
|
|
378
|
+
raise ValueError(msg)
|
|
379
|
+
|
|
380
|
+
min_x_nonzero = np.min(x_nonzero)
|
|
381
|
+
max_x_nonzero = np.max(x_nonzero)
|
|
382
|
+
min_y_nonzero = np.min(y_nonzero)
|
|
383
|
+
max_y_nonzero = np.max(y_nonzero)
|
|
384
|
+
|
|
385
|
+
# Transform to raster CRS
|
|
386
|
+
x1, y1 = self.raster_meta.transform * (min_x_nonzero, min_y_nonzero)
|
|
387
|
+
x2, y2 = self.raster_meta.transform * (max_x_nonzero, max_y_nonzero)
|
|
388
|
+
xmin, xmax = sorted([x1, x2])
|
|
389
|
+
ymin, ymax = sorted([y1, y2])
|
|
390
|
+
|
|
391
|
+
arr[arr == 0] = np.nan
|
|
392
|
+
|
|
393
|
+
with self.to_rasterio_dataset() as dataset:
|
|
394
|
+
img, *_ = rasterio.plot.show(
|
|
395
|
+
dataset, with_bounds=True, ax=ax, cmap=cmap
|
|
396
|
+
).get_images()
|
|
397
|
+
|
|
398
|
+
ax.set_xlim(xmin, xmax)
|
|
399
|
+
ax.set_ylim(ymin, ymax)
|
|
400
|
+
|
|
401
|
+
ax.set_aspect("equal", "box")
|
|
402
|
+
ax.set_yticklabels([])
|
|
403
|
+
ax.set_xticklabels([])
|
|
404
|
+
|
|
405
|
+
divider = make_axes_locatable(ax)
|
|
406
|
+
cax = divider.append_axes("right", size="5%", pad=0.05)
|
|
407
|
+
fig = ax.get_figure()
|
|
408
|
+
fig.colorbar(img, label=cbar_label, cax=cax)
|
|
409
|
+
return ax
|
|
410
|
+
|
|
411
|
+
def as_geodataframe(self, name: str = "value") -> gpd.GeoDataFrame:
|
|
412
|
+
"""Create a GeoDataFrame representation of the raster."""
|
|
413
|
+
polygons = create_fishnet(bounds=self.bounds, res=self.raster_meta.cell_size)
|
|
414
|
+
point_tuples = [polygon.centroid.coords[0] for polygon in polygons]
|
|
415
|
+
raster_gdf = gpd.GeoDataFrame(
|
|
416
|
+
{
|
|
417
|
+
"geometry": polygons,
|
|
418
|
+
name: self.sample(point_tuples, na_action="ignore"),
|
|
419
|
+
},
|
|
420
|
+
crs=self.raster_meta.crs,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
return raster_gdf
|
|
424
|
+
|
|
425
|
+
def to_file(self, path: Path) -> None:
|
|
426
|
+
"""Write the raster to a GeoTIFF file."""
|
|
427
|
+
|
|
428
|
+
suffix = path.suffix.lower()
|
|
429
|
+
if suffix in (".tif", ".tiff"):
|
|
430
|
+
driver = "GTiff"
|
|
431
|
+
elif suffix in (".grd"):
|
|
432
|
+
# https://grapherhelp.goldensoftware.com/subsys/ascii_grid_file_format.htm
|
|
433
|
+
# e.g. Used by AnAqSim
|
|
434
|
+
driver = "GSAG"
|
|
435
|
+
else:
|
|
436
|
+
msg = f"Unsupported file extension: {suffix}"
|
|
437
|
+
raise ValueError(msg)
|
|
438
|
+
|
|
439
|
+
with rasterio.open(
|
|
440
|
+
path,
|
|
441
|
+
"w",
|
|
442
|
+
driver=driver,
|
|
443
|
+
height=self.arr.shape[0],
|
|
444
|
+
width=self.arr.shape[1],
|
|
445
|
+
count=1,
|
|
446
|
+
dtype=self.arr.dtype,
|
|
447
|
+
crs=self.raster_meta.crs,
|
|
448
|
+
transform=self.raster_meta.transform,
|
|
449
|
+
nodata=np.nan,
|
|
450
|
+
) as dst:
|
|
451
|
+
try:
|
|
452
|
+
dst.write(self.arr, 1)
|
|
453
|
+
except CPLE_BaseError as err:
|
|
454
|
+
msg = f"Failed to write raster to file: {err}"
|
|
455
|
+
raise OSError(msg) from err
|
|
456
|
+
|
|
457
|
+
def __str__(self) -> str:
|
|
458
|
+
mean = np.nanmean(self.arr)
|
|
459
|
+
return f"RasterModel(shape={self.arr.shape}, {mean=})"
|
|
460
|
+
|
|
461
|
+
def __repr__(self) -> str:
|
|
462
|
+
return str(self)
|
|
463
|
+
|
|
464
|
+
@classmethod
|
|
465
|
+
def example(cls) -> Self:
|
|
466
|
+
"""Create an example RasterModel."""
|
|
467
|
+
# Peaks dataset style example
|
|
468
|
+
n = 256
|
|
469
|
+
x = np.linspace(-3, 3, n)
|
|
470
|
+
y = np.linspace(-3, 3, n)
|
|
471
|
+
x, y = np.meshgrid(x, y)
|
|
472
|
+
z = np.exp(-(x**2) - y**2) * np.sin(3 * np.sqrt(x**2 + y**2))
|
|
473
|
+
arr = z.astype(np.float32)
|
|
474
|
+
|
|
475
|
+
raster_meta = RasterMeta.example()
|
|
476
|
+
return cls(arr=arr, raster_meta=raster_meta)
|
|
477
|
+
|
|
478
|
+
def fillna(self, value: float) -> Self:
|
|
479
|
+
"""Fill NaN values in the raster with a specified value.
|
|
480
|
+
|
|
481
|
+
See also `extrapolate()` for filling NaN values using extrapolation from data.
|
|
482
|
+
"""
|
|
483
|
+
filled_arr = np.nan_to_num(self.arr, nan=value)
|
|
484
|
+
new_raster = self.model_copy()
|
|
485
|
+
new_raster.arr = filled_arr
|
|
486
|
+
return new_raster
|
|
487
|
+
|
|
488
|
+
def get_xy(self) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
489
|
+
"""Get the x and y coordinates of the raster in meshgrid format."""
|
|
490
|
+
col_idx, row_idx = np.meshgrid(
|
|
491
|
+
np.arange(self.arr.shape[1]),
|
|
492
|
+
np.arange(self.arr.shape[0]),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
col_idx = col_idx.flatten()
|
|
496
|
+
row_idx = row_idx.flatten()
|
|
497
|
+
|
|
498
|
+
coords = np.vstack((row_idx, col_idx)).T
|
|
499
|
+
|
|
500
|
+
x, y = rasterio.transform.xy(self.raster_meta.transform, *coords.T)
|
|
501
|
+
x = np.array(x).reshape(self.arr.shape)
|
|
502
|
+
y = np.array(y).reshape(self.arr.shape)
|
|
503
|
+
return x, y
|
|
504
|
+
|
|
505
|
+
def contour(
|
|
506
|
+
self, *, levels: list[float], smoothing: bool = True
|
|
507
|
+
) -> gpd.GeoDataFrame:
|
|
508
|
+
"""Create contour lines from the raster data, optionally with smoothing.
|
|
509
|
+
|
|
510
|
+
The contour lines are returned as a GeoDataFrame with the contours as linestring
|
|
511
|
+
geometries and the contour levels as attributes in a column named 'level'.
|
|
512
|
+
|
|
513
|
+
Consider calling `blur()` before this method to smooth the raster data before
|
|
514
|
+
contouring, to denoise the contours.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
levels: A list of contour levels to generate. The contour lines will be
|
|
518
|
+
generated for each level in this list.
|
|
519
|
+
smoothing: Defaults to true, which corresponds to applying a smoothing
|
|
520
|
+
algorithm to the contour lines. At the moment, this is the
|
|
521
|
+
Catmull-Rom spline algorithm. If set to False, the raw
|
|
522
|
+
contours will be returned without any smoothing.
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
all_levels = []
|
|
526
|
+
all_geoms = []
|
|
527
|
+
for level in levels:
|
|
528
|
+
contours = skimage.measure.find_contours(
|
|
529
|
+
self.arr,
|
|
530
|
+
level=level,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# Constructg shapely LineString objects
|
|
534
|
+
# Convert to CRS from array index coordinates to raster CRS
|
|
535
|
+
geoms = [
|
|
536
|
+
LineString(
|
|
537
|
+
np.array(
|
|
538
|
+
rasterio.transform.xy(self.raster_meta.transform, *contour.T)
|
|
539
|
+
).T
|
|
540
|
+
)
|
|
541
|
+
for contour in contours
|
|
542
|
+
# Contour lines need at least three distinct points to avoid
|
|
543
|
+
# degenerate geometries
|
|
544
|
+
if np.unique(contour, axis=0).shape[0] > 2
|
|
545
|
+
]
|
|
546
|
+
|
|
547
|
+
# Apply smoothing if requested
|
|
548
|
+
if smoothing:
|
|
549
|
+
geoms = [catmull_rom_smooth(geom) for geom in geoms]
|
|
550
|
+
|
|
551
|
+
all_geoms.extend(geoms)
|
|
552
|
+
all_levels.extend([level] * len(geoms))
|
|
553
|
+
|
|
554
|
+
contour_gdf = gpd.GeoDataFrame(
|
|
555
|
+
data={
|
|
556
|
+
"level": all_levels,
|
|
557
|
+
},
|
|
558
|
+
geometry=all_geoms,
|
|
559
|
+
crs=self.raster_meta.crs,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
return contour_gdf
|
|
563
|
+
|
|
564
|
+
def blur(self, sigma: float) -> Self:
|
|
565
|
+
"""Apply a Gaussian blur to the raster data.
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
sigma: Standard deviation for Gaussian kernel, in units of geographic
|
|
569
|
+
coordinate distance (e.g. meters). A larger sigma results in a more
|
|
570
|
+
blurred image.
|
|
571
|
+
"""
|
|
572
|
+
|
|
573
|
+
cell_sigma = sigma / self.raster_meta.cell_size
|
|
574
|
+
|
|
575
|
+
blurred_array = gaussian_filter(self.arr, sigma=cell_sigma)
|
|
576
|
+
new_raster = self.model_copy()
|
|
577
|
+
new_raster.arr = blurred_array
|
|
578
|
+
return new_raster
|
|
579
|
+
|
|
580
|
+
def extrapolate(self, method: Literal["nearest"] = "nearest") -> Self:
|
|
581
|
+
"""Extrapolate the raster data to fill NaN values.
|
|
582
|
+
|
|
583
|
+
See also `fillna()` for filling NaN values with a specific value.
|
|
584
|
+
|
|
585
|
+
If the raster is all-NaN, this method will return a copy of the raster without
|
|
586
|
+
changing the NaN values.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
method: The method to use for extrapolation. Currently only 'nearest' is
|
|
590
|
+
supported, which fills NaN values with the nearest non-NaN value.
|
|
591
|
+
"""
|
|
592
|
+
if method not in ("nearest",):
|
|
593
|
+
msg = f"Unsupported extrapolation method: {method}"
|
|
594
|
+
raise NotImplementedError(msg)
|
|
595
|
+
|
|
596
|
+
raster = self.model_copy()
|
|
597
|
+
raster.arr = fillna_nearest_neighbours(arr=self.arr)
|
|
598
|
+
|
|
599
|
+
return raster
|
|
600
|
+
|
|
601
|
+
def resample(
|
|
602
|
+
self, new_cell_size: float, *, method: Literal["bilinear"] = "bilinear"
|
|
603
|
+
) -> Self:
|
|
604
|
+
"""Resample the raster data to a new resolution.
|
|
605
|
+
|
|
606
|
+
If the new cell size is not an exact multiple of the current cell size, the
|
|
607
|
+
overall raster bounds may increase slightly. The affine transform will keep
|
|
608
|
+
the same shift, i.e. the top-left corner of the raster will remain in the same'
|
|
609
|
+
coordinate location. A corollary is that the overall centre of the raster bounds
|
|
610
|
+
will not necessary be the same as the original raster.
|
|
611
|
+
|
|
612
|
+
Args:
|
|
613
|
+
new_cell_size: The desired cell size for the resampled raster.
|
|
614
|
+
method: The resampling method to use. Only 'bilinear' is supported.
|
|
615
|
+
"""
|
|
616
|
+
if method not in ("bilinear",):
|
|
617
|
+
msg = f"Unsupported resampling method: {method}"
|
|
618
|
+
raise NotImplementedError(msg)
|
|
619
|
+
|
|
620
|
+
factor = self.raster_meta.cell_size / new_cell_size
|
|
621
|
+
|
|
622
|
+
# Use the rasterio dataset with proper context management
|
|
623
|
+
with self.to_rasterio_dataset() as dataset:
|
|
624
|
+
# N.B. the new height and width may increase slightly.
|
|
625
|
+
new_height = int(np.ceil(dataset.height * factor))
|
|
626
|
+
new_width = int(np.ceil(dataset.width * factor))
|
|
627
|
+
|
|
628
|
+
# Resample via rasterio
|
|
629
|
+
(new_arr,) = dataset.read( # Assume exactly one band
|
|
630
|
+
out_shape=(dataset.count, new_height, new_width),
|
|
631
|
+
resampling=Resampling.bilinear,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
# Create new RasterMeta with updated transform and cell size
|
|
635
|
+
new_raster_meta = RasterMeta(
|
|
636
|
+
transform=dataset.transform
|
|
637
|
+
* dataset.transform.scale(
|
|
638
|
+
(dataset.width / new_width),
|
|
639
|
+
(dataset.height / new_height),
|
|
640
|
+
),
|
|
641
|
+
crs=self.raster_meta.crs,
|
|
642
|
+
cell_size=new_cell_size,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
return RasterModel(arr=new_arr, raster_meta=new_raster_meta)
|
|
646
|
+
|
|
647
|
+
@field_validator("arr")
|
|
648
|
+
@classmethod
|
|
649
|
+
def check_2d_array(cls, v: np.ndarray) -> np.ndarray:
|
|
650
|
+
"""Validator to ensure the cell array is 2D."""
|
|
651
|
+
if v.ndim != 2:
|
|
652
|
+
msg = "Cell array must be 2D"
|
|
653
|
+
raise RasterCellArrayShapeError(msg)
|
|
654
|
+
return v
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rastr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Geospatial Raster datatype library for Python.
|
|
5
|
+
Project-URL: Source Code, https://github.com/tonkintaylor/rastr
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/tonkintaylor/rastr/issues
|
|
7
|
+
Project-URL: Releases, https://github.com/tonkintaylor/rastr/releases
|
|
8
|
+
Project-URL: Source Archive, https://github.com/tonkintaylor/rastr/archive/ac9cfaefef4030485d30ce79b97a000821338bd2.zip
|
|
9
|
+
Author-email: Tonkin & Taylor Limited <Sub-DisciplineData+AnalyticsStaff@tonkintaylor.co.nz>, Nathan McDougall <nmcdougall@tonkintaylor.co.nz>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: affine>=2.4.0
|
|
19
|
+
Requires-Dist: folium>=0.20.0
|
|
20
|
+
Requires-Dist: geopandas>=1.1.1
|
|
21
|
+
Requires-Dist: matplotlib>=3.10.5
|
|
22
|
+
Requires-Dist: numpy>=2.2.6
|
|
23
|
+
Requires-Dist: pandas>=2.3.1
|
|
24
|
+
Requires-Dist: pydantic>=2.11.7
|
|
25
|
+
Requires-Dist: pyproj>=3.7.1
|
|
26
|
+
Requires-Dist: rasterio>=1.4.3
|
|
27
|
+
Requires-Dist: scikit-image>=0.25.2
|
|
28
|
+
Requires-Dist: scipy>=1.15.3
|
|
29
|
+
Requires-Dist: shapely>=2.1.1
|
|
30
|
+
Requires-Dist: tqdm>=4.67.1
|
|
31
|
+
Requires-Dist: typing-extensions>=4.14.1
|
|
32
|
+
Requires-Dist: xyzservices>=2025.4.0
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# Rastr
|
|
36
|
+
|
|
37
|
+
[](<https://pypi.python.org/pypi/rastr>)
|
|
38
|
+
[](https://github.com/astral-sh/uv)
|
|
39
|
+
[](https://github.com/astral-sh/ruff)
|
|
40
|
+
[](https://github.com/usethis-python/usethis-python)
|
|
41
|
+
|
|
42
|
+
Geospatial Raster datatype library for Python.
|
|
43
|
+
|
|
44
|
+
Currently, only single-banded rasters with square cells are supported.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
rastr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
rastr/_version.py,sha256=-LyU5F1uZDjn6Q8_Z6-_FJt_8RE4Kq9zcKdg1abSSps,511
|
|
3
|
+
rastr/create.py,sha256=tHLVnGarMt04p1z8CVknMWMjQLKrb0WrcP_Wgdw8xr4,9346
|
|
4
|
+
rastr/io.py,sha256=GLj2o26L2bLLXys2wTSKk1mM-FRDciOt7Ty7r87gVg4,961
|
|
5
|
+
rastr/meta.py,sha256=b_knC8a5qWcAZKm8RrZ7bYTLuOtfGQsIp73JMcq8cU0,1384
|
|
6
|
+
rastr/raster.py,sha256=PzmMLJqf6nhYNu81dqLJ9iZpkxOBrn02FtI-5o3meL4,22996
|
|
7
|
+
rastr/arr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
rastr/arr/fill.py,sha256=7N3ECMli7ssNJJk5qgDoj_3xExgu03nohGPgUKWxcCk,903
|
|
9
|
+
rastr/gis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
rastr/gis/fishnet.py,sha256=LZqtI9cgYPacuWNfIfdbTkRMRSnJCQdYlaT2eVPmorM,2459
|
|
11
|
+
rastr/gis/smooth.py,sha256=LbWvAG1O-O5H6P5LrbwD03mbnXVYjkH1re-_iZ4arIU,4754
|
|
12
|
+
rastr-0.1.0.dist-info/METADATA,sha256=IKTH8nVmhO3FRMTHWKk0zpdxt5kxw5giusz-7perakI,2143
|
|
13
|
+
rastr-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
14
|
+
rastr-0.1.0.dist-info/licenses/LICENSE,sha256=7qUsx93G2ATTRLZiSYuQofwAX_uWvrqnAiMK8PzxvNc,1080
|
|
15
|
+
rastr-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Tonkin + Taylor Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|