rastr 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of rastr might be problematic. Click here for more details.

rastr/_version.py CHANGED
@@ -1,7 +1,14 @@
1
1
  # file generated by setuptools-scm
2
2
  # don't change, don't track in version control
3
3
 
4
- __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
5
12
 
6
13
  TYPE_CHECKING = False
7
14
  if TYPE_CHECKING:
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
9
16
  from typing import Union
10
17
 
11
18
  VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
12
20
  else:
13
21
  VERSION_TUPLE = object
22
+ COMMIT_ID = object
14
23
 
15
24
  version: str
16
25
  __version__: str
17
26
  __version_tuple__: VERSION_TUPLE
18
27
  version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
19
30
 
20
- __version__ = version = '0.2.0'
21
- __version_tuple__ = version_tuple = (0, 2, 0)
31
+ __version__ = version = '0.3.0'
32
+ __version_tuple__ = version_tuple = (0, 3, 0)
33
+
34
+ __commit_id__ = commit_id = None
rastr/arr/fill.py CHANGED
@@ -1,6 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
1
5
  import numpy as np
2
- from numpy.typing import NDArray
3
- from scipy.interpolate import NearestNDInterpolator
6
+
7
+ if TYPE_CHECKING:
8
+ from numpy.typing import NDArray
4
9
 
5
10
 
6
11
  def fillna_nearest_neighbours(arr: NDArray[np.float64]) -> NDArray[np.float64]:
@@ -11,6 +16,8 @@ def fillna_nearest_neighbours(arr: NDArray[np.float64]) -> NDArray[np.float64]:
11
16
  In the case of tiebreaks, the value from the neighbour with the lowest index is
12
17
  imputed.
13
18
  """
19
+ from scipy.interpolate import NearestNDInterpolator
20
+
14
21
  nonnan_mask = np.nonzero(~np.isnan(arr))
15
22
  nonnan_idxs = np.array(nonnan_mask).transpose()
16
23
 
rastr/create.py CHANGED
@@ -1,18 +1,32 @@
1
- from collections.abc import Iterable
2
- from functools import partial
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import warnings
5
+ from typing import TYPE_CHECKING, TypeVar
3
6
 
4
- import geopandas as gpd
5
7
  import numpy as np
6
- import pandas as pd
7
8
  import rasterio.features
9
+ import rasterio.transform
8
10
  from affine import Affine
9
- from shapely.geometry import Point, Polygon
10
- from tqdm.notebook import tqdm
11
+ from pyproj import CRS
12
+ from shapely.geometry import Point
11
13
 
12
14
  from rastr.gis.fishnet import create_point_grid, get_point_grid_shape
13
15
  from rastr.meta import RasterMeta
14
16
  from rastr.raster import RasterModel
15
17
 
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Iterable
20
+
21
+ import geopandas as gpd
22
+ from numpy.typing import ArrayLike
23
+ from shapely.geometry import Polygon
24
+
25
+
26
+ TQDM_INSTALLED = importlib.util.find_spec("tqdm") is not None
27
+
28
+ _T = TypeVar("_T")
29
+
16
30
 
17
31
  class MissingColumnsError(ValueError):
18
32
  """Raised when target columns are missing from the GeoDataFrame."""
@@ -61,17 +75,10 @@ def raster_distance_from_polygon(
61
75
  Raises:
62
76
  ValueError: If the provided CRS is geographic (lat/lon).
63
77
  """
64
- if 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
78
+ if show_pbar and not TQDM_INSTALLED:
79
+ msg = "The 'tqdm' package is not installed. Progress bars will not be shown."
80
+ warnings.warn(msg, UserWarning, stacklevel=2)
81
+ show_pbar = False
75
82
 
76
83
  # Check if the provided CRS is projected (cartesian)
77
84
  if raster_meta.crs.is_geographic:
@@ -80,36 +87,44 @@ def raster_distance_from_polygon(
80
87
  )
81
88
  raise ValueError(err_msg)
82
89
 
83
- # Calculate the coordinates
84
- if snap_raster is not None:
90
+ if extent_polygon is None and snap_raster is None:
91
+ err_msg = "Either 'extent_polygon' or 'snap_raster' must be provided. "
92
+ raise ValueError(err_msg)
93
+ elif extent_polygon is not None and snap_raster is not None:
94
+ err_msg = "Only one of 'extent_polygon' or 'snap_raster' can be provided. "
95
+ raise ValueError(err_msg)
96
+ elif extent_polygon is None and snap_raster is not None:
97
+ # Calculate the coordinates
85
98
  x, y = snap_raster.get_xy()
86
- else:
99
+
100
+ # Create a mask to identify points for which distance should be calculated
101
+ distance_extent = snap_raster.bbox.difference(polygon)
102
+ elif extent_polygon is not None and snap_raster is None:
87
103
  x, y = create_point_grid(
88
104
  bounds=extent_polygon.bounds, cell_size=raster_meta.cell_size
89
105
  )
90
-
91
- points = [Point(x, y) for x, y in zip(x.flatten(), y.flatten(), strict=True)]
92
-
93
- # Create a mask to identify points for which distance should be calculated
94
- if extent_polygon is not None:
95
106
  distance_extent = extent_polygon.difference(polygon)
96
107
  else:
97
- distance_extent = snap_raster.bbox.difference(polygon)
108
+ raise AssertionError
98
109
 
99
- if show_pbar:
100
- _pbar = partial(tqdm, desc="Finding points within extent")
101
- mask = [distance_extent.intersects(point) for point in _pbar(points)]
110
+ pts = [Point(x, y) for x, y in zip(x.flatten(), y.flatten(), strict=True)]
102
111
 
103
- if show_pbar:
104
- _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
- )
112
+ _pts = _pbar(pts, desc="Finding points within extent") if show_pbar else pts
113
+ mask = [distance_extent.intersects(pt) for pt in _pts]
114
+
115
+ _pts = _pbar(pts, desc="Calculating distances") if show_pbar else pts
116
+ distances = np.where(mask, np.array([polygon.distance(pt) for pt in _pts]), np.nan)
108
117
  distance_raster = distances.reshape(x.shape)
109
118
 
110
119
  return RasterModel(arr=distance_raster, raster_meta=raster_meta)
111
120
 
112
121
 
122
+ def _pbar(iterable: Iterable[_T], *, desc: str | None = None) -> Iterable[_T]:
123
+ from tqdm import tqdm
124
+
125
+ return tqdm(iterable, desc=desc)
126
+
127
+
113
128
  def full_raster(
114
129
  raster_meta: RasterMeta,
115
130
  *,
@@ -185,7 +200,8 @@ def rasterize_gdf(
185
200
  shapes,
186
201
  out_shape=shape,
187
202
  transform=transform,
188
- fill=np.nan, # Fill gaps with NaN
203
+ # Fill gaps with NaN
204
+ fill=np.nan, # type: ignore[reportArgumentType] docstring contradicts inferred annotation
189
205
  dtype=np.float32,
190
206
  )
191
207
 
@@ -222,6 +238,8 @@ def _validate_columns_numeric(gdf: gpd.GeoDataFrame, target_cols: list[str]) ->
222
238
  Raises:
223
239
  NonNumericColumnsError: If any columns contain non-numeric data.
224
240
  """
241
+ import pandas as pd
242
+
225
243
  non_numeric_cols = []
226
244
  for col in target_cols:
227
245
  if not pd.api.types.is_numeric_dtype(gdf[col]):
@@ -259,3 +277,105 @@ def _validate_no_overlapping_geometries(gdf: gpd.GeoDataFrame) -> None:
259
277
  "Overlapping geometries can lead to data loss during rasterization."
260
278
  )
261
279
  raise OverlappingGeometriesError(msg)
280
+
281
+
282
+ def raster_from_point_cloud(
283
+ x: ArrayLike,
284
+ y: ArrayLike,
285
+ z: ArrayLike,
286
+ *,
287
+ crs: CRS | str,
288
+ cell_size: float | None = None,
289
+ ) -> RasterModel:
290
+ """Create a raster from a point cloud via interpolation.
291
+
292
+ Interpolation is only possible within the convex hull of the points. Outside of
293
+ this, cells will be NaN-valued.
294
+
295
+ All (x,y) points must be unique.
296
+
297
+ Args:
298
+ x: X coordinates of points.
299
+ y: Y coordinates of points.
300
+ z: Values at each (x, y) point to assign the raster.
301
+ crs: Coordinate reference system for the (x, y) coordinates.
302
+ cell_size: Desired cell size for the raster. If None, a heuristic is used based
303
+ on the spacing between (x, y) points.
304
+
305
+ Returns:
306
+ Raster containing the interpolated values.
307
+
308
+ Raises:
309
+ ValueError: If any (x, y) points are duplicated, or if they are all collinear.
310
+ """
311
+ from scipy.interpolate import LinearNDInterpolator
312
+ from scipy.spatial import KDTree, QhullError
313
+
314
+ x = np.asarray(x).ravel()
315
+ y = np.asarray(y).ravel()
316
+ z = np.asarray(z).ravel()
317
+ crs = CRS.from_user_input(crs)
318
+
319
+ # Validate input arrays
320
+ if len(x) != len(y) or len(x) != len(z):
321
+ msg = "Length of x, y, and z must be equal."
322
+ raise ValueError(msg)
323
+ if len(x) < 3:
324
+ msg = "At least three (x, y, z) points are required to triangulate a surface."
325
+ raise ValueError(msg)
326
+ # Check for duplicate (x, y) points
327
+ xy_points = np.column_stack((x, y))
328
+ if len(xy_points) != len(np.unique(xy_points, axis=0)):
329
+ msg = "Duplicate (x, y) points found. Each (x, y) point must be unique."
330
+ raise ValueError(msg)
331
+
332
+ # Heuristic for cell size if not provided
333
+ if cell_size is None:
334
+ # Half the 5th percentile of nearest neighbor distances between the (x,y) points
335
+ tree = KDTree(np.column_stack((x, y)))
336
+ distances, _ = tree.query(np.column_stack((x, y)), k=2)
337
+ distances: np.ndarray
338
+ cell_size = float(np.percentile(distances[distances > 0], 5)) / 2
339
+
340
+ # Compute bounds from data
341
+ minx, miny, maxx, maxy = np.min(x), np.min(y), np.max(x), np.max(y)
342
+
343
+ # Compute grid shape
344
+ width = int(np.ceil((maxx - minx) / cell_size))
345
+ height = int(np.ceil((maxy - miny) / cell_size))
346
+ shape = (height, width)
347
+
348
+ # Compute transform: upper left corner is (minx, maxy)
349
+ transform = Affine.translation(minx, maxy) * Affine.scale(cell_size, -cell_size)
350
+
351
+ # Create grid coordinates for raster cells
352
+ rows, cols = np.indices(shape)
353
+ xs, ys = rasterio.transform.xy(
354
+ transform=transform, rows=rows, cols=cols, offset="center"
355
+ )
356
+ grid_x = np.array(xs).ravel()
357
+ grid_y = np.array(ys).ravel()
358
+
359
+ # Perform interpolation
360
+ try:
361
+ interpolator = LinearNDInterpolator(
362
+ points=xy_points, values=z, fill_value=np.nan
363
+ )
364
+ except QhullError as err:
365
+ msg = (
366
+ "Failed to interpolate. This may be due to insufficient or "
367
+ "degenerate input points. Ensure that the (x, y) points are not all "
368
+ "collinear (i.e. that the convex hull is non-degenerate)."
369
+ )
370
+ raise ValueError(msg) from err
371
+
372
+ grid_values = np.array(interpolator(np.column_stack((grid_x, grid_y))))
373
+
374
+ arr = grid_values.reshape(shape).astype(np.float32)
375
+
376
+ raster_meta = RasterMeta(
377
+ cell_size=cell_size,
378
+ crs=crs,
379
+ transform=transform,
380
+ )
381
+ return RasterModel(arr=arr, raster_meta=raster_meta)
rastr/gis/fishnet.py CHANGED
@@ -1,12 +1,18 @@
1
- import geopandas as gpd
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
2
5
  import numpy as np
3
- from geopandas.array import GeometryArray
4
6
  from shapely import BufferCapStyle, BufferJoinStyle
5
7
 
8
+ if TYPE_CHECKING:
9
+ from geopandas.array import GeometryArray
10
+ from numpy.typing import NDArray
11
+
6
12
 
7
13
  def create_point_grid(
8
14
  *, bounds: tuple[float, float, float, float], cell_size: float
9
- ) -> tuple[np.ndarray, np.ndarray]:
15
+ ) -> tuple[NDArray, NDArray]:
10
16
  """Create a regular grid of point coordinates for raster centers.
11
17
 
12
18
  This function replicates the original grid generation logic that uses
@@ -25,7 +31,8 @@ def create_point_grid(
25
31
  x_coords = np.arange(xmin + cell_size / 2, xmax + cell_size / 2, cell_size)
26
32
  y_coords = np.arange(ymax - cell_size / 2, ymin - cell_size / 2, -cell_size)
27
33
 
28
- return np.meshgrid(x_coords, y_coords)
34
+ x_points, y_points = np.meshgrid(x_coords, y_coords) # type: ignore[reportAssignmentType]
35
+ return x_points, y_points
29
36
 
30
37
 
31
38
  def get_point_grid_shape(
@@ -58,6 +65,8 @@ def create_fishnet(
58
65
  Returns:
59
66
  Shapely Polygons.
60
67
  """
68
+ import geopandas as gpd
69
+
61
70
  # Use the shared helper function to create the point grid
62
71
  xx, yy = create_point_grid(bounds=bounds, cell_size=res)
63
72
 
rastr/gis/smooth.py CHANGED
@@ -5,12 +5,15 @@ Fork + Port of <https://github.com/philipschall/shapelysmooth> (Public domain)
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- from typing import TypeAlias
8
+ from typing import TYPE_CHECKING, TypeAlias
9
9
 
10
10
  import numpy as np
11
11
  from shapely.geometry import LineString, Polygon
12
12
  from typing_extensions import assert_never
13
13
 
14
+ if TYPE_CHECKING:
15
+ from numpy.typing import NDArray
16
+
14
17
  T: TypeAlias = LineString | Polygon
15
18
 
16
19
 
@@ -46,7 +49,7 @@ def catmull_rom_smooth(geometry: T, alpha: float = 0.5, subdivs: int = 10) -> T:
46
49
 
47
50
 
48
51
  def _catmull_rom(
49
- coords: np.ndarray,
52
+ coords: NDArray,
50
53
  *,
51
54
  alpha: float = 0.5,
52
55
  subdivs: int = 8,
@@ -89,7 +92,7 @@ def _catmull_rom(
89
92
 
90
93
 
91
94
  def _recursive_eval(
92
- slice4: np.ndarray, tangents: list[float], ts: np.ndarray
95
+ slice4: NDArray, tangents: list[float], ts: NDArray
93
96
  ) -> list[tuple[float, float]]:
94
97
  """De Boor/De Casteljau-style recursive linear interpolation over 4 control points.
95
98
 
@@ -128,7 +131,7 @@ def _recursive_eval(
128
131
 
129
132
  def _get_coords(
130
133
  geometry: LineString | Polygon,
131
- ) -> tuple[np.ndarray, list[np.ndarray]]:
134
+ ) -> tuple[NDArray, list[NDArray]]:
132
135
  if isinstance(geometry, LineString):
133
136
  return np.array(geometry.coords), []
134
137
  elif isinstance(geometry, Polygon):
rastr/io.py CHANGED
@@ -1,16 +1,26 @@
1
+ from __future__ import annotations
2
+
1
3
  from pathlib import Path
4
+ from typing import TYPE_CHECKING
2
5
 
3
6
  import numpy as np
4
7
  import rasterio
5
- from numpy.typing import NDArray
6
- from pyproj.crs import CRS
8
+ import rasterio.merge
9
+ from pyproj.crs.crs import CRS
7
10
 
8
11
  from rastr.meta import RasterMeta
9
12
  from rastr.raster import RasterModel
10
13
 
14
+ if TYPE_CHECKING:
15
+ from numpy.typing import NDArray
16
+
11
17
 
12
- def read_raster_inmem(raster_path: Path | str, crs: CRS | None = None) -> RasterModel:
18
+ def read_raster_inmem(
19
+ raster_path: Path | str, *, crs: CRS | str | None = None
20
+ ) -> RasterModel:
13
21
  """Read raster data from a file and return an in-memory Raster object."""
22
+ crs = CRS.from_user_input(crs) if crs is not None else None
23
+
14
24
  with rasterio.open(raster_path, mode="r") as dst:
15
25
  # Read the entire array
16
26
  arr: NDArray[np.float64] = dst.read()
@@ -27,3 +37,58 @@ def read_raster_inmem(raster_path: Path | str, crs: CRS | None = None) -> Raster
27
37
  raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
28
38
  raster_obj = RasterModel(arr=arr, raster_meta=raster_meta)
29
39
  return raster_obj
40
+
41
+
42
+ def read_raster_mosaic_inmem(
43
+ mosaic_dir: Path | str, *, glob: str = "*.tif", crs: CRS | None = None
44
+ ) -> RasterModel:
45
+ """Read a raster mosaic from a directory and return an in-memory Raster object.
46
+
47
+ This assumes that all rasters have the same metadata, e.g. coordinate system,
48
+ cell size, etc.
49
+ """
50
+ mosaic_dir = Path(mosaic_dir)
51
+ raster_paths = list(mosaic_dir.glob(glob))
52
+ if not raster_paths:
53
+ msg = f"No raster files found in {mosaic_dir} matching {glob}"
54
+ raise FileNotFoundError(msg)
55
+
56
+ # Sort raster_paths in alphabetical order by stem
57
+ raster_paths.sort(key=lambda p: p.stem)
58
+
59
+ # Open all TIFF datasets using context managers to ensure proper closure
60
+ sources = []
61
+ try:
62
+ for raster_path in raster_paths:
63
+ src = rasterio.open(raster_path)
64
+ sources.append(src)
65
+
66
+ # Merge into a single mosaic array & transform
67
+ arr, transform = rasterio.merge.merge(sources)
68
+
69
+ # Copy metadata from the first dataset
70
+ out_meta = sources[0].meta.copy()
71
+ out_meta.update(
72
+ {
73
+ "driver": "GTiff",
74
+ "height": arr.shape[1],
75
+ "width": arr.shape[2],
76
+ "transform": transform,
77
+ }
78
+ )
79
+ cell_size = sources[0].res[0]
80
+ if crs is None:
81
+ crs = CRS.from_user_input(sources[0].crs)
82
+
83
+ nodata = sources[0].nodata
84
+ if nodata is not None:
85
+ arr[arr == nodata] = np.nan
86
+
87
+ arr = arr.squeeze().astype(np.float64)
88
+
89
+ raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
90
+ raster_obj = RasterModel(arr=arr, raster_meta=raster_meta)
91
+ return raster_obj
92
+ finally:
93
+ for src in sources:
94
+ src.close()
rastr/meta.py CHANGED
@@ -1,8 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
1
5
  import numpy as np
2
6
  from affine import Affine
3
7
  from pydantic import BaseModel, InstanceOf
4
8
  from pyproj import CRS
5
- from typing_extensions import Self
9
+
10
+ if TYPE_CHECKING:
11
+ from numpy.typing import NDArray
12
+ from typing_extensions import Self
6
13
 
7
14
 
8
15
  class RasterMeta(BaseModel, extra="forbid"):
@@ -28,7 +35,7 @@ class RasterMeta(BaseModel, extra="forbid"):
28
35
  transform=Affine.scale(2.0, 2.0),
29
36
  )
30
37
 
31
- def get_cell_centre_coords(self, shape: tuple[int, int]) -> np.ndarray:
38
+ def get_cell_centre_coords(self, shape: tuple[int, int]) -> NDArray:
32
39
  """Return an array of (x, y) coordinates for the center of each cell.
33
40
 
34
41
  The coordinates will be in the coordinate system defined by the
@@ -38,13 +45,43 @@ class RasterMeta(BaseModel, extra="forbid"):
38
45
  shape: (rows, cols) of the raster array.
39
46
 
40
47
  Returns:
41
- np.ndarray of shape (rows, cols, 2) with (x, y) coordinates for each
42
- cell center.
48
+ (x, y) coordinates for each cell center, with shape (rows, cols, 2)
43
49
  """
44
- rows, cols = shape
45
- x_idx = np.arange(cols)
46
- y_idx = np.arange(rows)
47
- xv, yv = np.meshgrid(x_idx, y_idx)
48
- x_coords, y_coords = self.transform * (xv + 0.5, yv + 0.5)
49
- coords = np.stack([x_coords, y_coords], axis=-1)
50
+ x_coords = self.get_cell_x_coords(shape[1]) # cols for x-coordinates
51
+ y_coords = self.get_cell_y_coords(shape[0]) # rows for y-coordinates
52
+ coords = np.stack(np.meshgrid(x_coords, y_coords), axis=-1)
50
53
  return coords
54
+
55
+ def get_cell_x_coords(self, n_columns: int) -> NDArray:
56
+ """Return an array of x coordinates for the center of each cell.
57
+
58
+ The coordinates will be in the coordinate system defined by the
59
+ raster's transform.
60
+
61
+ Args:
62
+ n_columns: Number of columns in the raster array.
63
+
64
+ Returns:
65
+ x_coordinates at cell centers, with shape (n_columns,)
66
+ """
67
+ x_idx = np.arange(n_columns) + 0.5
68
+ y_idx = np.zeros_like(x_idx) # Use y=0 for a single row
69
+ x_coords, _ = self.transform * (x_idx, y_idx) # type: ignore[reportAssignmentType] overloaded tuple size in affine
70
+ return x_coords
71
+
72
+ def get_cell_y_coords(self, n_rows: int) -> NDArray:
73
+ """Return an array of y coordinates for the center of each cell.
74
+
75
+ The coordinates will be in the coordinate system defined by the
76
+ raster's transform.
77
+
78
+ Args:
79
+ n_rows: Number of rows in the raster array.
80
+
81
+ Returns:
82
+ y_coordinates at cell centers, with shape (n_rows,)
83
+ """
84
+ x_idx = np.zeros(n_rows) # Use x=0 for a single column
85
+ y_idx = np.arange(n_rows) + 0.5
86
+ _, y_coords = self.transform * (x_idx, y_idx) # type: ignore[reportAssignmentType] overloaded tuple size in affine
87
+ return y_coords
rastr/raster.py CHANGED
@@ -1,30 +1,24 @@
1
1
  """Raster data structure."""
2
2
 
3
- from collections.abc import Callable, Generator
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import warnings
4
7
  from contextlib import contextmanager
5
8
  from pathlib import Path
6
- from typing import TYPE_CHECKING, Literal
9
+ from typing import TYPE_CHECKING, Any, Literal
7
10
 
8
- import geopandas as gpd
9
- import matplotlib as mpl
10
11
  import numpy as np
11
- import pandas as pd
12
+ import numpy.ma
12
13
  import rasterio.plot
13
14
  import rasterio.sample
14
15
  import rasterio.transform
15
16
  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
17
  from pydantic import BaseModel, InstanceOf, field_validator
22
18
  from pyproj.crs.crs import CRS
23
19
  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
20
+ from rasterio.io import MemoryFile
21
+ from shapely.geometry import LineString, Point, Polygon
28
22
 
29
23
  from rastr.arr.fill import fillna_nearest_neighbours
30
24
  from rastr.gis.fishnet import create_fishnet
@@ -32,16 +26,14 @@ from rastr.gis.smooth import catmull_rom_smooth
32
26
  from rastr.meta import RasterMeta
33
27
 
34
28
  if TYPE_CHECKING:
35
- from folium import Map
29
+ from collections.abc import Callable, Generator
36
30
 
37
- try:
38
- import folium
39
- import folium.raster_layers
31
+ import geopandas as gpd
40
32
  from folium import Map
41
- except ImportError:
42
- FOLIUM_INSTALLED = False
43
- else:
44
- FOLIUM_INSTALLED = True
33
+ from matplotlib.axes import Axes
34
+ from numpy.typing import ArrayLike, NDArray
35
+ from rasterio.io import BufferedDatasetWriter, DatasetReader, DatasetWriter
36
+ from typing_extensions import Self
45
37
 
46
38
  try:
47
39
  from rasterio._err import CPLE_BaseError
@@ -49,7 +41,9 @@ except ImportError:
49
41
  CPLE_BaseError = Exception # Fallback if private module import fails
50
42
 
51
43
 
52
- CTX_BASEMAP_SOURCE = xyz.Esri.WorldImagery # pyright: ignore[reportAttributeAccessIssue]
44
+ FOLIUM_INSTALLED = importlib.util.find_spec("folium") is not None
45
+ BRANCA_INSTALLED = importlib.util.find_spec("branca") is not None
46
+ MATPLOTLIB_INSTALLED = importlib.util.find_spec("matplotlib") is not None
53
47
 
54
48
 
55
49
  class RasterCellArrayShapeError(ValueError):
@@ -62,6 +56,42 @@ class RasterModel(BaseModel):
62
56
  arr: InstanceOf[np.ndarray]
63
57
  raster_meta: RasterMeta
64
58
 
59
+ @property
60
+ def meta(self) -> RasterMeta:
61
+ """Alias for raster_meta."""
62
+ return self.raster_meta
63
+
64
+ @meta.setter
65
+ def meta(self, value: RasterMeta) -> None:
66
+ self.raster_meta = value
67
+
68
+ def __init__(
69
+ self,
70
+ *,
71
+ arr: ArrayLike,
72
+ meta: RasterMeta | None = None,
73
+ raster_meta: RasterMeta | None = None,
74
+ ) -> None:
75
+ arr = np.asarray(arr)
76
+
77
+ # Set the meta
78
+ if meta is not None and raster_meta is not None:
79
+ msg = (
80
+ "Only one of 'meta' or 'raster_meta' should be provided, they are "
81
+ "aliases."
82
+ )
83
+ raise ValueError(msg)
84
+ elif meta is not None and raster_meta is None:
85
+ raster_meta = meta
86
+ elif meta is None and raster_meta is not None:
87
+ pass
88
+ else:
89
+ # Don't need to mention `'meta'` to simplify the messaging.
90
+ msg = "The attribute 'raster_meta' is required."
91
+ raise ValueError(msg)
92
+
93
+ super().__init__(arr=arr, raster_meta=raster_meta)
94
+
65
95
  def __eq__(self, other: object) -> bool:
66
96
  """Check equality of two RasterModel objects."""
67
97
  if not isinstance(other, RasterModel):
@@ -160,6 +190,16 @@ class RasterModel(BaseModel):
160
190
  """Get the coordinates of the cell centres in the raster."""
161
191
  return self.raster_meta.get_cell_centre_coords(self.arr.shape)
162
192
 
193
+ @property
194
+ def cell_x_coords(self) -> NDArray[np.float64]:
195
+ """Get the x coordinates of the cell centres in the raster."""
196
+ return self.raster_meta.get_cell_x_coords(self.arr.shape[0])
197
+
198
+ @property
199
+ def cell_y_coords(self) -> NDArray[np.float64]:
200
+ """Get the y coordinates of the cell centres in the raster."""
201
+ return self.raster_meta.get_cell_y_coords(self.arr.shape[1])
202
+
163
203
  @contextmanager
164
204
  def to_rasterio_dataset(
165
205
  self,
@@ -195,14 +235,15 @@ class RasterModel(BaseModel):
195
235
 
196
236
  def sample(
197
237
  self,
198
- xy: list[tuple[float, float]],
238
+ xy: list[tuple[float, float]] | list[Point] | ArrayLike,
199
239
  *,
200
240
  na_action: Literal["raise", "ignore"] = "raise",
201
241
  ) -> NDArray[np.float64]:
202
242
  """Sample raster values at GeoSeries locations and return sampled values.
203
243
 
204
244
  Args:
205
- xy: A list of (x, y) coordinates to sample the raster at.
245
+ xy: A list of (x, y) coordinates or shapely Point objects to sample the
246
+ raster at.
206
247
  na_action: Action to take when a NaN value is encountered in the input xy.
207
248
  Options are "raise" (raise an error) or "ignore" (replace with
208
249
  NaN).
@@ -213,6 +254,12 @@ class RasterModel(BaseModel):
213
254
  # If this function is too slow, consider the optimizations detailed here:
214
255
  # https://rdrn.me/optimising-sampling/
215
256
 
257
+ # Convert shapely Points to coordinate tuples if needed
258
+ if isinstance(xy, (list, tuple)):
259
+ xy = [_get_xy_tuple(point) for point in xy]
260
+
261
+ xy = np.asarray(xy, dtype=float)
262
+
216
263
  # Short-circuit
217
264
  if len(xy) == 0:
218
265
  return np.array([], dtype=float)
@@ -249,7 +296,7 @@ class RasterModel(BaseModel):
249
296
 
250
297
  # Convert the sampled values to a NumPy array and set masked values to NaN
251
298
  raster_values = np.array(
252
- [s.data[0] if not s.mask else np.nan for s in samples]
299
+ [s.data[0] if not numpy.ma.getmask(s) else np.nan for s in samples]
253
300
  ).astype(float)
254
301
 
255
302
  if len(xy_nan_idxs) > 0:
@@ -298,19 +345,25 @@ class RasterModel(BaseModel):
298
345
  m: Map | None = None,
299
346
  opacity: float = 1.0,
300
347
  colormap: str = "viridis",
348
+ cbar_label: str | None = None,
301
349
  ) -> Map:
302
350
  """Display the raster on a folium map."""
303
- if not FOLIUM_INSTALLED:
304
- msg = "The 'folium' package is required for 'explore()'."
351
+ if not FOLIUM_INSTALLED or not MATPLOTLIB_INSTALLED:
352
+ msg = "The 'folium' and 'matplotlib' packages are required for 'explore()'."
305
353
  raise ImportError(msg)
306
354
 
355
+ import folium.raster_layers
356
+ import geopandas as gpd
357
+ import matplotlib as mpl
358
+
307
359
  if m is None:
308
360
  m = folium.Map()
309
361
 
310
- rbga_map: Callable[[float], tuple[float, float, float, float]] = mpl.colormaps[
362
+ rgba_map: Callable[[float], tuple[float, float, float, float]] = mpl.colormaps[
311
363
  colormap
312
364
  ]
313
365
 
366
+ # Cast to GDF to facilitate converting bounds to WGS84
314
367
  wgs84_crs = CRS.from_epsg(4326)
315
368
  gdf = gpd.GeoDataFrame(geometry=[self.bbox], crs=self.raster_meta.crs).to_crs(
316
369
  wgs84_crs
@@ -320,8 +373,15 @@ class RasterModel(BaseModel):
320
373
  arr = np.array(self.arr)
321
374
 
322
375
  # Normalize the data to the range [0, 1] as this is the cmap range
323
- min_val = np.nanmin(arr)
324
- max_val = np.nanmax(arr)
376
+ with warnings.catch_warnings():
377
+ warnings.filterwarnings(
378
+ "ignore",
379
+ message="All-NaN slice encountered",
380
+ category=RuntimeWarning,
381
+ )
382
+ min_val = np.nanmin(arr)
383
+ max_val = np.nanmax(arr)
384
+
325
385
  if max_val > min_val: # Prevent division by zero
326
386
  arr = (arr - min_val) / (max_val - min_val)
327
387
  else:
@@ -332,26 +392,46 @@ class RasterModel(BaseModel):
332
392
  flip_x = self.raster_meta.transform.a < 0
333
393
  flip_y = self.raster_meta.transform.e > 0
334
394
  if flip_x:
335
- arr = np.flip(self.arr, axis=1)
395
+ arr = np.flip(arr, axis=1)
336
396
  if flip_y:
337
- arr = np.flip(self.arr, axis=0)
397
+ arr = np.flip(arr, axis=0)
338
398
 
339
399
  img = folium.raster_layers.ImageOverlay(
340
400
  image=arr,
341
401
  bounds=[[ymin, xmin], [ymax, xmax]],
342
402
  opacity=opacity,
343
- colormap=rbga_map,
403
+ colormap=rgba_map,
344
404
  mercator_project=True,
345
405
  )
346
406
 
347
407
  img.add_to(m)
348
408
 
409
+ # Add a colorbar legend
410
+ if BRANCA_INSTALLED:
411
+ from branca.colormap import LinearColormap as BrancaLinearColormap
412
+ from matplotlib.colors import to_hex
413
+
414
+ # Determine legend data range in original units
415
+ vmin = float(min_val) if np.isfinite(min_val) else 0.0
416
+ vmax = float(max_val) if np.isfinite(max_val) else 1.0
417
+ if vmax <= vmin:
418
+ vmax = vmin + 1.0
419
+
420
+ sample_points = np.linspace(0, 1, rgba_map.N)
421
+ colors = [to_hex(rgba_map(x)) for x in sample_points]
422
+ legend = BrancaLinearColormap(colors=colors, vmin=vmin, vmax=vmax)
423
+ if cbar_label:
424
+ legend.caption = cbar_label
425
+ legend.add_to(m)
426
+
349
427
  m.fit_bounds([[ymin, xmin], [ymax, xmax]])
350
428
 
351
429
  return m
352
430
 
353
431
  def to_clipboard(self) -> None:
354
432
  """Copy the raster cell array to the clipboard."""
433
+ import pandas as pd
434
+
355
435
  pd.DataFrame(self.arr).to_clipboard(index=False, header=False)
356
436
 
357
437
  def plot(
@@ -363,9 +443,17 @@ class RasterModel(BaseModel):
363
443
  cmap: str = "viridis",
364
444
  ) -> Axes:
365
445
  """Plot the raster on a matplotlib axis."""
446
+ if not MATPLOTLIB_INSTALLED:
447
+ msg = "The 'matplotlib' package is required for 'plot()'."
448
+ raise ImportError(msg)
449
+
450
+ from matplotlib import pyplot as plt
451
+ from mpl_toolkits.axes_grid1 import make_axes_locatable
452
+
366
453
  if ax is None:
367
- _, ax = plt.subplots()
368
- ax: Axes
454
+ _, _ax = plt.subplots()
455
+ _ax: Axes
456
+ ax = _ax
369
457
 
370
458
  if basemap:
371
459
  msg = "Basemap plotting is not yet implemented."
@@ -387,8 +475,8 @@ class RasterModel(BaseModel):
387
475
  max_y_nonzero = np.max(y_nonzero)
388
476
 
389
477
  # Transform to raster CRS
390
- x1, y1 = self.raster_meta.transform * (min_x_nonzero, min_y_nonzero)
391
- x2, y2 = self.raster_meta.transform * (max_x_nonzero, max_y_nonzero)
478
+ x1, y1 = self.raster_meta.transform * (min_x_nonzero, min_y_nonzero) # type: ignore[reportAssignmentType] overloaded tuple size in affine
479
+ x2, y2 = self.raster_meta.transform * (max_x_nonzero, max_y_nonzero) # type: ignore[reportAssignmentType]
392
480
  xmin, xmax = sorted([x1, x2])
393
481
  ymin, ymax = sorted([y1, y2])
394
482
 
@@ -409,11 +497,14 @@ class RasterModel(BaseModel):
409
497
  divider = make_axes_locatable(ax)
410
498
  cax = divider.append_axes("right", size="5%", pad=0.05)
411
499
  fig = ax.get_figure()
412
- fig.colorbar(img, label=cbar_label, cax=cax)
500
+ if fig is not None:
501
+ fig.colorbar(img, label=cbar_label, cax=cax)
413
502
  return ax
414
503
 
415
504
  def as_geodataframe(self, name: str = "value") -> gpd.GeoDataFrame:
416
505
  """Create a GeoDataFrame representation of the raster."""
506
+ import geopandas as gpd
507
+
417
508
  polygons = create_fishnet(bounds=self.bounds, res=self.raster_meta.cell_size)
418
509
  point_tuples = [polygon.centroid.coords[0] for polygon in polygons]
419
510
  raster_gdf = gpd.GeoDataFrame(
@@ -493,24 +584,22 @@ class RasterModel(BaseModel):
493
584
  return new_raster
494
585
 
495
586
  def get_xy(self) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
496
- """Get the x and y coordinates of the raster in meshgrid format."""
497
- col_idx, row_idx = np.meshgrid(
498
- np.arange(self.arr.shape[1]),
499
- np.arange(self.arr.shape[0]),
500
- )
587
+ """Get the x and y coordinates of the raster cell centres in meshgrid format.
501
588
 
502
- col_idx = col_idx.flatten()
503
- row_idx = row_idx.flatten()
589
+ Returns the coordinates of the cell centres as two separate 2D arrays in
590
+ meshgrid format, where each array has the same shape as the raster data array.
504
591
 
505
- coords = np.vstack((row_idx, col_idx)).T
506
-
507
- x, y = rasterio.transform.xy(self.raster_meta.transform, *coords.T)
508
- x = np.array(x).reshape(self.arr.shape)
509
- y = np.array(y).reshape(self.arr.shape)
510
- return x, y
592
+ Returns:
593
+ A tuple of (x, y) coordinate arrays where:
594
+ - x: 2D array of x-coordinates of cell centres
595
+ - y: 2D array of y-coordinates of cell centres
596
+ Both arrays have the same shape as the raster data array.
597
+ """
598
+ coords = self.raster_meta.get_cell_centre_coords(self.arr.shape)
599
+ return coords[:, :, 0], coords[:, :, 1]
511
600
 
512
601
  def contour(
513
- self, *, levels: list[float], smoothing: bool = True
602
+ self, *, levels: list[float] | NDArray, smoothing: bool = True
514
603
  ) -> gpd.GeoDataFrame:
515
604
  """Create contour lines from the raster data, optionally with smoothing.
516
605
 
@@ -521,13 +610,14 @@ class RasterModel(BaseModel):
521
610
  contouring, to denoise the contours.
522
611
 
523
612
  Args:
524
- levels: A list of contour levels to generate. The contour lines will be
525
- generated for each level in this list.
613
+ levels: A list or array of contour levels to generate. The contour lines
614
+ will be generated for each level in this sequence.
526
615
  smoothing: Defaults to true, which corresponds to applying a smoothing
527
616
  algorithm to the contour lines. At the moment, this is the
528
617
  Catmull-Rom spline algorithm. If set to False, the raw
529
618
  contours will be returned without any smoothing.
530
619
  """
620
+ import geopandas as gpd
531
621
 
532
622
  all_levels = []
533
623
  all_geoms = []
@@ -576,6 +666,7 @@ class RasterModel(BaseModel):
576
666
  coordinate distance (e.g. meters). A larger sigma results in a more
577
667
  blurred image.
578
668
  """
669
+ from scipy.ndimage import gaussian_filter
579
670
 
580
671
  cell_sigma = sigma / self.raster_meta.cell_size
581
672
 
@@ -605,6 +696,82 @@ class RasterModel(BaseModel):
605
696
 
606
697
  return raster
607
698
 
699
+ def crop(
700
+ self,
701
+ bounds: tuple[float, float, float, float],
702
+ strategy: Literal["underflow", "overflow"] = "underflow",
703
+ ) -> Self:
704
+ """Crop the raster to the specified bounds.
705
+
706
+ Args:
707
+ bounds: A tuple of (minx, miny, maxx, maxy) defining the bounds to crop to.
708
+ strategy: The cropping strategy to use. 'underflow' will crop the raster
709
+ to be fully within the bounds, ignoring any cells that are
710
+ partially outside the bounds. 'overflow' will instead include
711
+ cells that intersect the bounds, ensuring the bounds area
712
+ remains covered with cells.
713
+
714
+ Returns:
715
+ A new RasterModel instance cropped to the specified bounds.
716
+ """
717
+
718
+ minx, miny, maxx, maxy = bounds
719
+ arr = self.arr
720
+
721
+ # Get the half cell size for cropping
722
+ cell_size = self.raster_meta.cell_size
723
+ half_cell_size = cell_size / 2
724
+
725
+ # Get the cell centre coordinates as 1D arrays
726
+ x_coords = self.cell_x_coords
727
+ y_coords = self.cell_y_coords
728
+
729
+ # Get the indices to crop the array
730
+ if strategy == "underflow":
731
+ x_idx = (x_coords >= minx + half_cell_size) & (
732
+ x_coords <= maxx - half_cell_size
733
+ )
734
+ y_idx = (y_coords >= miny + half_cell_size) & (
735
+ y_coords <= maxy - half_cell_size
736
+ )
737
+ elif strategy == "overflow":
738
+ x_idx = (x_coords > minx - half_cell_size) & (
739
+ x_coords < maxx + half_cell_size
740
+ )
741
+ y_idx = (y_coords > miny - half_cell_size) & (
742
+ y_coords < maxy + half_cell_size
743
+ )
744
+ else:
745
+ msg = f"Unsupported cropping strategy: {strategy}"
746
+ raise NotImplementedError(msg)
747
+
748
+ # Crop the array
749
+ cropped_arr = arr[np.ix_(x_idx, y_idx)]
750
+
751
+ # Check the shape of the cropped array
752
+ if cropped_arr.size == 0:
753
+ msg = "Cropped array is empty; no cells within the specified bounds."
754
+ raise ValueError(msg)
755
+
756
+ # Recalculate the transform for the cropped raster
757
+ x_coords = x_coords[x_idx]
758
+ y_coords = y_coords[y_idx]
759
+ transform = rasterio.transform.from_bounds(
760
+ west=x_coords.min() - half_cell_size,
761
+ south=y_coords.min() - half_cell_size,
762
+ east=x_coords.max() + half_cell_size,
763
+ north=y_coords.max() + half_cell_size,
764
+ width=cropped_arr.shape[1],
765
+ height=cropped_arr.shape[0],
766
+ )
767
+
768
+ # Update the raster
769
+ cls = self.__class__
770
+ new_meta = RasterMeta(
771
+ cell_size=cell_size, crs=self.raster_meta.crs, transform=transform
772
+ )
773
+ return cls(arr=cropped_arr, raster_meta=new_meta)
774
+
608
775
  def resample(
609
776
  self, new_cell_size: float, *, method: Literal["bilinear"] = "bilinear"
610
777
  ) -> Self:
@@ -654,9 +821,24 @@ class RasterModel(BaseModel):
654
821
 
655
822
  @field_validator("arr")
656
823
  @classmethod
657
- def check_2d_array(cls, v: np.ndarray) -> np.ndarray:
824
+ def check_2d_array(cls, v: NDArray) -> NDArray:
658
825
  """Validator to ensure the cell array is 2D."""
659
826
  if v.ndim != 2:
660
827
  msg = "Cell array must be 2D"
661
828
  raise RasterCellArrayShapeError(msg)
662
829
  return v
830
+
831
+
832
+ def _get_xy_tuple(xy: Any) -> tuple[float, float]:
833
+ """Convert Point or coordinate tuple to coordinate tuple.
834
+
835
+ Args:
836
+ xy: Either a coordinate tuple or a shapely Point object.
837
+
838
+ Returns:
839
+ A coordinate tuple (x, y).
840
+ """
841
+ if isinstance(xy, Point):
842
+ return (xy.x, xy.y)
843
+ x, y = xy
844
+ return (float(x), float(y))
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: rastr
3
+ Version: 0.3.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/46f802e7abd275eff61c73c5edc147d92966c886.zip
9
+ Author-email: Tonkin & Taylor Limited <Sub-DisciplineData+AnalyticsStaff@tonkintaylor.co.nz>, Nathan McDougall <nmcdougall@tonkintaylor.co.nz>, Ben Karl <bkarl@tonkintaylor.co.nz>
10
+ 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: branca>=0.8.1
20
+ Requires-Dist: folium>=0.20.0
21
+ Requires-Dist: geopandas>=1.1.1
22
+ Requires-Dist: matplotlib>=3.10.5
23
+ Requires-Dist: numpy>=2.2.6
24
+ Requires-Dist: pandas>=2.3.1
25
+ Requires-Dist: pydantic>=2.11.7
26
+ Requires-Dist: pyproj>=3.7.1
27
+ Requires-Dist: rasterio>=1.4.3
28
+ Requires-Dist: scikit-image>=0.25.2
29
+ Requires-Dist: scipy>=1.15.3
30
+ Requires-Dist: shapely>=2.1.1
31
+ Requires-Dist: tqdm>=4.67.1
32
+ Requires-Dist: typing-extensions>=4.14.1
33
+ Description-Content-Type: text/markdown
34
+
35
+ <h1 align="center">
36
+ <img src="https://raw.githubusercontent.com/tonkintaylor/rastr/refs/heads/develop/docs/logo.svg"><br>
37
+ </h1>
38
+
39
+ # rastr
40
+
41
+ [![PyPI Version](https://img.shields.io/pypi/v/rastr.svg)](<https://pypi.python.org/pypi/rastr>)
42
+ [![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)
43
+ [![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)
44
+ [![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)
45
+
46
+ A lightweight geospatial raster datatype library for Python focused on simplicity.
47
+
48
+ ## Overview
49
+
50
+ `rastr` provides an intuitive interface for creating, reading, manipulating, and exporting geospatial raster data in Python.
51
+
52
+ ### Features
53
+
54
+ - 🧮 **Complete raster arithmetic**: Full support for mathematical operations (`+`, `-`, `*`, `/`) between rasters and scalars.
55
+ - 📊 **Flexible visualization**: Built-in plotting with matplotlib and interactive mapping with folium.
56
+ - 🗺️ **Geospatial analysis tools**: Contour generation, Gaussian blurring, and spatial sampling.
57
+ - 🛠️ **Data manipulation**: Fill NaN values, extrapolate missing data, and resample to different resolutions.
58
+ - 🔗 **Seamless integration**: Works with GeoPandas, rasterio, and the broader Python geospatial ecosystem.
59
+ - ↔️ **Vector-to-raster workflows**: Convert GeoDataFrame polygons, points, and lines to raster format.
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ # With uv
65
+ uv add rastr
66
+
67
+ # With pip
68
+ pip install rastr
69
+ ```
70
+
71
+ ## Quick Start
72
+
73
+ ```python
74
+ from pyproj.crs.crs import CRS
75
+ from rasterio.transform import from_origin
76
+ from rastr.create import full_raster
77
+ from rastr.meta import RasterMeta
78
+ from rastr.raster import RasterModel
79
+
80
+ # Create an example raster
81
+ raster = RasterModel.example()
82
+
83
+ # Basic arithmetic operations
84
+ doubled = raster * 2
85
+ summed = raster + 10
86
+ combined = raster + doubled
87
+
88
+ # Create full rasters with specified values
89
+ cell_size = 1.0
90
+ empty_raster = full_raster(
91
+ RasterMeta(
92
+ cell_size=cell_size,
93
+ crs=CRS.from_epsg(2193),
94
+ transform=from_origin(0, 100, cell_size, cell_size),
95
+ ),
96
+ bounds=(0, 0, 100, 100),
97
+ fill_value=0.0,
98
+ )
99
+
100
+ # Visualize the data
101
+ ax = raster.plot(cbar_label="Values")
102
+
103
+ # Interactive web mapping (requires folium)
104
+ m = raster.explore(opacity=0.8, colormap="plasma")
105
+
106
+ # Sample values at specific coordinates
107
+ xy_points = [(100.0, 200.0), (150.0, 250.0)]
108
+ values = raster.sample(xy_points)
109
+
110
+ # Generate contour lines
111
+ contours = raster.contour(levels=[0.1, 0.5, 0.9], smoothing=True)
112
+
113
+ # Apply spatial operations
114
+ blurred = raster.blur(sigma=2.0) # Gaussian blur
115
+ filled = raster.extrapolate(method="nearest") # Fill NaN values via nearest-neighbours
116
+ resampled = raster.resample(new_cell_size=0.5) # Change resolution
117
+
118
+ # Export to file
119
+ raster.to_file("output.tif")
120
+
121
+ # Convert to GeoDataFrame for vector analysis
122
+ gdf = raster.as_geodataframe(name="elevation")
123
+ ```
124
+
125
+ ## Limitations
126
+
127
+ Current version limitations:
128
+
129
+ - Only Single-band rasters are supported.
130
+ - In-memory processing only (streaming support planned).
131
+ - Square cells only (rectangular cell support planned).
132
+ - Only float dtypes (integer support planned).
133
+
134
+ ### Contributing
135
+
136
+ See the
137
+ [CONTRIBUTING.md](https://github.com/usethis-python/usethis-python/blob/main/CONTRIBUTING.md)
138
+ file.
@@ -0,0 +1,15 @@
1
+ rastr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ rastr/_version.py,sha256=5zTqm8rgXsWYBpB2M3Zw_K1D-aV8wP7NsBLrmMKkrAQ,704
3
+ rastr/create.py,sha256=6aHpRFRXmpXzuzTt-SxY_BfVc7dXKCBLCArb7DjUrsM,13494
4
+ rastr/io.py,sha256=RgkiV_emOPjTFeI2a1aCBtfWwrSLH0XmP8rx9tu6PAI,2952
5
+ rastr/meta.py,sha256=5iDvGkYe8iMMkPV6gSL04jNcLRhuRNFqe9AppUpp55E,2928
6
+ rastr/raster.py,sha256=iHN8L91HW-cpDexZYgDnLaOw-N6KktQpXIusvUEiL0o,29931
7
+ rastr/arr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ rastr/arr/fill.py,sha256=80ucb36el9s042fDSwK1SZJhp_GNJNMM0fpQTWmJvgE,1001
9
+ rastr/gis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ rastr/gis/fishnet.py,sha256=Ic-0HV61ST8OxwhyoMyV_ybihs2xuhgAY-3n4CknAt8,2670
11
+ rastr/gis/smooth.py,sha256=3HQDQHQM5_LeNk21R8Eb8VpF727JcXq21HO9JMvcpW4,4810
12
+ rastr-0.3.0.dist-info/METADATA,sha256=pr4zdjI5FPlVynm2W09ErYddJ0Nmvx9wICWzBH2IpNI,4953
13
+ rastr-0.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ rastr-0.3.0.dist-info/licenses/LICENSE,sha256=7qUsx93G2ATTRLZiSYuQofwAX_uWvrqnAiMK8PzxvNc,1080
15
+ rastr-0.3.0.dist-info/RECORD,,
@@ -1,44 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: rastr
3
- Version: 0.2.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/551d36604b84f947fffbe328e9e877732c9e35fb.zip
9
- Author-email: Tonkin & Taylor Limited <Sub-DisciplineData+AnalyticsStaff@tonkintaylor.co.nz>, Nathan McDougall <nmcdougall@tonkintaylor.co.nz>, Ben Karl <bkarl@tonkintaylor.co.nz>
10
- 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, in-memory rasters with square cells are supported.
@@ -1,15 +0,0 @@
1
- rastr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- rastr/_version.py,sha256=iB5DfB5V6YB5Wo4JmvS-txT42QtmGaWcWp3udRT7zCI,511
3
- rastr/create.py,sha256=tHLVnGarMt04p1z8CVknMWMjQLKrb0WrcP_Wgdw8xr4,9346
4
- rastr/io.py,sha256=dKOY5JYQDULg3Si24cysVTZuWoPjftAE8pbxgSoN1tw,967
5
- rastr/meta.py,sha256=jmDDx3w61ZZ3dQSrMFgaBx0LJxYp42xTm_7uS_s96lg,1547
6
- rastr/raster.py,sha256=jGk9buG1uKQ3tb3ciI4yhw9JFu7oyXWXMnUBnPjOD54,23142
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.2.0.dist-info/METADATA,sha256=7uUi8aLMaEbxoPfk6go_szv3H_TnDGYGE4RSC_sbUqo,2191
13
- rastr-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
- rastr-0.2.0.dist-info/licenses/LICENSE,sha256=7qUsx93G2ATTRLZiSYuQofwAX_uWvrqnAiMK8PzxvNc,1080
15
- rastr-0.2.0.dist-info/RECORD,,
File without changes