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 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
+ [![PyPI Version](https://img.shields.io/pypi/v/rastr.svg)](<https://pypi.python.org/pypi/rastr>)
38
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
39
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
40
+ [![usethis](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/usethis-python/usethis-python/main/assets/badge/v1.json)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.