rastr 0.2.0__py3-none-any.whl → 0.4.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.
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.4.0'
32
+ __version_tuple__ = version_tuple = (0, 4, 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, overload
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,57 @@ 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
+ @property
69
+ def shape(self) -> tuple[int, ...]:
70
+ """Shape of the raster array."""
71
+ return self.arr.shape
72
+
73
+ @property
74
+ def crs(self) -> CRS:
75
+ """Convenience property to access the CRS via meta."""
76
+ return self.meta.crs
77
+
78
+ @crs.setter
79
+ def crs(self, value: CRS) -> None:
80
+ """Set the CRS via meta."""
81
+ self.meta.crs = value
82
+
83
+ def __init__(
84
+ self,
85
+ *,
86
+ arr: ArrayLike,
87
+ meta: RasterMeta | None = None,
88
+ raster_meta: RasterMeta | None = None,
89
+ ) -> None:
90
+ arr = np.asarray(arr)
91
+
92
+ # Set the meta
93
+ if meta is not None and raster_meta is not None:
94
+ msg = (
95
+ "Only one of 'meta' or 'raster_meta' should be provided, they are "
96
+ "aliases."
97
+ )
98
+ raise ValueError(msg)
99
+ elif meta is not None and raster_meta is None:
100
+ raster_meta = meta
101
+ elif meta is None and raster_meta is not None:
102
+ pass
103
+ else:
104
+ # Don't need to mention `'meta'` to simplify the messaging.
105
+ msg = "The attribute 'raster_meta' is required."
106
+ raise ValueError(msg)
107
+
108
+ super().__init__(arr=arr, raster_meta=raster_meta)
109
+
65
110
  def __eq__(self, other: object) -> bool:
66
111
  """Check equality of two RasterModel objects."""
67
112
  if not isinstance(other, RasterModel):
@@ -160,6 +205,16 @@ class RasterModel(BaseModel):
160
205
  """Get the coordinates of the cell centres in the raster."""
161
206
  return self.raster_meta.get_cell_centre_coords(self.arr.shape)
162
207
 
208
+ @property
209
+ def cell_x_coords(self) -> NDArray[np.float64]:
210
+ """Get the x coordinates of the cell centres in the raster."""
211
+ return self.raster_meta.get_cell_x_coords(self.arr.shape[1])
212
+
213
+ @property
214
+ def cell_y_coords(self) -> NDArray[np.float64]:
215
+ """Get the y coordinates of the cell centres in the raster."""
216
+ return self.raster_meta.get_cell_y_coords(self.arr.shape[0])
217
+
163
218
  @contextmanager
164
219
  def to_rasterio_dataset(
165
220
  self,
@@ -195,14 +250,15 @@ class RasterModel(BaseModel):
195
250
 
196
251
  def sample(
197
252
  self,
198
- xy: list[tuple[float, float]],
253
+ xy: list[tuple[float, float]] | list[Point] | ArrayLike,
199
254
  *,
200
255
  na_action: Literal["raise", "ignore"] = "raise",
201
256
  ) -> NDArray[np.float64]:
202
257
  """Sample raster values at GeoSeries locations and return sampled values.
203
258
 
204
259
  Args:
205
- xy: A list of (x, y) coordinates to sample the raster at.
260
+ xy: A list of (x, y) coordinates or shapely Point objects to sample the
261
+ raster at.
206
262
  na_action: Action to take when a NaN value is encountered in the input xy.
207
263
  Options are "raise" (raise an error) or "ignore" (replace with
208
264
  NaN).
@@ -213,6 +269,12 @@ class RasterModel(BaseModel):
213
269
  # If this function is too slow, consider the optimizations detailed here:
214
270
  # https://rdrn.me/optimising-sampling/
215
271
 
272
+ # Convert shapely Points to coordinate tuples if needed
273
+ if isinstance(xy, (list, tuple)):
274
+ xy = [_get_xy_tuple(point) for point in xy]
275
+
276
+ xy = np.asarray(xy, dtype=float)
277
+
216
278
  # Short-circuit
217
279
  if len(xy) == 0:
218
280
  return np.array([], dtype=float)
@@ -249,7 +311,7 @@ class RasterModel(BaseModel):
249
311
 
250
312
  # Convert the sampled values to a NumPy array and set masked values to NaN
251
313
  raster_values = np.array(
252
- [s.data[0] if not s.mask else np.nan for s in samples]
314
+ [s.data[0] if not numpy.ma.getmask(s) else np.nan for s in samples]
253
315
  ).astype(float)
254
316
 
255
317
  if len(xy_nan_idxs) > 0:
@@ -298,19 +360,25 @@ class RasterModel(BaseModel):
298
360
  m: Map | None = None,
299
361
  opacity: float = 1.0,
300
362
  colormap: str = "viridis",
363
+ cbar_label: str | None = None,
301
364
  ) -> Map:
302
365
  """Display the raster on a folium map."""
303
- if not FOLIUM_INSTALLED:
304
- msg = "The 'folium' package is required for 'explore()'."
366
+ if not FOLIUM_INSTALLED or not MATPLOTLIB_INSTALLED:
367
+ msg = "The 'folium' and 'matplotlib' packages are required for 'explore()'."
305
368
  raise ImportError(msg)
306
369
 
370
+ import folium.raster_layers
371
+ import geopandas as gpd
372
+ import matplotlib as mpl
373
+
307
374
  if m is None:
308
375
  m = folium.Map()
309
376
 
310
- rbga_map: Callable[[float], tuple[float, float, float, float]] = mpl.colormaps[
377
+ rgba_map: Callable[[float], tuple[float, float, float, float]] = mpl.colormaps[
311
378
  colormap
312
379
  ]
313
380
 
381
+ # Cast to GDF to facilitate converting bounds to WGS84
314
382
  wgs84_crs = CRS.from_epsg(4326)
315
383
  gdf = gpd.GeoDataFrame(geometry=[self.bbox], crs=self.raster_meta.crs).to_crs(
316
384
  wgs84_crs
@@ -320,8 +388,15 @@ class RasterModel(BaseModel):
320
388
  arr = np.array(self.arr)
321
389
 
322
390
  # 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)
391
+ with warnings.catch_warnings():
392
+ warnings.filterwarnings(
393
+ "ignore",
394
+ message="All-NaN slice encountered",
395
+ category=RuntimeWarning,
396
+ )
397
+ min_val = np.nanmin(arr)
398
+ max_val = np.nanmax(arr)
399
+
325
400
  if max_val > min_val: # Prevent division by zero
326
401
  arr = (arr - min_val) / (max_val - min_val)
327
402
  else:
@@ -332,26 +407,46 @@ class RasterModel(BaseModel):
332
407
  flip_x = self.raster_meta.transform.a < 0
333
408
  flip_y = self.raster_meta.transform.e > 0
334
409
  if flip_x:
335
- arr = np.flip(self.arr, axis=1)
410
+ arr = np.flip(arr, axis=1)
336
411
  if flip_y:
337
- arr = np.flip(self.arr, axis=0)
412
+ arr = np.flip(arr, axis=0)
338
413
 
339
414
  img = folium.raster_layers.ImageOverlay(
340
415
  image=arr,
341
416
  bounds=[[ymin, xmin], [ymax, xmax]],
342
417
  opacity=opacity,
343
- colormap=rbga_map,
418
+ colormap=rgba_map,
344
419
  mercator_project=True,
345
420
  )
346
421
 
347
422
  img.add_to(m)
348
423
 
424
+ # Add a colorbar legend
425
+ if BRANCA_INSTALLED:
426
+ from branca.colormap import LinearColormap as BrancaLinearColormap
427
+ from matplotlib.colors import to_hex
428
+
429
+ # Determine legend data range in original units
430
+ vmin = float(min_val) if np.isfinite(min_val) else 0.0
431
+ vmax = float(max_val) if np.isfinite(max_val) else 1.0
432
+ if vmax <= vmin:
433
+ vmax = vmin + 1.0
434
+
435
+ sample_points = np.linspace(0, 1, rgba_map.N)
436
+ colors = [to_hex(rgba_map(x)) for x in sample_points]
437
+ legend = BrancaLinearColormap(colors=colors, vmin=vmin, vmax=vmax)
438
+ if cbar_label:
439
+ legend.caption = cbar_label
440
+ legend.add_to(m)
441
+
349
442
  m.fit_bounds([[ymin, xmin], [ymax, xmax]])
350
443
 
351
444
  return m
352
445
 
353
446
  def to_clipboard(self) -> None:
354
447
  """Copy the raster cell array to the clipboard."""
448
+ import pandas as pd
449
+
355
450
  pd.DataFrame(self.arr).to_clipboard(index=False, header=False)
356
451
 
357
452
  def plot(
@@ -363,9 +458,17 @@ class RasterModel(BaseModel):
363
458
  cmap: str = "viridis",
364
459
  ) -> Axes:
365
460
  """Plot the raster on a matplotlib axis."""
461
+ if not MATPLOTLIB_INSTALLED:
462
+ msg = "The 'matplotlib' package is required for 'plot()'."
463
+ raise ImportError(msg)
464
+
465
+ from matplotlib import pyplot as plt
466
+ from mpl_toolkits.axes_grid1 import make_axes_locatable
467
+
366
468
  if ax is None:
367
- _, ax = plt.subplots()
368
- ax: Axes
469
+ _, _ax = plt.subplots()
470
+ _ax: Axes
471
+ ax = _ax
369
472
 
370
473
  if basemap:
371
474
  msg = "Basemap plotting is not yet implemented."
@@ -387,8 +490,8 @@ class RasterModel(BaseModel):
387
490
  max_y_nonzero = np.max(y_nonzero)
388
491
 
389
492
  # 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)
493
+ x1, y1 = self.raster_meta.transform * (min_x_nonzero, min_y_nonzero) # type: ignore[reportAssignmentType] overloaded tuple size in affine
494
+ x2, y2 = self.raster_meta.transform * (max_x_nonzero, max_y_nonzero) # type: ignore[reportAssignmentType]
392
495
  xmin, xmax = sorted([x1, x2])
393
496
  ymin, ymax = sorted([y1, y2])
394
497
 
@@ -409,11 +512,14 @@ class RasterModel(BaseModel):
409
512
  divider = make_axes_locatable(ax)
410
513
  cax = divider.append_axes("right", size="5%", pad=0.05)
411
514
  fig = ax.get_figure()
412
- fig.colorbar(img, label=cbar_label, cax=cax)
515
+ if fig is not None:
516
+ fig.colorbar(img, label=cbar_label, cax=cax)
413
517
  return ax
414
518
 
415
519
  def as_geodataframe(self, name: str = "value") -> gpd.GeoDataFrame:
416
520
  """Create a GeoDataFrame representation of the raster."""
521
+ import geopandas as gpd
522
+
417
523
  polygons = create_fishnet(bounds=self.bounds, res=self.raster_meta.cell_size)
418
524
  point_tuples = [polygon.centroid.coords[0] for polygon in polygons]
419
525
  raster_gdf = gpd.GeoDataFrame(
@@ -482,6 +588,43 @@ class RasterModel(BaseModel):
482
588
  raster_meta = RasterMeta.example()
483
589
  return cls(arr=arr, raster_meta=raster_meta)
484
590
 
591
+ @overload
592
+ def apply(
593
+ self,
594
+ func: Callable[[np.ndarray], np.ndarray],
595
+ *,
596
+ raw: Literal[True],
597
+ ) -> Self: ...
598
+ @overload
599
+ def apply(
600
+ self,
601
+ func: Callable[[float], float] | Callable[[np.ndarray], np.ndarray],
602
+ *,
603
+ raw: Literal[False] = False,
604
+ ) -> Self: ...
605
+ def apply(self, func, *, raw=False) -> Self:
606
+ """Apply a function element-wise to the raster array.
607
+
608
+ Creates a new raster instance with the same metadata (CRS, transform, etc.)
609
+ but with the data array transformed by the provided function. The original
610
+ raster is not modified.
611
+
612
+ Args:
613
+ func: The function to apply to the raster array. If `raw` is True, this
614
+ function should accept and return a NumPy array. If `raw` is False,
615
+ this function should accept and return a single float value.
616
+ raw: If True, the function is applied directly to the entire array at
617
+ once. If False, the function is applied element-wise to each cell
618
+ in the array using `np.vectorize()`. Default is False.
619
+ """
620
+ new_raster = self.model_copy()
621
+ if raw:
622
+ new_arr = func(self.arr)
623
+ else:
624
+ new_arr = np.vectorize(func)(self.arr)
625
+ new_raster.arr = np.asarray(new_arr)
626
+ return new_raster
627
+
485
628
  def fillna(self, value: float) -> Self:
486
629
  """Fill NaN values in the raster with a specified value.
487
630
 
@@ -493,41 +636,42 @@ class RasterModel(BaseModel):
493
636
  return new_raster
494
637
 
495
638
  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
- )
639
+ """Get the x and y coordinates of the raster cell centres in meshgrid format.
501
640
 
502
- col_idx = col_idx.flatten()
503
- row_idx = row_idx.flatten()
641
+ Returns the coordinates of the cell centres as two separate 2D arrays in
642
+ meshgrid format, where each array has the same shape as the raster data array.
504
643
 
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
644
+ Returns:
645
+ A tuple of (x, y) coordinate arrays where:
646
+ - x: 2D array of x-coordinates of cell centres
647
+ - y: 2D array of y-coordinates of cell centres
648
+ Both arrays have the same shape as the raster data array.
649
+ """
650
+ coords = self.raster_meta.get_cell_centre_coords(self.arr.shape)
651
+ return coords[:, :, 0], coords[:, :, 1]
511
652
 
512
653
  def contour(
513
- self, *, levels: list[float], smoothing: bool = True
654
+ self, levels: list[float] | NDArray, *, smoothing: bool = True
514
655
  ) -> gpd.GeoDataFrame:
515
656
  """Create contour lines from the raster data, optionally with smoothing.
516
657
 
517
- The contour lines are returned as a GeoDataFrame with the contours as linestring
518
- geometries and the contour levels as attributes in a column named 'level'.
658
+ The contour lines are returned as a GeoDataFrame with the contours dissolved
659
+ by level, resulting in one row per contour level. Each row contains a
660
+ (Multi)LineString geometry representing all contour lines for that level,
661
+ and the contour level value in a column named 'level'.
519
662
 
520
663
  Consider calling `blur()` before this method to smooth the raster data before
521
664
  contouring, to denoise the contours.
522
665
 
523
666
  Args:
524
- levels: A list of contour levels to generate. The contour lines will be
525
- generated for each level in this list.
667
+ levels: A list or array of contour levels to generate. The contour lines
668
+ will be generated for each level in this sequence.
526
669
  smoothing: Defaults to true, which corresponds to applying a smoothing
527
670
  algorithm to the contour lines. At the moment, this is the
528
671
  Catmull-Rom spline algorithm. If set to False, the raw
529
672
  contours will be returned without any smoothing.
530
673
  """
674
+ import geopandas as gpd
531
675
 
532
676
  all_levels = []
533
677
  all_geoms = []
@@ -537,7 +681,7 @@ class RasterModel(BaseModel):
537
681
  level=level,
538
682
  )
539
683
 
540
- # Constructg shapely LineString objects
684
+ # Construct shapely LineString objects
541
685
  # Convert to CRS from array index coordinates to raster CRS
542
686
  geoms = [
543
687
  LineString(
@@ -566,7 +710,8 @@ class RasterModel(BaseModel):
566
710
  crs=self.raster_meta.crs,
567
711
  )
568
712
 
569
- return contour_gdf
713
+ # Dissolve contours by level to merge all contour lines of the same level
714
+ return contour_gdf.dissolve(by="level", as_index=False)
570
715
 
571
716
  def blur(self, sigma: float) -> Self:
572
717
  """Apply a Gaussian blur to the raster data.
@@ -576,6 +721,7 @@ class RasterModel(BaseModel):
576
721
  coordinate distance (e.g. meters). A larger sigma results in a more
577
722
  blurred image.
578
723
  """
724
+ from scipy.ndimage import gaussian_filter
579
725
 
580
726
  cell_sigma = sigma / self.raster_meta.cell_size
581
727
 
@@ -605,6 +751,82 @@ class RasterModel(BaseModel):
605
751
 
606
752
  return raster
607
753
 
754
+ def crop(
755
+ self,
756
+ bounds: tuple[float, float, float, float],
757
+ strategy: Literal["underflow", "overflow"] = "underflow",
758
+ ) -> Self:
759
+ """Crop the raster to the specified bounds as (minx, miny, maxx, maxy).
760
+
761
+ Args:
762
+ bounds: A tuple of (minx, miny, maxx, maxy) defining the bounds to crop to.
763
+ strategy: The cropping strategy to use. 'underflow' will crop the raster
764
+ to be fully within the bounds, ignoring any cells that are
765
+ partially outside the bounds. 'overflow' will instead include
766
+ cells that intersect the bounds, ensuring the bounds area
767
+ remains covered with cells.
768
+
769
+ Returns:
770
+ A new RasterModel instance cropped to the specified bounds.
771
+ """
772
+
773
+ minx, miny, maxx, maxy = bounds
774
+ arr = self.arr
775
+
776
+ # Get the half cell size for cropping
777
+ cell_size = self.raster_meta.cell_size
778
+ half_cell_size = cell_size / 2
779
+
780
+ # Get the cell centre coordinates as 1D arrays
781
+ x_coords = self.cell_x_coords
782
+ y_coords = self.cell_y_coords
783
+
784
+ # Get the indices to crop the array
785
+ if strategy == "underflow":
786
+ x_idx = (x_coords >= minx + half_cell_size) & (
787
+ x_coords <= maxx - half_cell_size
788
+ )
789
+ y_idx = (y_coords >= miny + half_cell_size) & (
790
+ y_coords <= maxy - half_cell_size
791
+ )
792
+ elif strategy == "overflow":
793
+ x_idx = (x_coords > minx - half_cell_size) & (
794
+ x_coords < maxx + half_cell_size
795
+ )
796
+ y_idx = (y_coords > miny - half_cell_size) & (
797
+ y_coords < maxy + half_cell_size
798
+ )
799
+ else:
800
+ msg = f"Unsupported cropping strategy: {strategy}"
801
+ raise NotImplementedError(msg)
802
+
803
+ # Crop the array
804
+ cropped_arr = arr[np.ix_(y_idx, x_idx)]
805
+
806
+ # Check the shape of the cropped array
807
+ if cropped_arr.size == 0:
808
+ msg = "Cropped array is empty; no cells within the specified bounds."
809
+ raise ValueError(msg)
810
+
811
+ # Recalculate the transform for the cropped raster
812
+ x_coords = x_coords[x_idx]
813
+ y_coords = y_coords[y_idx]
814
+ transform = rasterio.transform.from_bounds(
815
+ west=x_coords.min() - half_cell_size,
816
+ south=y_coords.min() - half_cell_size,
817
+ east=x_coords.max() + half_cell_size,
818
+ north=y_coords.max() + half_cell_size,
819
+ width=cropped_arr.shape[1],
820
+ height=cropped_arr.shape[0],
821
+ )
822
+
823
+ # Update the raster
824
+ cls = self.__class__
825
+ new_meta = RasterMeta(
826
+ cell_size=cell_size, crs=self.raster_meta.crs, transform=transform
827
+ )
828
+ return cls(arr=cropped_arr, raster_meta=new_meta)
829
+
608
830
  def resample(
609
831
  self, new_cell_size: float, *, method: Literal["bilinear"] = "bilinear"
610
832
  ) -> Self:
@@ -654,9 +876,24 @@ class RasterModel(BaseModel):
654
876
 
655
877
  @field_validator("arr")
656
878
  @classmethod
657
- def check_2d_array(cls, v: np.ndarray) -> np.ndarray:
879
+ def check_2d_array(cls, v: NDArray) -> NDArray:
658
880
  """Validator to ensure the cell array is 2D."""
659
881
  if v.ndim != 2:
660
882
  msg = "Cell array must be 2D"
661
883
  raise RasterCellArrayShapeError(msg)
662
884
  return v
885
+
886
+
887
+ def _get_xy_tuple(xy: Any) -> tuple[float, float]:
888
+ """Convert Point or coordinate tuple to coordinate tuple.
889
+
890
+ Args:
891
+ xy: Either a coordinate tuple or a shapely Point object.
892
+
893
+ Returns:
894
+ A coordinate tuple (x, y).
895
+ """
896
+ if isinstance(xy, Point):
897
+ return (xy.x, xy.y)
898
+ x, y = xy
899
+ return (float(x), float(y))
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: rastr
3
+ Version: 0.4.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/336916af169603534dd0728c10401667f263d98a.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=2_0GUP7yBCXRus-qiJKxQD62z172WSs1sQ6DVpPsbmM,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=I9CQB2NYUOFvvpdzNsQcDBdgLXDCjB7ONFxDAq_6_SU,31995
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.4.0.dist-info/METADATA,sha256=GjZF16Tmxjz3WUbEFhAB1jW19AecpNkJpk__YNSamQQ,4953
13
+ rastr-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ rastr-0.4.0.dist-info/licenses/LICENSE,sha256=7qUsx93G2ATTRLZiSYuQofwAX_uWvrqnAiMK8PzxvNc,1080
15
+ rastr-0.4.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