rastr 0.7.1__py3-none-any.whl → 0.8.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
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.7.1'
32
- __version_tuple__ = version_tuple = (0, 7, 1)
31
+ __version__ = version = '0.8.0'
32
+ __version_tuple__ = version_tuple = (0, 8, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
rastr/create.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import importlib.util
4
4
  import warnings
5
- from typing import TYPE_CHECKING, TypeVar
5
+ from typing import TYPE_CHECKING, Literal, TypeVar
6
6
 
7
7
  import numpy as np
8
8
  import rasterio.features
@@ -10,19 +10,21 @@ import rasterio.transform
10
10
  from affine import Affine
11
11
  from pyproj import CRS
12
12
  from shapely.geometry import Point
13
+ from typing_extensions import assert_never
13
14
 
14
15
  from rastr.gis.crs import get_affine_sign
15
16
  from rastr.gis.fishnet import create_point_grid, get_point_grid_shape
16
- from rastr.gis.interpolate import interpn_kernel
17
+ from rastr.gis.interpolate import InterpolationError, interpn_kernel
17
18
  from rastr.meta import RasterMeta
18
- from rastr.raster import Raster
19
+ from rastr.raster import Raster, RasterModel
19
20
 
20
21
  if TYPE_CHECKING:
21
22
  from collections.abc import Collection, Iterable
22
23
 
23
24
  import geopandas as gpd
24
- from numpy.typing import ArrayLike
25
+ from numpy.typing import ArrayLike, NDArray
25
26
  from shapely.geometry import Polygon
27
+ from shapely.geometry.base import BaseGeometry
26
28
 
27
29
 
28
30
  TQDM_INSTALLED = importlib.util.find_spec("tqdm") is not None
@@ -215,6 +217,137 @@ def rasterize_gdf(
215
217
  return rasters
216
218
 
217
219
 
220
+ def rasterize_z_gdf(
221
+ gdf: gpd.GeoDataFrame,
222
+ *,
223
+ cell_size: float,
224
+ crs: CRS | str,
225
+ agg: Literal["mean", "min", "max"] = "mean",
226
+ ) -> RasterModel:
227
+ """Rasterize interpolated Z-values from geometries in a GeoDataFrame.
228
+
229
+ Handles overlapping geometries by aggregating values using a specified method.
230
+ All geometries must be 3D (have Z coordinates) for interpolation to work.
231
+
232
+ The Z-value for each cell is interpolated at the cell center.
233
+
234
+ Args:
235
+ gdf: GeoDataFrame containing 3D geometries with Z coordinates.
236
+ cell_size: Desired cell size for the output raster.
237
+ crs: Coordinate reference system for the output raster.
238
+ agg: Aggregation function to use for overlapping values ("mean", "min", "max").
239
+
240
+ Returns:
241
+ A raster of interpolated Z values.
242
+
243
+ Raises:
244
+ ValueError: If any geometries are not 3D.
245
+ """
246
+ crs = CRS.from_user_input(crs)
247
+
248
+ if len(gdf) == 0:
249
+ msg = "Cannot rasterize an empty GeoDataFrame."
250
+ raise ValueError(msg)
251
+
252
+ _validate_geometries_are_3d(gdf)
253
+
254
+ # Determine the bounds that would encompass the geometry while respecting the grid
255
+ gdf_bounds = gdf.total_bounds
256
+ meta, shape = RasterMeta.infer(
257
+ x=np.array([gdf_bounds[0], gdf_bounds[2]]),
258
+ y=np.array([gdf_bounds[1], gdf_bounds[3]]),
259
+ cell_size=cell_size,
260
+ crs=crs,
261
+ )
262
+
263
+ # Generate grid coordinates for interpolation
264
+ x_coords, y_coords = _get_grid(meta, shape=shape)
265
+
266
+ # Create 2D accumulation arrays
267
+ z_stack = []
268
+ for geom in gdf.geometry:
269
+ z_vals = _interpolate_z_in_geometry(geom, x_coords, y_coords)
270
+ z_stack.append(z_vals)
271
+
272
+ if not z_stack:
273
+ msg = (
274
+ "No valid Z values could be interpolated from the geometries. Raster "
275
+ "will be entirely NaN-valued."
276
+ )
277
+ warnings.warn(msg, stacklevel=2)
278
+ arr = np.full(shape, np.nan, dtype=np.float64)
279
+ return RasterModel(arr=arr, raster_meta=meta)
280
+
281
+ z_stack = np.array(z_stack) # Shape: (N, height * width)
282
+
283
+ with warnings.catch_warnings():
284
+ warnings.filterwarnings(
285
+ "ignore", category=RuntimeWarning, message="All-NaN slice encountered"
286
+ )
287
+ warnings.filterwarnings(
288
+ "ignore", category=RuntimeWarning, message="Mean of empty slice"
289
+ )
290
+
291
+ if agg == "mean":
292
+ z_agg = np.nanmean(z_stack, axis=0)
293
+ elif agg == "min":
294
+ z_agg = np.nanmin(z_stack, axis=0)
295
+ elif agg == "max":
296
+ z_agg = np.nanmax(z_stack, axis=0)
297
+ else:
298
+ assert_never(agg)
299
+
300
+ arr = np.asarray(z_agg, dtype=np.float64).reshape(shape)
301
+
302
+ return RasterModel(arr=arr, raster_meta=meta)
303
+
304
+
305
+ def _validate_geometries_are_3d(gdf: gpd.GeoDataFrame) -> None:
306
+ """Validate that all geometries have 3D coordinates (Z values).
307
+
308
+ Args:
309
+ gdf: The GeoDataFrame to check for 3D geometries.
310
+
311
+ Raises:
312
+ ValueError: If any geometries are not 3D.
313
+ """
314
+ for idx, geom in enumerate(gdf.geometry):
315
+ if geom is None or geom.is_empty:
316
+ continue
317
+
318
+ # Check if geometry has Z coordinates
319
+ if not geom.has_z:
320
+ msg = (
321
+ f"Geometry at index {idx} is not 3D. Z-coordinates are required since "
322
+ "they give the cell values during rasterization."
323
+ )
324
+ raise ValueError(msg)
325
+
326
+
327
+ def _interpolate_z_in_geometry(
328
+ geometry: BaseGeometry, x: NDArray, y: NDArray
329
+ ) -> NDArray[np.float64]:
330
+ """Vectorized interpolation of Z values in a geometry at multiple (x, y) points.
331
+
332
+ Only the boundary is considered (e.g. holes in polygons are ignored).
333
+
334
+ Parameters:
335
+ geometry: Shapely geometry with Z coordinates (Polygon, LineString, etc.).
336
+ x: Array of X coordinates, shape (N,).
337
+ y: Array of Y coordinates, shape (N,).
338
+
339
+ Returns:
340
+ Array of interpolated Z values (NaN if outside convex hull or no boundary).
341
+ """
342
+ # Extract coordinates from geometry boundary only
343
+ coords = np.array(geometry.boundary.coords)
344
+
345
+ try:
346
+ return interpn_kernel(coords[:, :2], coords[:, 2], xi=np.column_stack((x, y)))
347
+ except InterpolationError:
348
+ return np.full_like(x, np.nan, dtype=np.float64)
349
+
350
+
218
351
  def _validate_columns_exist(
219
352
  gdf: gpd.GeoDataFrame, target_cols: Collection[str]
220
353
  ) -> None:
@@ -299,7 +432,8 @@ def raster_from_point_cloud(
299
432
  Interpolation is only possible within the convex hull of the points. Outside of
300
433
  this, cells will be NaN-valued.
301
434
 
302
- All (x,y) points must be unique.
435
+ Duplicate (x, y, z) triples are silently deduplicated. However, duplicate (x, y)
436
+ points with different z values will raise an error.
303
437
 
304
438
  Args:
305
439
  x: X coordinates of points.
@@ -313,7 +447,8 @@ def raster_from_point_cloud(
313
447
  Raster containing the interpolated values.
314
448
 
315
449
  Raises:
316
- ValueError: If any (x, y) points are duplicated, or if they are all collinear.
450
+ ValueError: If any (x, y) points have different z values, or if they are all
451
+ collinear.
317
452
  """
318
453
  crs = CRS.from_user_input(crs)
319
454
  x, y, z = _validate_xyz(
@@ -357,7 +492,21 @@ def _validate_xyz(
357
492
  "surface."
358
493
  )
359
494
  raise ValueError(msg)
360
- # Check for duplicate (x, y) points
495
+ # Check for duplicate (x, y, z) triples and deduplicate them
496
+ xyz_points = np.column_stack((x, y, z))
497
+ unique_xyz, first_occurrence_indices = np.unique(
498
+ xyz_points, axis=0, return_index=True
499
+ )
500
+
501
+ # If we have duplicate (x, y, z) triples, deduplicate them
502
+ if len(unique_xyz) < len(xyz_points):
503
+ x = x[first_occurrence_indices]
504
+ y = y[first_occurrence_indices]
505
+ z = z[first_occurrence_indices]
506
+
507
+ # Check for duplicate (x, y) points with different z values
508
+ # After deduplication, if there are still duplicate (x,y) points, they must have
509
+ # different z values
361
510
  xy_points = np.column_stack((x, y))
362
511
  if len(xy_points) != len(np.unique(xy_points, axis=0)):
363
512
  msg = "Duplicate (x, y) points found. Each (x, y) point must be unique."
rastr/gis/fishnet.py CHANGED
@@ -7,7 +7,7 @@ from shapely import BufferCapStyle, BufferJoinStyle
7
7
 
8
8
  if TYPE_CHECKING:
9
9
  from geopandas.array import GeometryArray
10
- from numpy.typing import NDArray
10
+ from numpy.typing import ArrayLike, NDArray
11
11
 
12
12
 
13
13
  def create_point_grid(
@@ -36,11 +36,11 @@ def create_point_grid(
36
36
 
37
37
 
38
38
  def get_point_grid_shape(
39
- *, bounds: tuple[float, float, float, float], cell_size: float
39
+ *, bounds: tuple[float, float, float, float] | ArrayLike, cell_size: float
40
40
  ) -> tuple[int, int]:
41
41
  """Calculate the shape of the point grid based on bounds and cell size."""
42
42
 
43
- xmin, ymin, xmax, ymax = bounds
43
+ xmin, ymin, xmax, ymax = np.asarray(bounds)
44
44
  ncols_exact = (xmax - xmin) / cell_size
45
45
  nrows_exact = (ymax - ymin) / cell_size
46
46
 
rastr/gis/interpolate.py CHANGED
@@ -8,6 +8,10 @@ if TYPE_CHECKING:
8
8
  from collections.abc import Callable
9
9
 
10
10
 
11
+ class InterpolationError(ValueError):
12
+ """Exception for interpolation-related errors."""
13
+
14
+
11
15
  def interpn_kernel(
12
16
  points: np.ndarray,
13
17
  values: np.ndarray,
@@ -44,7 +48,7 @@ def interpn_kernel(
44
48
  "degenerate input points. Ensure that the (x, y) points are not all "
45
49
  "collinear (i.e. that the convex hull is non-degenerate)."
46
50
  )
47
- raise ValueError(msg) from err
51
+ raise InterpolationError(msg) from err
48
52
 
49
53
  grid_values = np.array(interpolator(xi))
50
54
  return grid_values
rastr/io.py CHANGED
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import warnings
3
4
  from pathlib import Path
4
- from typing import TYPE_CHECKING, TypeVar
5
+ from typing import TYPE_CHECKING, Any, TypeVar
5
6
 
6
7
  import numpy as np
7
8
  import rasterio
@@ -12,8 +13,14 @@ from rastr.meta import RasterMeta
12
13
  from rastr.raster import Raster
13
14
 
14
15
  if TYPE_CHECKING:
16
+ import geopandas as gpd
15
17
  from numpy.typing import NDArray
16
18
 
19
+ try:
20
+ from rasterio._err import CPLE_BaseError
21
+ except ImportError:
22
+ CPLE_BaseError = Exception # Fallback if private module import fails
23
+
17
24
  R = TypeVar("R", bound=Raster)
18
25
 
19
26
 
@@ -118,3 +125,115 @@ def read_raster_mosaic_inmem(
118
125
  finally:
119
126
  for src in sources:
120
127
  src.close()
128
+
129
+
130
+ def write_raster(raster: Raster, *, path: Path | str, **kwargs: Any) -> None:
131
+ """Write the raster to a file.
132
+
133
+ Args:
134
+ raster: The Raster object to write.
135
+ path: Path to output file.
136
+ **kwargs: Additional keyword arguments to pass to `rasterio.open()`. If
137
+ `nodata` is provided, NaN values in the raster will be replaced
138
+ with the nodata value.
139
+ """
140
+ path = Path(path)
141
+
142
+ suffix = path.suffix.lower()
143
+ if suffix in (".tif", ".tiff"):
144
+ driver = "GTiff"
145
+ elif suffix in (".grd"):
146
+ # https://grapherhelp.goldensoftware.com/subsys/ascii_grid_file_format.htm
147
+ # e.g. Used by AnAqSim
148
+ driver = "GSAG"
149
+ else:
150
+ msg = f"Unsupported file extension: {suffix}"
151
+ raise ValueError(msg)
152
+
153
+ # Handle nodata: use provided value or default to np.nan
154
+ if "nodata" in kwargs:
155
+ # Replace NaN values with the nodata value
156
+ nodata_value = kwargs.pop("nodata")
157
+ arr_to_write = np.where(np.isnan(raster.arr), nodata_value, raster.arr)
158
+ else:
159
+ nodata_value = np.nan
160
+ arr_to_write = raster.arr
161
+
162
+ with rasterio.open(
163
+ path,
164
+ "w",
165
+ driver=driver,
166
+ height=raster.arr.shape[0],
167
+ width=raster.arr.shape[1],
168
+ count=1,
169
+ dtype=raster.arr.dtype,
170
+ crs=raster.raster_meta.crs,
171
+ transform=raster.raster_meta.transform,
172
+ nodata=nodata_value,
173
+ **kwargs,
174
+ ) as dst:
175
+ try:
176
+ dst.write(arr_to_write, 1)
177
+ except CPLE_BaseError as err:
178
+ msg = f"Failed to write raster to file: {err}"
179
+ raise OSError(msg) from err
180
+
181
+
182
+ def read_cad_gdf(path: Path | str, crs: CRS | str | None = None) -> gpd.GeoDataFrame:
183
+ """Read a GeoDataFrame from a DXF/CAD file with warning suppression.
184
+
185
+ This is useful in tandem with `Raster.clip` and `rastr.create.rasterize_z_gdf` to
186
+ create rasters from CAD files. Often CAD files represent surfaces which for GIS
187
+ contexts are better represented as rasters. In other cases, CAD files represent
188
+ geometries but their difficult representation means that it is often easier to first
189
+ rasterize them, and then spatially join with a clean vector representation
190
+ developed separately.
191
+
192
+ DXF files often have large geometries that can trigger warnings during reading.
193
+ This function suppresses those warnings and ensures proper CRS handling.
194
+
195
+ Supports any format supported by `geopandas.read_file` which provides 3D geometries.
196
+
197
+ Args:
198
+ path: Path to the CAD file.
199
+ crs: Optional CRS for the output GeoDataFrame. If None, uses the CRS from the
200
+ CAD file.
201
+
202
+ Returns:
203
+ GeoDataFrame: The CAD data as a GeoDataFrame.
204
+
205
+ Raises:
206
+ ValueError: If CRS is missing from CAD file and not provided, or if CRS is
207
+ inconsistent between CAD file and provided CRS.
208
+ """
209
+ import geopandas as gpd
210
+
211
+ path = Path(path)
212
+
213
+ with warnings.catch_warnings():
214
+ # The CAD-derived geometries are really messy, so we can get warnings
215
+ # about large polygons, but we're going to handle those.
216
+ warnings.filterwarnings(
217
+ "ignore",
218
+ message=".*Non closed ring detected.*",
219
+ category=RuntimeWarning,
220
+ )
221
+
222
+ # Read the CAD file using geopandas
223
+ gdf = gpd.read_file(path)
224
+
225
+ # Handle CRS logic
226
+ crs = CRS.from_user_input(crs) if crs is not None else None
227
+ if crs is None:
228
+ if gdf.crs is None:
229
+ msg = (
230
+ f"No CRS found in CAD file {path} and no CRS provided. "
231
+ "Please provide a CRS parameter."
232
+ )
233
+ raise ValueError(msg)
234
+ # Check if CRS are consistent
235
+ elif gdf.crs is not None and not gdf.crs.equals(crs):
236
+ # Reproject if inconsistent
237
+ gdf = gdf.to_crs(crs)
238
+
239
+ return gdf
rastr/meta.py CHANGED
@@ -97,7 +97,7 @@ class RasterMeta(BaseModel, extra="forbid"):
97
97
  cell_size: float | None = None,
98
98
  crs: CRS,
99
99
  ) -> tuple[Self, tuple[int, int]]:
100
- """Automatically get recommended raster metadata (and shape) using data bounds.
100
+ """Automatically get recommended raster metadata (and shape) using data points.
101
101
 
102
102
  The cell size can be provided, or a heuristic will be used based on the spacing
103
103
  of the (x, y) points.
rastr/raster.py CHANGED
@@ -6,8 +6,7 @@ import importlib.util
6
6
  import warnings
7
7
  from collections.abc import Collection
8
8
  from contextlib import contextmanager
9
- from pathlib import Path
10
- from typing import TYPE_CHECKING, Any, Literal, overload
9
+ from typing import TYPE_CHECKING, Any, Literal, NamedTuple, overload
11
10
 
12
11
  import numpy as np
13
12
  import numpy.ma
@@ -15,13 +14,12 @@ import rasterio.features
15
14
  import rasterio.plot
16
15
  import rasterio.sample
17
16
  import rasterio.transform
18
- import skimage.measure
19
17
  from pydantic import BaseModel, InstanceOf, field_validator
20
18
  from pyproj import Transformer
21
19
  from pyproj.crs.crs import CRS
22
20
  from rasterio.enums import Resampling
23
21
  from rasterio.io import MemoryFile
24
- from shapely.geometry import LineString, Point, Polygon
22
+ from shapely.geometry import LineString, MultiPolygon, Point, Polygon
25
23
 
26
24
  from rastr.arr.fill import fillna_nearest_neighbours
27
25
  from rastr.gis.fishnet import create_fishnet
@@ -30,6 +28,7 @@ from rastr.meta import RasterMeta
30
28
 
31
29
  if TYPE_CHECKING:
32
30
  from collections.abc import Callable, Generator
31
+ from pathlib import Path
33
32
 
34
33
  import geopandas as gpd
35
34
  from affine import Affine
@@ -39,14 +38,9 @@ if TYPE_CHECKING:
39
38
  from matplotlib.image import AxesImage
40
39
  from numpy.typing import ArrayLike, NDArray
41
40
  from rasterio.io import BufferedDatasetWriter, DatasetReader, DatasetWriter
42
- from shapely import MultiPolygon
41
+ from shapely.geometry.base import BaseGeometry
43
42
  from typing_extensions import Self
44
43
 
45
- try:
46
- from rasterio._err import CPLE_BaseError
47
- except ImportError:
48
- CPLE_BaseError = Exception # Fallback if private module import fails
49
-
50
44
 
51
45
  FOLIUM_INSTALLED = importlib.util.find_spec("folium") is not None
52
46
  BRANCA_INSTALLED = importlib.util.find_spec("branca") is not None
@@ -55,6 +49,27 @@ MATPLOTLIB_INSTALLED = importlib.util.find_spec("matplotlib") is not None
55
49
  CONTOUR_PERTURB_EPS = 1e-10
56
50
 
57
51
 
52
+ @contextmanager
53
+ def suppress_slice_warning() -> Generator[None, None, None]:
54
+ """Context manager to suppress all-NaN slice warnings from NumPy operations.
55
+
56
+ An all-NaN slice is a row or a column of an array where every value is NaN.
57
+ This is common for rasters around the edges, and is almost always a false
58
+ positive.
59
+
60
+ Note that even .trim_nan() won't even guarantee there aren't NaN slices,
61
+ since that method only applies around the edges of a raster, whereas there
62
+ might be NaN slices in between non-NaN data in the middle.
63
+ """
64
+ with warnings.catch_warnings():
65
+ warnings.filterwarnings(
66
+ "ignore",
67
+ message="All-NaN slice encountered",
68
+ category=RuntimeWarning,
69
+ )
70
+ yield
71
+
72
+
58
73
  class RasterCellArrayShapeError(ValueError):
59
74
  """Custom error for invalid raster cell array shapes."""
60
75
 
@@ -249,6 +264,18 @@ class Raster(BaseModel):
249
264
  cls = self.__class__
250
265
  return cls(arr=-self.arr, raster_meta=self.raster_meta)
251
266
 
267
+ def abs(self) -> Self:
268
+ """Compute the absolute value of the raster.
269
+
270
+ Returns a new raster with the absolute value of each cell. The original raster
271
+ is not modified.
272
+
273
+ Returns:
274
+ A new Raster instance with the absolute values.
275
+ """
276
+ cls = self.__class__
277
+ return cls(arr=np.abs(self.arr), raster_meta=self.raster_meta)
278
+
252
279
  @property
253
280
  def cell_centre_coords(self) -> NDArray[np.float64]:
254
281
  """Get the coordinates of the cell centres in the raster."""
@@ -417,8 +444,8 @@ class Raster(BaseModel):
417
444
  return raster_values
418
445
 
419
446
  @property
420
- def bounds(self) -> tuple[float, float, float, float]:
421
- """Bounding box of the raster as (xmin, ymin, xmax, ymax)"""
447
+ def bounds(self) -> Bounds:
448
+ """Bounding box of the raster as a named tuple with xmin, ymin, xmax, ymax."""
422
449
  x1, y1, x2, y2 = rasterio.transform.array_bounds(
423
450
  height=self.arr.shape[0],
424
451
  width=self.arr.shape[1],
@@ -426,7 +453,7 @@ class Raster(BaseModel):
426
453
  )
427
454
  xmin, xmax = sorted([x1, x2])
428
455
  ymin, ymax = sorted([y1, y2])
429
- return (xmin, ymin, xmax, ymax)
456
+ return Bounds(xmin=xmin, ymin=ymin, xmax=xmax, ymax=ymax)
430
457
 
431
458
  @property
432
459
  def bbox(self) -> Polygon:
@@ -580,7 +607,8 @@ class Raster(BaseModel):
580
607
  suppressed: Values to suppress from the plot (i.e. not display). This can be
581
608
  useful for zeroes especially.
582
609
  **kwargs: Additional keyword arguments to pass to `rasterio.plot.show()`.
583
- This includes parameters like `alpha` for transparency.
610
+ This includes parameters like `alpha` for transparency, and `vmin`
611
+ and `vmax` for controlling the color scale limits.
584
612
  """
585
613
  if not MATPLOTLIB_INSTALLED:
586
614
  msg = "The 'matplotlib' package is required for 'plot()'."
@@ -673,8 +701,15 @@ class Raster(BaseModel):
673
701
 
674
702
  return raster_gdf
675
703
 
704
+ def gdf(self, name: str = "value") -> gpd.GeoDataFrame:
705
+ """Create a GeoDataFrame representation of the raster.
706
+
707
+ Alias for `as_geodataframe()`.
708
+ """
709
+ return self.as_geodataframe(name=name)
710
+
676
711
  def to_file(self, path: Path | str, **kwargs: Any) -> None:
677
- """Write the raster to a GeoTIFF file.
712
+ """Write the raster to a file.
678
713
 
679
714
  Args:
680
715
  path: Path to output file.
@@ -682,47 +717,9 @@ class Raster(BaseModel):
682
717
  `nodata` is provided, NaN values in the raster will be replaced
683
718
  with the nodata value.
684
719
  """
720
+ from rastr.io import write_raster # noqa: PLC0415
685
721
 
686
- path = Path(path)
687
-
688
- suffix = path.suffix.lower()
689
- if suffix in (".tif", ".tiff"):
690
- driver = "GTiff"
691
- elif suffix in (".grd"):
692
- # https://grapherhelp.goldensoftware.com/subsys/ascii_grid_file_format.htm
693
- # e.g. Used by AnAqSim
694
- driver = "GSAG"
695
- else:
696
- msg = f"Unsupported file extension: {suffix}"
697
- raise ValueError(msg)
698
-
699
- # Handle nodata: use provided value or default to np.nan
700
- if "nodata" in kwargs:
701
- # Replace NaN values with the nodata value
702
- nodata_value = kwargs.pop("nodata")
703
- arr_to_write = np.where(np.isnan(self.arr), nodata_value, self.arr)
704
- else:
705
- nodata_value = np.nan
706
- arr_to_write = self.arr
707
-
708
- with rasterio.open(
709
- path,
710
- "w",
711
- driver=driver,
712
- height=self.arr.shape[0],
713
- width=self.arr.shape[1],
714
- count=1,
715
- dtype=self.arr.dtype,
716
- crs=self.raster_meta.crs,
717
- transform=self.raster_meta.transform,
718
- nodata=nodata_value,
719
- **kwargs,
720
- ) as dst:
721
- try:
722
- dst.write(arr_to_write, 1)
723
- except CPLE_BaseError as err:
724
- msg = f"Failed to write raster to file: {err}"
725
- raise OSError(msg) from err
722
+ return write_raster(self, path=path, **kwargs)
726
723
 
727
724
  def __str__(self) -> str:
728
725
  cls = self.__class__
@@ -817,7 +814,8 @@ class Raster(BaseModel):
817
814
  Returns:
818
815
  The maximum value in the raster. Returns NaN if all values are NaN.
819
816
  """
820
- return float(np.nanmax(self.arr))
817
+ with suppress_slice_warning():
818
+ return float(np.nanmax(self.arr))
821
819
 
822
820
  def min(self) -> float:
823
821
  """Get the minimum value in the raster, ignoring NaN values.
@@ -825,7 +823,8 @@ class Raster(BaseModel):
825
823
  Returns:
826
824
  The minimum value in the raster. Returns NaN if all values are NaN.
827
825
  """
828
- return float(np.nanmin(self.arr))
826
+ with suppress_slice_warning():
827
+ return float(np.nanmin(self.arr))
829
828
 
830
829
  def mean(self) -> float:
831
830
  """Get the mean value in the raster, ignoring NaN values.
@@ -833,7 +832,8 @@ class Raster(BaseModel):
833
832
  Returns:
834
833
  The mean value in the raster. Returns NaN if all values are NaN.
835
834
  """
836
- return float(np.nanmean(self.arr))
835
+ with suppress_slice_warning():
836
+ return float(np.nanmean(self.arr))
837
837
 
838
838
  def std(self) -> float:
839
839
  """Get the standard deviation of values in the raster, ignoring NaN values.
@@ -841,7 +841,8 @@ class Raster(BaseModel):
841
841
  Returns:
842
842
  The standard deviation of the raster. Returns NaN if all values are NaN.
843
843
  """
844
- return float(np.nanstd(self.arr))
844
+ with suppress_slice_warning():
845
+ return float(np.nanstd(self.arr))
845
846
 
846
847
  def quantile(self, q: float) -> float:
847
848
  """Get the specified quantile value in the raster, ignoring NaN values.
@@ -852,7 +853,8 @@ class Raster(BaseModel):
852
853
  Returns:
853
854
  The quantile value. Returns NaN if all values are NaN.
854
855
  """
855
- return float(np.nanquantile(self.arr, q))
856
+ with suppress_slice_warning():
857
+ return float(np.nanquantile(self.arr, q))
856
858
 
857
859
  def median(self) -> float:
858
860
  """Get the median value in the raster, ignoring NaN values.
@@ -862,7 +864,8 @@ class Raster(BaseModel):
862
864
  Returns:
863
865
  The median value in the raster. Returns NaN if all values are NaN.
864
866
  """
865
- return float(np.nanmedian(self.arr))
867
+ with suppress_slice_warning():
868
+ return float(np.nanmedian(self.arr))
866
869
 
867
870
  def fillna(self, value: float) -> Self:
868
871
  """Fill NaN values in the raster with a specified value.
@@ -983,6 +986,7 @@ class Raster(BaseModel):
983
986
  contours will be returned without any smoothing.
984
987
  """
985
988
  import geopandas as gpd
989
+ import skimage.measure
986
990
 
987
991
  all_levels = []
988
992
  all_geoms = []
@@ -1032,6 +1036,27 @@ class Raster(BaseModel):
1032
1036
  # Dissolve contours by level to merge all contour lines of the same level
1033
1037
  return contour_gdf.dissolve(by="level", as_index=False)
1034
1038
 
1039
+ def sobel(self) -> Self:
1040
+ """Compute the Sobel gradient magnitude of the raster.
1041
+
1042
+ This is effectively a discrete differentiation operator, computing an
1043
+ approximation of the magnitude of the gradient of the image intensity function.
1044
+
1045
+ Borders are treated using half-sample symmetric sampling, i.e. repeating the
1046
+ border values. Be aware that this can lead to edge artifacts and under-estimate
1047
+ the gradient along the border pixels.
1048
+
1049
+ Returns:
1050
+ New raster containing the gradient magnitude in units of raster cell units
1051
+ per unit distance (e.g. per meter).
1052
+ """
1053
+ from skimage import filters
1054
+
1055
+ new_raster = self.model_copy()
1056
+ # Scale by cell size to convert to per unit distance
1057
+ new_raster.arr = filters.sobel(self.arr) / self.cell_size
1058
+ return new_raster
1059
+
1035
1060
  def blur(self, sigma: float, *, preserve_nan: bool = True) -> Self:
1036
1061
  """Apply a Gaussian blur to the raster data.
1037
1062
 
@@ -1286,7 +1311,7 @@ class Raster(BaseModel):
1286
1311
 
1287
1312
  def clip(
1288
1313
  self,
1289
- polygon: Polygon | MultiPolygon,
1314
+ polygon: BaseGeometry,
1290
1315
  *,
1291
1316
  strategy: Literal["centres"] = "centres",
1292
1317
  ) -> Self:
@@ -1304,22 +1329,25 @@ class Raster(BaseModel):
1304
1329
 
1305
1330
  Returns:
1306
1331
  A new Raster with cells outside the polygon set to NaN.
1332
+
1333
+ Raises:
1334
+ TypeError: If the provided geometry is not a Polygon or MultiPolygon.
1307
1335
  """
1336
+ if not isinstance(polygon, Polygon | MultiPolygon):
1337
+ msg = (
1338
+ f"Only Polygon and MultiPolygon geometries are supported for clipping, "
1339
+ f"got {type(polygon).__name__}"
1340
+ )
1341
+ raise TypeError(msg)
1342
+
1308
1343
  if strategy != "centres":
1309
1344
  msg = f"Unsupported clipping strategy: {strategy}"
1310
1345
  raise NotImplementedError(msg)
1311
1346
 
1312
- raster = self.model_copy()
1313
-
1314
- mask = rasterio.features.rasterize(
1315
- [(polygon, 1)],
1316
- fill=0,
1317
- out_shape=self.shape,
1318
- transform=self.meta.transform,
1319
- dtype=np.uint8,
1320
- )
1347
+ mask_raster = self._polygon_indicator(polygon)
1321
1348
 
1322
- raster.arr = np.where(mask, raster.arr, np.nan)
1349
+ raster = self.model_copy()
1350
+ raster.arr = np.where(mask_raster.arr, raster.arr, np.nan)
1323
1351
 
1324
1352
  return raster
1325
1353
 
@@ -1433,6 +1461,99 @@ class Raster(BaseModel):
1433
1461
 
1434
1462
  return cls(arr=new_arr, raster_meta=new_raster_meta)
1435
1463
 
1464
+ def replace_polygon(
1465
+ self,
1466
+ polygon: BaseGeometry | dict[BaseGeometry, float],
1467
+ value: float | None = None,
1468
+ ) -> Self:
1469
+ """Replace values within the specified polygon(s) with other values.
1470
+
1471
+ Creates a new raster with the specified values replaced. This is useful for
1472
+ operations like masking or setting regions to NaN.
1473
+
1474
+ The method supports two interfaces:
1475
+ 1. Single replacement: `raster.replace_polygon(polygon1, value=np.nan)`
1476
+ 2. Multiple replacements using a dictionary:
1477
+ `raster.replace_polygon({polygon1: 0, polygon2: 1})`
1478
+
1479
+ Args:
1480
+ polygon: Geometry to replace, or dict mapping geometries to values.
1481
+ value: Replacement value. Required if polygon is a geometry, None if polygon
1482
+ is a dict.
1483
+
1484
+ Examples:
1485
+ >>> # Replace a single polygon
1486
+ >>> raster.replace_polygon(polygon1, value=np.nan)
1487
+ >>> # Replace multiple polygons
1488
+ >>> raster.replace_polygon({polygon1: 0, polygon2: 1})
1489
+ """
1490
+ # Normalize input to dict format
1491
+ if isinstance(polygon, dict):
1492
+ if value is not None:
1493
+ msg = "value must be None when polygon is a dict"
1494
+ raise ValueError(msg)
1495
+ replacements = polygon
1496
+ else:
1497
+ if value is None:
1498
+ msg = "value must be specified when polygon is a geometry"
1499
+ raise ValueError(msg)
1500
+ replacements = {polygon: value}
1501
+
1502
+ # Validate all geometries upfront
1503
+ for geom in replacements.keys():
1504
+ if not isinstance(geom, Polygon | MultiPolygon):
1505
+ msg = (
1506
+ f"Only Polygon and MultiPolygon geometries are supported, "
1507
+ f"got {type(geom).__name__}"
1508
+ )
1509
+ raise TypeError(msg)
1510
+
1511
+ # Create copy and convert to float if needed
1512
+ raster = self.model_copy()
1513
+ needs_float = any(
1514
+ isinstance(v, float) or (v is not None and np.isnan(v))
1515
+ for v in replacements.values()
1516
+ )
1517
+ if needs_float and not np.issubdtype(raster.arr.dtype, np.floating):
1518
+ raster.arr = raster.arr.astype(float)
1519
+
1520
+ # Apply all replacements
1521
+ for geom, val in replacements.items():
1522
+ mask_raster = self._polygon_indicator(geom)
1523
+ raster.arr = np.where(mask_raster.arr, val, raster.arr)
1524
+
1525
+ return raster
1526
+
1527
+ def _polygon_indicator(self, geom: BaseGeometry) -> Self:
1528
+ """Create a binary mask with 1 inside the polygon, 0 outside.
1529
+
1530
+ The mask has the same shape and transform as the raster.
1531
+
1532
+ Args:
1533
+ geom:
1534
+ A shapely geometry (typically Polygon or MultiPolygon)
1535
+ to rasterize.
1536
+
1537
+ Returns:
1538
+ Raster:
1539
+ A new Raster where cells inside the geometry are 1,
1540
+ and outside are 0.
1541
+ """
1542
+
1543
+ arr = rasterio.features.rasterize(
1544
+ [(geom, 1)],
1545
+ fill=0,
1546
+ out_shape=self.shape,
1547
+ transform=self.meta.transform,
1548
+ dtype=np.uint8,
1549
+ )
1550
+
1551
+ # Create a new raster with the binary mask array
1552
+ raster = self.model_copy()
1553
+ raster.arr = arr
1554
+
1555
+ return raster
1556
+
1436
1557
 
1437
1558
  def _map_colorbar(
1438
1559
  *,
@@ -1466,22 +1587,32 @@ def _get_vmin_vmax(
1466
1587
 
1467
1588
  Allows for custom over-ride vmin and vmax values to be provided.
1468
1589
  """
1469
- with warnings.catch_warnings():
1470
- warnings.filterwarnings(
1471
- "ignore",
1472
- message="All-NaN slice encountered",
1473
- category=RuntimeWarning,
1474
- )
1475
- if vmin is None:
1476
- _vmin = float(raster.min())
1477
- else:
1478
- _vmin = vmin
1479
- if vmax is None:
1480
- _vmax = float(raster.max())
1481
- else:
1482
- _vmax = vmax
1590
+ if vmin is None:
1591
+ _vmin = float(raster.min())
1592
+ else:
1593
+ _vmin = vmin
1594
+ if vmax is None:
1595
+ _vmax = float(raster.max())
1596
+ else:
1597
+ _vmax = vmax
1483
1598
 
1484
1599
  return _vmin, _vmax
1485
1600
 
1486
1601
 
1487
1602
  RasterModel = Raster
1603
+
1604
+
1605
+ class Bounds(NamedTuple):
1606
+ """Bounding box coordinates for a raster.
1607
+
1608
+ Attributes:
1609
+ xmin: The minimum x-coordinate.
1610
+ ymin: The minimum y-coordinate.
1611
+ xmax: The maximum x-coordinate.
1612
+ ymax: The maximum y-coordinate.
1613
+ """
1614
+
1615
+ xmin: float
1616
+ ymin: float
1617
+ xmax: float
1618
+ ymax: float
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rastr
3
- Version: 0.7.1
3
+ Version: 0.8.0
4
4
  Summary: Geospatial Raster datatype library for Python.
5
5
  Project-URL: Source Code, https://github.com/tonkintaylor/rastr
6
6
  Project-URL: Bug Tracker, https://github.com/tonkintaylor/rastr/issues
7
7
  Project-URL: Releases, https://github.com/tonkintaylor/rastr/releases
8
- Project-URL: Source Archive, https://github.com/tonkintaylor/rastr/archive/f84ab299005d87e37339d53759e005e85c9e5ad2.zip
8
+ Project-URL: Source Archive, https://github.com/tonkintaylor/rastr/archive/68715eeb3fa4dca80a0999df65ebf2f9c7daca6d.zip
9
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
10
  License-Expression: MIT
11
11
  License-File: LICENSE
@@ -39,9 +39,8 @@ Description-Content-Type: text/markdown
39
39
  # rastr
40
40
 
41
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)
42
+ [![PyPI Supported Versions](https://img.shields.io/pypi/pyversions/rastr.svg)](https://pypi.python.org/pypi/rastr)
43
+ ![PyPI License](https://img.shields.io/pypi/l/rastr.svg)
45
44
 
46
45
  A lightweight geospatial raster datatype library for Python focused on simplicity.
47
46
 
@@ -73,9 +72,8 @@ pip install rastr
73
72
  ```python
74
73
  from pyproj.crs.crs import CRS
75
74
  from rasterio.transform import from_origin
75
+ from rastr import Raster, RasterMeta
76
76
  from rastr.create import full_raster
77
- from rastr.meta import RasterMeta
78
- from rastr.raster import Raster
79
77
 
80
78
  # Read a raster from a file
81
79
  raster = Raster.read_file("path/to/raster.tif")
@@ -0,0 +1,17 @@
1
+ rastr/__init__.py,sha256=z26KywZdRKwO-N5Qc34SuuGGwH8Y812csKORc3S4SYU,113
2
+ rastr/_version.py,sha256=Rttl-BDadtcW1QzGnNffCWA_Wc9mUKDMOBPZp--Mnsc,704
3
+ rastr/create.py,sha256=a2T-kv6Zi0Pnx65kCq9oHBPOeIhS1mtioqTm_XoRRU0,18312
4
+ rastr/io.py,sha256=Tui_1AzmGEkvyDrNEHVK6S-I5OEjh36lZqNRwA9effQ,7722
5
+ rastr/meta.py,sha256=ex4GVIwkJza8qZGJiEvMMstx4HOum0kHPCnl9PzrFkw,5980
6
+ rastr/raster.py,sha256=zTwyUPGZAgEBjawg0i6dTR6knrFYrzvH7sUaF_DuMuU,59078
7
+ rastr/arr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ rastr/arr/fill.py,sha256=ZSd9mcfzYafkAes2G2q8hJGlxhW47kI2brPf--jds3o,1029
9
+ rastr/gis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ rastr/gis/crs.py,sha256=9K57Ys6P32v0uzap-l7L_HbjolJMX-ETuRB_rN30Qz0,1953
11
+ rastr/gis/fishnet.py,sha256=J6hQEBFSWaSTg6dC9f6ppDxTk-cV2UVmX-O35T9Yj7w,3143
12
+ rastr/gis/interpolate.py,sha256=osRobeMx1QntggbNv-K3HDrWLUS7WO_XPp8nrMiPqVE,1799
13
+ rastr/gis/smooth.py,sha256=bGNBFs7PYW_J_vzcX0h2VL2R0o4dX3Da_nEm7Pjivlw,5295
14
+ rastr-0.8.0.dist-info/METADATA,sha256=EiigwvI6q_QFgJHMWmJ1iMHXE9Zm25SH9GTCbbyjsz8,5369
15
+ rastr-0.8.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
+ rastr-0.8.0.dist-info/licenses/LICENSE,sha256=7qUsx93G2ATTRLZiSYuQofwAX_uWvrqnAiMK8PzxvNc,1080
17
+ rastr-0.8.0.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- rastr/__init__.py,sha256=z26KywZdRKwO-N5Qc34SuuGGwH8Y812csKORc3S4SYU,113
2
- rastr/_version.py,sha256=az9tfX88VBVbBBILFx2zOlpMh1RBTXIwP6baQTMKtHc,704
3
- rastr/create.py,sha256=jT2X7mgJoMapnRz-M11dJoKFidaf0k_qleR5zxnRAnw,13195
4
- rastr/io.py,sha256=RPhypnSNhLaWYdGRzctM9aTXbw9_TuMjvhMvDyUZavk,3640
5
- rastr/meta.py,sha256=lUZVodFzhnzLI1sr7SgiM9XN9D-n7nXvs0voWTJYlMg,5980
6
- rastr/raster.py,sha256=9yib1g0HOzPepaLCu_ApEbfUynb0IjQzCD__FEOco1c,54219
7
- rastr/arr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- rastr/arr/fill.py,sha256=ZSd9mcfzYafkAes2G2q8hJGlxhW47kI2brPf--jds3o,1029
9
- rastr/gis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- rastr/gis/crs.py,sha256=9K57Ys6P32v0uzap-l7L_HbjolJMX-ETuRB_rN30Qz0,1953
11
- rastr/gis/fishnet.py,sha256=nAiJ_DuSQP326pLM9JmI8A4QwWWgVu7Mae1K1dWjDc4,3108
12
- rastr/gis/interpolate.py,sha256=DzjtD5ynnwKP7TrwPiK3P0dOy5ZRzME9bV8-7tn5TFk,1697
13
- rastr/gis/smooth.py,sha256=bGNBFs7PYW_J_vzcX0h2VL2R0o4dX3Da_nEm7Pjivlw,5295
14
- rastr-0.7.1.dist-info/METADATA,sha256=-MvdAkql2szGVK4jAI2Kw3evRhIYq3daQBmvPAfpJ0o,5724
15
- rastr-0.7.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
- rastr-0.7.1.dist-info/licenses/LICENSE,sha256=7qUsx93G2ATTRLZiSYuQofwAX_uWvrqnAiMK8PzxvNc,1080
17
- rastr-0.7.1.dist-info/RECORD,,
File without changes