rastr 0.3.0__py3-none-any.whl → 0.5.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.3.0'
32
- __version_tuple__ = version_tuple = (0, 3, 0)
31
+ __version__ = version = '0.5.0'
32
+ __version_tuple__ = version_tuple = (0, 5, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
rastr/create.py CHANGED
@@ -13,7 +13,7 @@ from shapely.geometry import Point
13
13
 
14
14
  from rastr.gis.fishnet import create_point_grid, get_point_grid_shape
15
15
  from rastr.meta import RasterMeta
16
- from rastr.raster import RasterModel
16
+ from rastr.raster import Raster
17
17
 
18
18
  if TYPE_CHECKING:
19
19
  from collections.abc import Iterable
@@ -49,9 +49,9 @@ def raster_distance_from_polygon(
49
49
  *,
50
50
  raster_meta: RasterMeta,
51
51
  extent_polygon: Polygon | None = None,
52
- snap_raster: RasterModel | None = None,
52
+ snap_raster: Raster | None = None,
53
53
  show_pbar: bool = False,
54
- ) -> RasterModel:
54
+ ) -> Raster:
55
55
  """Make a raster where each cell's value is its centre's distance to a polygon.
56
56
 
57
57
  The raster should use a projected coordinate system.
@@ -116,7 +116,7 @@ def raster_distance_from_polygon(
116
116
  distances = np.where(mask, np.array([polygon.distance(pt) for pt in _pts]), np.nan)
117
117
  distance_raster = distances.reshape(x.shape)
118
118
 
119
- return RasterModel(arr=distance_raster, raster_meta=raster_meta)
119
+ return Raster(arr=distance_raster, raster_meta=raster_meta)
120
120
 
121
121
 
122
122
  def _pbar(iterable: Iterable[_T], *, desc: str | None = None) -> Iterable[_T]:
@@ -130,11 +130,11 @@ def full_raster(
130
130
  *,
131
131
  bounds: tuple[float, float, float, float],
132
132
  fill_value: float = np.nan,
133
- ) -> RasterModel:
133
+ ) -> Raster:
134
134
  """Create a raster with a specified fill value for all cells."""
135
135
  shape = get_point_grid_shape(bounds=bounds, cell_size=raster_meta.cell_size)
136
136
  arr = np.full(shape, fill_value, dtype=np.float32)
137
- return RasterModel(arr=arr, raster_meta=raster_meta)
137
+ return Raster(arr=arr, raster_meta=raster_meta)
138
138
 
139
139
 
140
140
  def rasterize_gdf(
@@ -142,7 +142,7 @@ def rasterize_gdf(
142
142
  *,
143
143
  raster_meta: RasterMeta,
144
144
  target_cols: list[str],
145
- ) -> list[RasterModel]:
145
+ ) -> list[Raster]:
146
146
  """Rasterize geometries from a GeoDataFrame.
147
147
 
148
148
  Supports polygons, points, linestrings, and other geometry types.
@@ -205,8 +205,8 @@ def rasterize_gdf(
205
205
  dtype=np.float32,
206
206
  )
207
207
 
208
- # Create RasterModel
209
- raster = RasterModel(arr=raster_array, raster_meta=raster_meta)
208
+ # Create Raster
209
+ raster = Raster(arr=raster_array, raster_meta=raster_meta)
210
210
  rasters.append(raster)
211
211
 
212
212
  return rasters
@@ -286,7 +286,7 @@ def raster_from_point_cloud(
286
286
  *,
287
287
  crs: CRS | str,
288
288
  cell_size: float | None = None,
289
- ) -> RasterModel:
289
+ ) -> Raster:
290
290
  """Create a raster from a point cloud via interpolation.
291
291
 
292
292
  Interpolation is only possible within the convex hull of the points. Outside of
@@ -320,8 +320,18 @@ def raster_from_point_cloud(
320
320
  if len(x) != len(y) or len(x) != len(z):
321
321
  msg = "Length of x, y, and z must be equal."
322
322
  raise ValueError(msg)
323
+ xy_finite_mask = np.isfinite(x) & np.isfinite(y)
324
+ if np.any(~xy_finite_mask):
325
+ msg = "Some (x,y) points are NaN-valued or non-finite. These will be ignored."
326
+ warnings.warn(msg, stacklevel=2)
327
+ x = x[xy_finite_mask]
328
+ y = y[xy_finite_mask]
329
+ z = z[xy_finite_mask]
323
330
  if len(x) < 3:
324
- msg = "At least three (x, y, z) points are required to triangulate a surface."
331
+ msg = (
332
+ "At least three valid (x, y, z) points are required to triangulate a "
333
+ "surface."
334
+ )
325
335
  raise ValueError(msg)
326
336
  # Check for duplicate (x, y) points
327
337
  xy_points = np.column_stack((x, y))
@@ -332,8 +342,8 @@ def raster_from_point_cloud(
332
342
  # Heuristic for cell size if not provided
333
343
  if cell_size is None:
334
344
  # 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)
345
+ tree = KDTree(xy_points)
346
+ distances, _ = tree.query(xy_points, k=2)
337
347
  distances: np.ndarray
338
348
  cell_size = float(np.percentile(distances[distances > 0], 5)) / 2
339
349
 
@@ -378,4 +388,4 @@ def raster_from_point_cloud(
378
388
  crs=crs,
379
389
  transform=transform,
380
390
  )
381
- return RasterModel(arr=arr, raster_meta=raster_meta)
391
+ return Raster(arr=arr, raster_meta=raster_meta)
rastr/io.py CHANGED
@@ -9,7 +9,7 @@ import rasterio.merge
9
9
  from pyproj.crs.crs import CRS
10
10
 
11
11
  from rastr.meta import RasterMeta
12
- from rastr.raster import RasterModel
12
+ from rastr.raster import Raster
13
13
 
14
14
  if TYPE_CHECKING:
15
15
  from numpy.typing import NDArray
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
17
17
 
18
18
  def read_raster_inmem(
19
19
  raster_path: Path | str, *, crs: CRS | str | None = None
20
- ) -> RasterModel:
20
+ ) -> Raster:
21
21
  """Read raster data from a file and return an in-memory Raster object."""
22
22
  crs = CRS.from_user_input(crs) if crs is not None else None
23
23
 
@@ -35,13 +35,13 @@ def read_raster_inmem(
35
35
  arr[arr == nodata] = np.nan
36
36
 
37
37
  raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
38
- raster_obj = RasterModel(arr=arr, raster_meta=raster_meta)
38
+ raster_obj = Raster(arr=arr, raster_meta=raster_meta)
39
39
  return raster_obj
40
40
 
41
41
 
42
42
  def read_raster_mosaic_inmem(
43
43
  mosaic_dir: Path | str, *, glob: str = "*.tif", crs: CRS | None = None
44
- ) -> RasterModel:
44
+ ) -> Raster:
45
45
  """Read a raster mosaic from a directory and return an in-memory Raster object.
46
46
 
47
47
  This assumes that all rasters have the same metadata, e.g. coordinate system,
@@ -87,7 +87,7 @@ def read_raster_mosaic_inmem(
87
87
  arr = arr.squeeze().astype(np.float64)
88
88
 
89
89
  raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
90
- raster_obj = RasterModel(arr=arr, raster_meta=raster_meta)
90
+ raster_obj = Raster(arr=arr, raster_meta=raster_meta)
91
91
  return raster_obj
92
92
  finally:
93
93
  for src in sources:
rastr/raster.py CHANGED
@@ -4,17 +4,20 @@ from __future__ import annotations
4
4
 
5
5
  import importlib.util
6
6
  import warnings
7
+ from collections.abc import Collection
7
8
  from contextlib import contextmanager
8
9
  from pathlib import Path
9
- from typing import TYPE_CHECKING, Any, Literal
10
+ from typing import TYPE_CHECKING, Any, Literal, overload
10
11
 
11
12
  import numpy as np
12
13
  import numpy.ma
14
+ import rasterio.features
13
15
  import rasterio.plot
14
16
  import rasterio.sample
15
17
  import rasterio.transform
16
18
  import skimage.measure
17
19
  from pydantic import BaseModel, InstanceOf, field_validator
20
+ from pyproj import Transformer
18
21
  from pyproj.crs.crs import CRS
19
22
  from rasterio.enums import Resampling
20
23
  from rasterio.io import MemoryFile
@@ -29,10 +32,14 @@ if TYPE_CHECKING:
29
32
  from collections.abc import Callable, Generator
30
33
 
31
34
  import geopandas as gpd
35
+ from affine import Affine
36
+ from branca.colormap import LinearColormap as BrancaLinearColormap
32
37
  from folium import Map
33
38
  from matplotlib.axes import Axes
39
+ from matplotlib.image import AxesImage
34
40
  from numpy.typing import ArrayLike, NDArray
35
41
  from rasterio.io import BufferedDatasetWriter, DatasetReader, DatasetWriter
42
+ from shapely import MultiPolygon
36
43
  from typing_extensions import Self
37
44
 
38
45
  try:
@@ -45,17 +52,28 @@ FOLIUM_INSTALLED = importlib.util.find_spec("folium") is not None
45
52
  BRANCA_INSTALLED = importlib.util.find_spec("branca") is not None
46
53
  MATPLOTLIB_INSTALLED = importlib.util.find_spec("matplotlib") is not None
47
54
 
55
+ CONTOUR_PERTURB_EPS = 1e-10
56
+
48
57
 
49
58
  class RasterCellArrayShapeError(ValueError):
50
59
  """Custom error for invalid raster cell array shapes."""
51
60
 
52
61
 
53
- class RasterModel(BaseModel):
62
+ class Raster(BaseModel):
54
63
  """2-dimensional raster and metadata."""
55
64
 
56
65
  arr: InstanceOf[np.ndarray]
57
66
  raster_meta: RasterMeta
58
67
 
68
+ @field_validator("arr")
69
+ @classmethod
70
+ def check_2d_array(cls, v: NDArray) -> NDArray:
71
+ """Validator to ensure the cell array is 2D."""
72
+ if v.ndim != 2:
73
+ msg = "Cell array must be 2D"
74
+ raise RasterCellArrayShapeError(msg)
75
+ return v
76
+
59
77
  @property
60
78
  def meta(self) -> RasterMeta:
61
79
  """Alias for raster_meta."""
@@ -65,6 +83,31 @@ class RasterModel(BaseModel):
65
83
  def meta(self, value: RasterMeta) -> None:
66
84
  self.raster_meta = value
67
85
 
86
+ @property
87
+ def shape(self) -> tuple[int, ...]:
88
+ """Shape of the raster array."""
89
+ return self.arr.shape
90
+
91
+ @property
92
+ def crs(self) -> CRS:
93
+ """Convenience property to access the CRS via meta."""
94
+ return self.meta.crs
95
+
96
+ @crs.setter
97
+ def crs(self, value: CRS) -> None:
98
+ """Set the CRS via meta."""
99
+ self.meta.crs = value
100
+
101
+ @property
102
+ def transform(self) -> Affine:
103
+ """Convenience property to access the transform via meta."""
104
+ return self.meta.transform
105
+
106
+ @transform.setter
107
+ def transform(self, value: Affine) -> None:
108
+ """Set the transform via meta."""
109
+ self.meta.transform = value
110
+
68
111
  def __init__(
69
112
  self,
70
113
  *,
@@ -93,14 +136,25 @@ class RasterModel(BaseModel):
93
136
  super().__init__(arr=arr, raster_meta=raster_meta)
94
137
 
95
138
  def __eq__(self, other: object) -> bool:
96
- """Check equality of two RasterModel objects."""
97
- if not isinstance(other, RasterModel):
139
+ """Check equality of two Raster objects."""
140
+ if not isinstance(other, Raster):
98
141
  return NotImplemented
99
142
  return (
100
143
  np.array_equal(self.arr, other.arr)
101
144
  and self.raster_meta == other.raster_meta
102
145
  )
103
146
 
147
+ def is_like(self, other: Raster) -> bool:
148
+ """Check if two Raster objects have the same metadata and shape.
149
+
150
+ Args:
151
+ other: Another Raster to compare with.
152
+
153
+ Returns:
154
+ True if both rasters have the same meta and shape attributes.
155
+ """
156
+ return self.meta == other.meta and self.shape == other.shape
157
+
104
158
  __hash__ = BaseModel.__hash__
105
159
 
106
160
  def __add__(self, other: float | Self) -> Self:
@@ -108,7 +162,7 @@ class RasterModel(BaseModel):
108
162
  if isinstance(other, float | int):
109
163
  new_arr = self.arr + other
110
164
  return cls(arr=new_arr, raster_meta=self.raster_meta)
111
- elif isinstance(other, RasterModel):
165
+ elif isinstance(other, Raster):
112
166
  if self.raster_meta != other.raster_meta:
113
167
  msg = (
114
168
  "Rasters must have the same metadata (e.g. CRS, cell size, etc.) "
@@ -134,7 +188,7 @@ class RasterModel(BaseModel):
134
188
  if isinstance(other, float | int):
135
189
  new_arr = self.arr * other
136
190
  return cls(arr=new_arr, raster_meta=self.raster_meta)
137
- elif isinstance(other, RasterModel):
191
+ elif isinstance(other, Raster):
138
192
  if self.raster_meta != other.raster_meta:
139
193
  msg = (
140
194
  "Rasters must have the same metadata (e.g. CRS, cell size, etc.) "
@@ -157,7 +211,7 @@ class RasterModel(BaseModel):
157
211
  if isinstance(other, float | int):
158
212
  new_arr = self.arr / other
159
213
  return cls(arr=new_arr, raster_meta=self.raster_meta)
160
- elif isinstance(other, RasterModel):
214
+ elif isinstance(other, Raster):
161
215
  if self.raster_meta != other.raster_meta:
162
216
  msg = (
163
217
  "Rasters must have the same metadata (e.g. CRS, cell size, etc.) "
@@ -193,12 +247,12 @@ class RasterModel(BaseModel):
193
247
  @property
194
248
  def cell_x_coords(self) -> NDArray[np.float64]:
195
249
  """Get the x coordinates of the cell centres in the raster."""
196
- return self.raster_meta.get_cell_x_coords(self.arr.shape[0])
250
+ return self.raster_meta.get_cell_x_coords(self.arr.shape[1])
197
251
 
198
252
  @property
199
253
  def cell_y_coords(self) -> NDArray[np.float64]:
200
254
  """Get the y coordinates of the cell centres in the raster."""
201
- return self.raster_meta.get_cell_y_coords(self.arr.shape[1])
255
+ return self.raster_meta.get_cell_y_coords(self.arr.shape[0])
202
256
 
203
257
  @contextmanager
204
258
  def to_rasterio_dataset(
@@ -207,7 +261,7 @@ class RasterModel(BaseModel):
207
261
  """Create a rasterio in-memory dataset from the Raster object.
208
262
 
209
263
  Example:
210
- >>> raster = RasterModel.example()
264
+ >>> raster = Raster.example()
211
265
  >>> with raster.to_rasterio_dataset() as dataset:
212
266
  >>> ...
213
267
  """
@@ -233,12 +287,30 @@ class RasterModel(BaseModel):
233
287
  finally:
234
288
  memfile.close()
235
289
 
290
+ @overload
236
291
  def sample(
237
292
  self,
238
- xy: list[tuple[float, float]] | list[Point] | ArrayLike,
293
+ xy: Collection[tuple[float, float]] | Collection[Point] | ArrayLike,
239
294
  *,
240
295
  na_action: Literal["raise", "ignore"] = "raise",
241
- ) -> NDArray[np.float64]:
296
+ ) -> NDArray: ...
297
+ @overload
298
+ def sample(
299
+ self,
300
+ xy: tuple[float, float] | Point,
301
+ *,
302
+ na_action: Literal["raise", "ignore"] = "raise",
303
+ ) -> float: ...
304
+ def sample(
305
+ self,
306
+ xy: Collection[tuple[float, float]]
307
+ | Collection[Point]
308
+ | ArrayLike
309
+ | tuple[float, float]
310
+ | Point,
311
+ *,
312
+ na_action: Literal["raise", "ignore"] = "raise",
313
+ ) -> NDArray | float:
242
314
  """Sample raster values at GeoSeries locations and return sampled values.
243
315
 
244
316
  Args:
@@ -255,13 +327,30 @@ class RasterModel(BaseModel):
255
327
  # https://rdrn.me/optimising-sampling/
256
328
 
257
329
  # Convert shapely Points to coordinate tuples if needed
258
- if isinstance(xy, (list, tuple)):
259
- xy = [_get_xy_tuple(point) for point in xy]
330
+ if isinstance(xy, Point):
331
+ xy = [(xy.x, xy.y)]
332
+ singleton = True
333
+ elif (
334
+ isinstance(xy, Collection)
335
+ and len(xy) > 0
336
+ and isinstance(next(iter(xy)), Point)
337
+ ):
338
+ xy = [(point.x, point.y) for point in xy] # pyright: ignore[reportAttributeAccessIssue]
339
+ singleton = False
340
+ elif (
341
+ isinstance(xy, tuple)
342
+ and len(xy) == 2
343
+ and isinstance(next(iter(xy)), (float, int))
344
+ ):
345
+ xy = [xy] # pyright: ignore[reportAssignmentType]
346
+ singleton = True
347
+ else:
348
+ singleton = False
260
349
 
261
350
  xy = np.asarray(xy, dtype=float)
262
351
 
263
- # Short-circuit
264
352
  if len(xy) == 0:
353
+ # Short-circuit
265
354
  return np.array([], dtype=float)
266
355
 
267
356
  # Create in-memory rasterio dataset from the incumbent Raster object
@@ -311,6 +400,10 @@ class RasterModel(BaseModel):
311
400
  axis=0,
312
401
  )
313
402
 
403
+ if singleton:
404
+ (raster_value,) = raster_values
405
+ return raster_value
406
+
314
407
  return raster_values
315
408
 
316
409
  @property
@@ -339,13 +432,16 @@ class RasterModel(BaseModel):
339
432
  ]
340
433
  )
341
434
 
342
- def explore(
435
+ def explore( # noqa: PLR0913 c.f. geopandas.explore which also has many input args
343
436
  self,
344
437
  *,
345
438
  m: Map | None = None,
346
439
  opacity: float = 1.0,
347
- colormap: str = "viridis",
440
+ colormap: str
441
+ | Callable[[float], tuple[float, float, float, float]] = "viridis",
348
442
  cbar_label: str | None = None,
443
+ vmin: float | None = None,
444
+ vmax: float | None = None,
349
445
  ) -> Map:
350
446
  """Display the raster on a folium map."""
351
447
  if not FOLIUM_INSTALLED or not MATPLOTLIB_INSTALLED:
@@ -353,39 +449,44 @@ class RasterModel(BaseModel):
353
449
  raise ImportError(msg)
354
450
 
355
451
  import folium.raster_layers
356
- import geopandas as gpd
357
452
  import matplotlib as mpl
358
453
 
359
454
  if m is None:
360
455
  m = folium.Map()
361
456
 
362
- rgba_map: Callable[[float], tuple[float, float, float, float]] = mpl.colormaps[
363
- colormap
364
- ]
457
+ if vmin is not None and vmax is not None and vmax <= vmin:
458
+ msg = "'vmin' must be less than 'vmax'."
459
+ raise ValueError(msg)
460
+
461
+ if isinstance(colormap, str):
462
+ colormap = mpl.colormaps[colormap]
365
463
 
366
- # Cast to GDF to facilitate converting bounds to WGS84
464
+ # Transform bounds to WGS84 using pyproj directly
367
465
  wgs84_crs = CRS.from_epsg(4326)
368
- gdf = gpd.GeoDataFrame(geometry=[self.bbox], crs=self.raster_meta.crs).to_crs(
369
- wgs84_crs
466
+ transformer = Transformer.from_crs(
467
+ self.raster_meta.crs, wgs84_crs, always_xy=True
370
468
  )
371
- xmin, ymin, xmax, ymax = gdf.total_bounds
372
469
 
373
- arr = np.array(self.arr)
470
+ # Get the corner points of the bounding box
471
+ raster_xmin, raster_ymin, raster_xmax, raster_ymax = self.bounds
472
+ corner_points = [
473
+ (raster_xmin, raster_ymin),
474
+ (raster_xmin, raster_ymax),
475
+ (raster_xmax, raster_ymax),
476
+ (raster_xmax, raster_ymin),
477
+ ]
478
+
479
+ # Transform all corner points to WGS84
480
+ transformed_points = [transformer.transform(x, y) for x, y in corner_points]
374
481
 
375
- # Normalize the data to the range [0, 1] as this is the cmap range
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)
482
+ # Find the bounding box of the transformed points
483
+ transformed_xs, transformed_ys = zip(*transformed_points, strict=True)
484
+ xmin, xmax = min(transformed_xs), max(transformed_xs)
485
+ ymin, ymax = min(transformed_ys), max(transformed_ys)
384
486
 
385
- if max_val > min_val: # Prevent division by zero
386
- arr = (arr - min_val) / (max_val - min_val)
387
- else:
388
- arr = np.zeros_like(arr) # In case all values are the same
487
+ # Normalize the array to [0, 1] for colormap mapping
488
+ _vmin, _vmax = _get_vmin_vmax(self, vmin=vmin, vmax=vmax)
489
+ arr = self.normalize(vmin=_vmin, vmax=_vmax).arr
389
490
 
390
491
  # Finally, need to determine whether to flip the image based on negative Affine
391
492
  # coefficients
@@ -396,11 +497,12 @@ class RasterModel(BaseModel):
396
497
  if flip_y:
397
498
  arr = np.flip(arr, axis=0)
398
499
 
500
+ bounds = [[ymin, xmin], [ymax, xmax]]
399
501
  img = folium.raster_layers.ImageOverlay(
400
502
  image=arr,
401
- bounds=[[ymin, xmin], [ymax, xmax]],
503
+ bounds=bounds,
402
504
  opacity=opacity,
403
- colormap=rgba_map,
505
+ colormap=colormap,
404
506
  mercator_project=True,
405
507
  )
406
508
 
@@ -408,26 +510,39 @@ class RasterModel(BaseModel):
408
510
 
409
511
  # Add a colorbar legend
410
512
  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)
513
+ cbar = _map_colorbar(colormap=colormap, vmin=_vmin, vmax=_vmax)
423
514
  if cbar_label:
424
- legend.caption = cbar_label
425
- legend.add_to(m)
515
+ cbar.caption = cbar_label
516
+ cbar.add_to(m)
426
517
 
427
- m.fit_bounds([[ymin, xmin], [ymax, xmax]])
518
+ m.fit_bounds(bounds)
428
519
 
429
520
  return m
430
521
 
522
+ def normalize(
523
+ self, *, vmin: float | None = None, vmax: float | None = None
524
+ ) -> Self:
525
+ """Normalize the raster values to the range [0, 1].
526
+
527
+ If custom vmin and vmax values are provided, values below vmin will be set to 0,
528
+ and values above vmax will be set to 1.
529
+
530
+ Args:
531
+ vmin: Minimum value for normalization. Values below this will be set to 0.
532
+ If None, the minimum value in the array is used.
533
+ vmax: Maximum value for normalization. Values above this will be set to 1.
534
+ If None, the maximum value in the array is used.
535
+ """
536
+ _vmin, _vmax = _get_vmin_vmax(self, vmin=vmin, vmax=vmax)
537
+
538
+ arr = self.arr.copy()
539
+ if _vmax > _vmin:
540
+ arr = (arr - _vmin) / (_vmax - _vmin)
541
+ arr = np.clip(arr, 0, 1)
542
+ else:
543
+ arr = np.zeros_like(arr)
544
+ return self.__class__(arr=arr, raster_meta=self.raster_meta)
545
+
431
546
  def to_clipboard(self) -> None:
432
547
  """Copy the raster cell array to the clipboard."""
433
548
  import pandas as pd
@@ -441,8 +556,22 @@ class RasterModel(BaseModel):
441
556
  cbar_label: str | None = None,
442
557
  basemap: bool = False,
443
558
  cmap: str = "viridis",
559
+ suppressed: Collection[float] | float = tuple(),
560
+ **kwargs: Any,
444
561
  ) -> Axes:
445
- """Plot the raster on a matplotlib axis."""
562
+ """Plot the raster on a matplotlib axis.
563
+
564
+ Args:
565
+ ax: A matplotlib axes object to plot on. If None, a new figure will be
566
+ created.
567
+ cbar_label: Label for the colorbar. If None, no label is added.
568
+ basemap: Whether to add a basemap. Currently not implemented.
569
+ cmap: Colormap to use for the plot.
570
+ suppressed: Values to suppress from the plot (i.e. not display). This can be
571
+ useful for zeroes especially.
572
+ **kwargs: Additional keyword arguments to pass to `rasterio.plot.show()`.
573
+ This includes parameters like `alpha` for transparency.
574
+ """
446
575
  if not MATPLOTLIB_INSTALLED:
447
576
  msg = "The 'matplotlib' package is required for 'plot()'."
448
577
  raise ImportError(msg)
@@ -450,6 +579,8 @@ class RasterModel(BaseModel):
450
579
  from matplotlib import pyplot as plt
451
580
  from mpl_toolkits.axes_grid1 import make_axes_locatable
452
581
 
582
+ suppressed = np.array(suppressed)
583
+
453
584
  if ax is None:
454
585
  _, _ax = plt.subplots()
455
586
  _ax: Axes
@@ -459,33 +590,34 @@ class RasterModel(BaseModel):
459
590
  msg = "Basemap plotting is not yet implemented."
460
591
  raise NotImplementedError(msg)
461
592
 
462
- arr = self.arr.copy()
593
+ model = self.model_copy()
594
+ model.arr = model.arr.copy()
463
595
 
464
- # Get extent of the non-zero values in array index coordinates
465
- (x_nonzero,) = np.nonzero(arr.any(axis=0))
466
- (y_nonzero,) = np.nonzero(arr.any(axis=1))
596
+ # Get extent of the unsuppressed values in array index coordinates
597
+ suppressed_mask = np.isin(model.arr, suppressed)
598
+ (x_unsuppressed,) = np.nonzero((~suppressed_mask).any(axis=0))
599
+ (y_unsuppressed,) = np.nonzero((~suppressed_mask).any(axis=1))
467
600
 
468
- if len(x_nonzero) == 0 or len(y_nonzero) == 0:
469
- msg = "Raster contains no non-zero values; cannot plot."
601
+ if len(x_unsuppressed) == 0 or len(y_unsuppressed) == 0:
602
+ msg = "Raster contains no unsuppressed values; cannot plot."
470
603
  raise ValueError(msg)
471
604
 
472
- min_x_nonzero = np.min(x_nonzero)
473
- max_x_nonzero = np.max(x_nonzero)
474
- min_y_nonzero = np.min(y_nonzero)
475
- max_y_nonzero = np.max(y_nonzero)
605
+ # N.B. these are array index coordinates, so np.min and np.max are safe since
606
+ # they cannot encounter NaN values.
607
+ min_x_unsuppressed = np.min(x_unsuppressed)
608
+ max_x_unsuppressed = np.max(x_unsuppressed)
609
+ min_y_unsuppressed = np.min(y_unsuppressed)
610
+ max_y_unsuppressed = np.max(y_unsuppressed)
476
611
 
477
612
  # Transform to raster CRS
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]
613
+ x1, y1 = self.raster_meta.transform * (min_x_unsuppressed, min_y_unsuppressed) # type: ignore[reportAssignmentType] overloaded tuple size in affine
614
+ x2, y2 = self.raster_meta.transform * (max_x_unsuppressed, max_y_unsuppressed) # type: ignore[reportAssignmentType]
480
615
  xmin, xmax = sorted([x1, x2])
481
616
  ymin, ymax = sorted([y1, y2])
482
617
 
483
- arr[arr == 0] = np.nan
618
+ model.arr[suppressed_mask] = np.nan
484
619
 
485
- with self.to_rasterio_dataset() as dataset:
486
- img, *_ = rasterio.plot.show(
487
- dataset, with_bounds=True, ax=ax, cmap=cmap
488
- ).get_images()
620
+ img, *_ = model.rio_show(ax=ax, cmap=cmap, with_bounds=True, **kwargs)
489
621
 
490
622
  ax.set_xlim(xmin, xmax)
491
623
  ax.set_ylim(ymin, ymax)
@@ -501,6 +633,20 @@ class RasterModel(BaseModel):
501
633
  fig.colorbar(img, label=cbar_label, cax=cax)
502
634
  return ax
503
635
 
636
+ def rio_show(self, **kwargs: Any) -> list[AxesImage]:
637
+ """Plot the raster using rasterio's built-in plotting function.
638
+
639
+ This is useful for lower-level access to rasterio's plotting capabilities.
640
+ Generally, the `plot()` method is preferred for most use cases.
641
+
642
+ Args:
643
+ **kwargs: Keyword arguments to pass to `rasterio.plot.show()`. This includes
644
+ parameters like `alpha` for transparency, and `with_bounds` to control
645
+ whether to plot in spatial coordinates or array index coordinates.
646
+ """
647
+ with self.to_rasterio_dataset() as dataset:
648
+ return rasterio.plot.show(dataset, **kwargs).get_images()
649
+
504
650
  def as_geodataframe(self, name: str = "value") -> gpd.GeoDataFrame:
505
651
  """Create a GeoDataFrame representation of the raster."""
506
652
  import geopandas as gpd
@@ -561,7 +707,7 @@ class RasterModel(BaseModel):
561
707
 
562
708
  @classmethod
563
709
  def example(cls) -> Self:
564
- """Create an example RasterModel."""
710
+ """Create an example Raster."""
565
711
  # Peaks dataset style example
566
712
  n = 256
567
713
  x = np.linspace(-3, 3, n)
@@ -573,6 +719,96 @@ class RasterModel(BaseModel):
573
719
  raster_meta = RasterMeta.example()
574
720
  return cls(arr=arr, raster_meta=raster_meta)
575
721
 
722
+ @overload
723
+ def apply(
724
+ self,
725
+ func: Callable[[np.ndarray], np.ndarray],
726
+ *,
727
+ raw: Literal[True],
728
+ ) -> Self: ...
729
+ @overload
730
+ def apply(
731
+ self,
732
+ func: Callable[[float], float] | Callable[[np.ndarray], np.ndarray],
733
+ *,
734
+ raw: Literal[False] = False,
735
+ ) -> Self: ...
736
+ def apply(self, func, *, raw=False) -> Self:
737
+ """Apply a function element-wise to the raster array.
738
+
739
+ Creates a new raster instance with the same metadata (CRS, transform, etc.)
740
+ but with the data array transformed by the provided function. The original
741
+ raster is not modified.
742
+
743
+ Args:
744
+ func: The function to apply to the raster array. If `raw` is True, this
745
+ function should accept and return a NumPy array. If `raw` is False,
746
+ this function should accept and return a single float value.
747
+ raw: If True, the function is applied directly to the entire array at
748
+ once. If False, the function is applied element-wise to each cell
749
+ in the array using `np.vectorize()`. Default is False.
750
+ """
751
+ new_raster = self.model_copy()
752
+ if raw:
753
+ new_arr = func(self.arr)
754
+ else:
755
+ new_arr = np.vectorize(func)(self.arr)
756
+ new_raster.arr = np.asarray(new_arr)
757
+ return new_raster
758
+
759
+ def max(self) -> float:
760
+ """Get the maximum value in the raster, ignoring NaN values.
761
+
762
+ Returns:
763
+ The maximum value in the raster. Returns NaN if all values are NaN.
764
+ """
765
+ return float(np.nanmax(self.arr))
766
+
767
+ def min(self) -> float:
768
+ """Get the minimum value in the raster, ignoring NaN values.
769
+
770
+ Returns:
771
+ The minimum value in the raster. Returns NaN if all values are NaN.
772
+ """
773
+ return float(np.nanmin(self.arr))
774
+
775
+ def mean(self) -> float:
776
+ """Get the mean value in the raster, ignoring NaN values.
777
+
778
+ Returns:
779
+ The mean value in the raster. Returns NaN if all values are NaN.
780
+ """
781
+ return float(np.nanmean(self.arr))
782
+
783
+ def std(self) -> float:
784
+ """Get the standard deviation of values in the raster, ignoring NaN values.
785
+
786
+ Returns:
787
+ The standard deviation of the raster. Returns NaN if all values are NaN.
788
+ """
789
+ return float(np.nanstd(self.arr))
790
+
791
+ def quantile(self, q: float) -> float:
792
+ """Get the specified quantile value in the raster, ignoring NaN values.
793
+
794
+ Args:
795
+ q: Quantile to compute, must be between 0 and 1 inclusive.
796
+
797
+ Returns:
798
+ The quantile value. Returns NaN if all values are NaN.
799
+ """
800
+ return float(np.nanquantile(self.arr, q))
801
+
802
+ def median(self) -> float:
803
+ """Get the median value in the raster, ignoring NaN values.
804
+
805
+ This is equivalent to quantile(0.5).
806
+
807
+ Returns:
808
+ The median value in the raster. Returns NaN if all values are NaN.
809
+ """
810
+ return float(np.nanmedian(self.arr))
811
+
576
812
  def fillna(self, value: float) -> Self:
577
813
  """Fill NaN values in the raster with a specified value.
578
814
 
@@ -599,12 +835,14 @@ class RasterModel(BaseModel):
599
835
  return coords[:, :, 0], coords[:, :, 1]
600
836
 
601
837
  def contour(
602
- self, *, levels: list[float] | NDArray, smoothing: bool = True
838
+ self, levels: list[float] | NDArray, *, smoothing: bool = True
603
839
  ) -> gpd.GeoDataFrame:
604
840
  """Create contour lines from the raster data, optionally with smoothing.
605
841
 
606
- The contour lines are returned as a GeoDataFrame with the contours as linestring
607
- geometries and the contour levels as attributes in a column named 'level'.
842
+ The contour lines are returned as a GeoDataFrame with the contours dissolved
843
+ by level, resulting in one row per contour level. Each row contains a
844
+ (Multi)LineString geometry representing all contour lines for that level,
845
+ and the contour level value in a column named 'level'.
608
846
 
609
847
  Consider calling `blur()` before this method to smooth the raster data before
610
848
  contouring, to denoise the contours.
@@ -622,12 +860,20 @@ class RasterModel(BaseModel):
622
860
  all_levels = []
623
861
  all_geoms = []
624
862
  for level in levels:
863
+ # If this is the maximum or minimum level, perturb it ever-so-slightly to
864
+ # ensure we get contours at the edges of the raster
865
+ perturbed_level = level
866
+ if level == self.max():
867
+ perturbed_level -= CONTOUR_PERTURB_EPS
868
+ elif level == self.min():
869
+ perturbed_level += CONTOUR_PERTURB_EPS
870
+
625
871
  contours = skimage.measure.find_contours(
626
872
  self.arr,
627
- level=level,
873
+ level=perturbed_level,
628
874
  )
629
875
 
630
- # Constructg shapely LineString objects
876
+ # Construct shapely LineString objects
631
877
  # Convert to CRS from array index coordinates to raster CRS
632
878
  geoms = [
633
879
  LineString(
@@ -656,7 +902,8 @@ class RasterModel(BaseModel):
656
902
  crs=self.raster_meta.crs,
657
903
  )
658
904
 
659
- return contour_gdf
905
+ # Dissolve contours by level to merge all contour lines of the same level
906
+ return contour_gdf.dissolve(by="level", as_index=False)
660
907
 
661
908
  def blur(self, sigma: float) -> Self:
662
909
  """Apply a Gaussian blur to the raster data.
@@ -696,12 +943,78 @@ class RasterModel(BaseModel):
696
943
 
697
944
  return raster
698
945
 
946
+ def pad(self, width: float, *, value: float = np.nan) -> Self:
947
+ """Extend the raster by adding a constant fill value around the edges.
948
+
949
+ By default, the padding value is NaN, but this can be changed via the
950
+ `value` parameter.
951
+
952
+ This grows the raster by adding padding around all edges. New cells are
953
+ filled with the constant `value`.
954
+
955
+ If the width is not an exact multiple of the cell size, the padding may be
956
+ slightly larger than the specified width, i.e. the value is rounded up to
957
+ the nearest whole number of cells.
958
+
959
+ Args:
960
+ width: The width of the padding, in the same units as the raster CRS
961
+ (e.g. meters). This defines how far from the edge the padding
962
+ extends.
963
+ value: The constant value to use for padding. Default is NaN.
964
+ """
965
+ cell_size = self.raster_meta.cell_size
966
+
967
+ # Calculate number of cells to pad in each direction
968
+ pad_cells = int(np.ceil(width / cell_size))
969
+
970
+ # Get current bounds
971
+ xmin, ymin, xmax, ymax = self.bounds
972
+
973
+ # Calculate new bounds with padding
974
+ new_xmin = xmin - (pad_cells * cell_size)
975
+ new_ymin = ymin - (pad_cells * cell_size)
976
+ new_xmax = xmax + (pad_cells * cell_size)
977
+ new_ymax = ymax + (pad_cells * cell_size)
978
+
979
+ # Create padded array
980
+ new_height = self.arr.shape[0] + 2 * pad_cells
981
+ new_width = self.arr.shape[1] + 2 * pad_cells
982
+
983
+ # Create new array filled with the padding value
984
+ padded_arr = np.full((new_height, new_width), value, dtype=self.arr.dtype)
985
+
986
+ # Copy original array into the center of the padded array
987
+ padded_arr[
988
+ pad_cells : pad_cells + self.arr.shape[0],
989
+ pad_cells : pad_cells + self.arr.shape[1],
990
+ ] = self.arr
991
+
992
+ # Create new transform for the padded raster
993
+ new_transform = rasterio.transform.from_bounds(
994
+ west=new_xmin,
995
+ south=new_ymin,
996
+ east=new_xmax,
997
+ north=new_ymax,
998
+ width=new_width,
999
+ height=new_height,
1000
+ )
1001
+
1002
+ # Create new raster metadata
1003
+ new_meta = RasterMeta(
1004
+ cell_size=cell_size,
1005
+ crs=self.raster_meta.crs,
1006
+ transform=new_transform,
1007
+ )
1008
+
1009
+ return self.__class__(arr=padded_arr, raster_meta=new_meta)
1010
+
699
1011
  def crop(
700
1012
  self,
701
1013
  bounds: tuple[float, float, float, float],
1014
+ *,
702
1015
  strategy: Literal["underflow", "overflow"] = "underflow",
703
1016
  ) -> Self:
704
- """Crop the raster to the specified bounds.
1017
+ """Crop the raster to the specified bounds as (minx, miny, maxx, maxy).
705
1018
 
706
1019
  Args:
707
1020
  bounds: A tuple of (minx, miny, maxx, maxy) defining the bounds to crop to.
@@ -712,7 +1025,7 @@ class RasterModel(BaseModel):
712
1025
  remains covered with cells.
713
1026
 
714
1027
  Returns:
715
- A new RasterModel instance cropped to the specified bounds.
1028
+ A new Raster instance cropped to the specified bounds.
716
1029
  """
717
1030
 
718
1031
  minx, miny, maxx, maxy = bounds
@@ -746,7 +1059,7 @@ class RasterModel(BaseModel):
746
1059
  raise NotImplementedError(msg)
747
1060
 
748
1061
  # Crop the array
749
- cropped_arr = arr[np.ix_(x_idx, y_idx)]
1062
+ cropped_arr = arr[np.ix_(y_idx, x_idx)]
750
1063
 
751
1064
  # Check the shape of the cropped array
752
1065
  if cropped_arr.size == 0:
@@ -772,6 +1085,141 @@ class RasterModel(BaseModel):
772
1085
  )
773
1086
  return cls(arr=cropped_arr, raster_meta=new_meta)
774
1087
 
1088
+ def taper_border(self, width: float, *, limit: float = 0.0) -> Self:
1089
+ """Taper values to a limiting value around the border of the raster.
1090
+
1091
+ By default, the borders are tapered to zero, but this can be changed via the
1092
+ `limit` parameter.
1093
+
1094
+ This keeps the raster size the same, overwriting values in the border area.
1095
+ To instead grow the raster, consider using `pad()` followed by `taper_border()`.
1096
+
1097
+ The tapering is linear from the cell centres around the border of the raster,
1098
+ so the value at the edge of the raster will be equal to `limit`.
1099
+
1100
+ Args:
1101
+ width: The width of the taper, in the same units as the raster CRS
1102
+ (e.g. meters). This defines how far from the edge the tapering
1103
+ starts.
1104
+ limit: The limiting value to taper to at the edges. Default is zero.
1105
+ """
1106
+
1107
+ # Determine the width in cell units (possibly fractional)
1108
+ cell_size = self.raster_meta.cell_size
1109
+ width_in_cells = width / cell_size
1110
+
1111
+ # Calculate the distance from the edge in cell units
1112
+ arr_height, arr_width = self.arr.shape
1113
+ y_indices, x_indices = np.indices((int(arr_height), int(arr_width)))
1114
+ dist_from_left = x_indices
1115
+ dist_from_right = arr_width - 1 - x_indices
1116
+ dist_from_top = y_indices
1117
+ dist_from_bottom = arr_height - 1 - y_indices
1118
+ dist_from_edge = np.minimum.reduce(
1119
+ [dist_from_left, dist_from_right, dist_from_top, dist_from_bottom]
1120
+ )
1121
+
1122
+ # Mask the arrays to only the area within the width from the edge, rounding up
1123
+ mask = dist_from_edge < np.ceil(width_in_cells)
1124
+ masked_dist_arr = np.where(mask, dist_from_edge, np.nan)
1125
+ masked_arr = np.where(mask, self.arr, np.nan)
1126
+
1127
+ # Calculate the tapering factor based on the distance from the edge
1128
+ taper_factor = np.clip(masked_dist_arr / width_in_cells, 0.0, 1.0)
1129
+ tapered_values = limit + (masked_arr - limit) * taper_factor
1130
+
1131
+ # Create the new raster array
1132
+ new_arr = self.arr.copy()
1133
+ new_arr[mask] = tapered_values[mask]
1134
+ new_raster = self.model_copy()
1135
+ new_raster.arr = new_arr
1136
+
1137
+ return new_raster
1138
+
1139
+ def clip(
1140
+ self,
1141
+ polygon: Polygon | MultiPolygon,
1142
+ *,
1143
+ strategy: Literal["centres"] = "centres",
1144
+ ) -> Self:
1145
+ """Clip the raster to the specified polygon, replacing cells outside with NaN.
1146
+
1147
+ The clipping strategy determines how to handle cells that are partially
1148
+ within the polygon. Currently, only the 'centres' strategy is supported, which
1149
+ retains cells whose centres fall within the polygon.
1150
+
1151
+ Args:
1152
+ polygon: A shapely Polygon or MultiPolygon defining the area to clip to.
1153
+ strategy: The clipping strategy to use. Currently only 'centres' is
1154
+ supported, which retains cells whose centres fall within the
1155
+ polygon.
1156
+
1157
+ Returns:
1158
+ A new Raster with cells outside the polygon set to NaN.
1159
+ """
1160
+ if strategy != "centres":
1161
+ msg = f"Unsupported clipping strategy: {strategy}"
1162
+ raise NotImplementedError(msg)
1163
+
1164
+ raster = self.model_copy()
1165
+
1166
+ mask = rasterio.features.rasterize(
1167
+ [(polygon, 1)],
1168
+ fill=0,
1169
+ out_shape=self.shape,
1170
+ transform=self.meta.transform,
1171
+ dtype=np.uint8,
1172
+ )
1173
+
1174
+ raster.arr = np.where(mask, raster.arr, np.nan)
1175
+
1176
+ return raster
1177
+
1178
+ def trim_nan(self) -> Self:
1179
+ """Crop the raster by trimming away all-NaN slices at the edges.
1180
+
1181
+ This effectively trims the raster to the smallest bounding box that contains all
1182
+ of the non-NaN values. Note that this does not guarantee no NaN values at all
1183
+ around the edges, only that there won't be entire edges which are all-NaN.
1184
+
1185
+ Consider using `.extrapolate()` for further cleanup of NaN values.
1186
+ """
1187
+ arr = self.arr
1188
+
1189
+ # Check if the entire array is NaN
1190
+ if np.all(np.isnan(arr)):
1191
+ msg = "Cannot crop raster: all values are NaN"
1192
+ raise ValueError(msg)
1193
+
1194
+ # Find rows and columns that are not all NaN
1195
+ nan_row_mask = np.all(np.isnan(arr), axis=1)
1196
+ nan_col_mask = np.all(np.isnan(arr), axis=0)
1197
+
1198
+ # Find the bounding indices
1199
+ (row_indices,) = np.where(~nan_row_mask)
1200
+ (col_indices,) = np.where(~nan_col_mask)
1201
+
1202
+ min_row, max_row = row_indices[0], row_indices[-1]
1203
+ min_col, max_col = col_indices[0], col_indices[-1]
1204
+
1205
+ # Crop the array
1206
+ cropped_arr = arr[min_row : max_row + 1, min_col : max_col + 1]
1207
+
1208
+ # Shift the transform by the number of pixels cropped (min_col, min_row)
1209
+ new_transform = (
1210
+ self.raster_meta.transform
1211
+ * rasterio.transform.Affine.translation(min_col, min_row)
1212
+ )
1213
+
1214
+ # Create new metadata
1215
+ new_meta = RasterMeta(
1216
+ cell_size=self.raster_meta.cell_size,
1217
+ crs=self.raster_meta.crs,
1218
+ transform=new_transform,
1219
+ )
1220
+
1221
+ return self.__class__(arr=cropped_arr, raster_meta=new_meta)
1222
+
775
1223
  def resample(
776
1224
  self, new_cell_size: float, *, method: Literal["bilinear"] = "bilinear"
777
1225
  ) -> Self:
@@ -819,26 +1267,55 @@ class RasterModel(BaseModel):
819
1267
 
820
1268
  return cls(arr=new_arr, raster_meta=new_raster_meta)
821
1269
 
822
- @field_validator("arr")
823
- @classmethod
824
- def check_2d_array(cls, v: NDArray) -> NDArray:
825
- """Validator to ensure the cell array is 2D."""
826
- if v.ndim != 2:
827
- msg = "Cell array must be 2D"
828
- raise RasterCellArrayShapeError(msg)
829
- return v
830
1270
 
1271
+ def _map_colorbar(
1272
+ *,
1273
+ colormap: Callable[[float], tuple[float, float, float, float]],
1274
+ vmin: float,
1275
+ vmax: float,
1276
+ ) -> BrancaLinearColormap:
1277
+ from branca.colormap import LinearColormap as BrancaLinearColormap
1278
+ from matplotlib.colors import ListedColormap, to_hex
1279
+
1280
+ # Determine legend data range in original units
1281
+ vmin = float(vmin) if np.isfinite(vmin) else 0.0
1282
+ vmax = float(vmax) if np.isfinite(vmax) else 1.0
1283
+ if vmax <= vmin:
1284
+ vmax = vmin + 1.0
1285
+
1286
+ if isinstance(colormap, ListedColormap):
1287
+ n = colormap.N
1288
+ else:
1289
+ n = 256
1290
+
1291
+ sample_points = np.linspace(0, 1, n)
1292
+ colors = [to_hex(colormap(x)) for x in sample_points]
1293
+ return BrancaLinearColormap(colors=colors, vmin=vmin, vmax=vmax)
831
1294
 
832
- def _get_xy_tuple(xy: Any) -> tuple[float, float]:
833
- """Convert Point or coordinate tuple to coordinate tuple.
834
1295
 
835
- Args:
836
- xy: Either a coordinate tuple or a shapely Point object.
1296
+ def _get_vmin_vmax(
1297
+ raster: Raster, *, vmin: float | None = None, vmax: float | None = None
1298
+ ) -> tuple[float, float]:
1299
+ """Get maximum and minimum values from a raster array, ignoring NaNs.
837
1300
 
838
- Returns:
839
- A coordinate tuple (x, y).
1301
+ Allows for custom over-ride vmin and vmax values to be provided.
840
1302
  """
841
- if isinstance(xy, Point):
842
- return (xy.x, xy.y)
843
- x, y = xy
844
- return (float(x), float(y))
1303
+ with warnings.catch_warnings():
1304
+ warnings.filterwarnings(
1305
+ "ignore",
1306
+ message="All-NaN slice encountered",
1307
+ category=RuntimeWarning,
1308
+ )
1309
+ if vmin is None:
1310
+ _vmin = raster.min()
1311
+ else:
1312
+ _vmin = vmin
1313
+ if vmax is None:
1314
+ _vmax = raster.max()
1315
+ else:
1316
+ _vmax = vmax
1317
+
1318
+ return _vmin, _vmax
1319
+
1320
+
1321
+ RasterModel = Raster
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rastr
3
- Version: 0.3.0
3
+ Version: 0.5.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/46f802e7abd275eff61c73c5edc147d92966c886.zip
8
+ Project-URL: Source Archive, https://github.com/tonkintaylor/rastr/archive/fde05e3c098da7ff9e77a37332d55fb7dbacd873.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
@@ -75,10 +75,10 @@ from pyproj.crs.crs import CRS
75
75
  from rasterio.transform import from_origin
76
76
  from rastr.create import full_raster
77
77
  from rastr.meta import RasterMeta
78
- from rastr.raster import RasterModel
78
+ from rastr.raster import Raster
79
79
 
80
80
  # Create an example raster
81
- raster = RasterModel.example()
81
+ raster = Raster.example()
82
82
 
83
83
  # Basic arithmetic operations
84
84
  doubled = raster * 2
@@ -131,6 +131,12 @@ Current version limitations:
131
131
  - Square cells only (rectangular cell support planned).
132
132
  - Only float dtypes (integer support planned).
133
133
 
134
+ ## Similar Projects
135
+
136
+ - [rasters](https://github.com/python-rasters/rasters) is a project with similar goals of providing a dedicated raster datatype in Python with higher-level interfaces for GIS operations. Unlike `rastr`, it has support for multi-band rasters, and has some more advanced functionality for Earth Science applications. Both projects are relatively new and under active development.
137
+ - [rasterio](https://rasterio.readthedocs.io/) is a core dependency of `rastr` and provides low-level raster I/O and processing capabilities.
138
+ - [rioxarray](https://corteva.github.io/rioxarray/stable/getting_started/getting_started.html) extends [`xarray`](https://docs.xarray.dev/en/stable/index.html) for raster data with geospatial support via `rasterio`.
139
+
134
140
  ### Contributing
135
141
 
136
142
  See the
@@ -1,15 +1,15 @@
1
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
2
+ rastr/_version.py,sha256=fvHpBU3KZKRinkriKdtAt3crenOyysELF-M9y3ozg3U,704
3
+ rastr/create.py,sha256=7fmg4GKeTXdM5w8oqrCD9eDrc7PU87oHYGPNA-IZ8Cc,13759
4
+ rastr/io.py,sha256=llR2wFyrJVjEG6HN82UAJLVPs_H8nvDxmbEZLjJYjno,2927
5
5
  rastr/meta.py,sha256=5iDvGkYe8iMMkPV6gSL04jNcLRhuRNFqe9AppUpp55E,2928
6
- rastr/raster.py,sha256=iHN8L91HW-cpDexZYgDnLaOw-N6KktQpXIusvUEiL0o,29931
6
+ rastr/raster.py,sha256=_1Wr78B0_V9r4oh7X-h5lT1QXlnBsSBW6ZkICGSjUFw,47514
7
7
  rastr/arr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  rastr/arr/fill.py,sha256=80ucb36el9s042fDSwK1SZJhp_GNJNMM0fpQTWmJvgE,1001
9
9
  rastr/gis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  rastr/gis/fishnet.py,sha256=Ic-0HV61ST8OxwhyoMyV_ybihs2xuhgAY-3n4CknAt8,2670
11
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,,
12
+ rastr-0.5.0.dist-info/METADATA,sha256=1-sja-_hUE6ukT7KqsDH-nzPZNWVHVeXCABmM9rzTg8,5701
13
+ rastr-0.5.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ rastr-0.5.0.dist-info/licenses/LICENSE,sha256=7qUsx93G2ATTRLZiSYuQofwAX_uWvrqnAiMK8PzxvNc,1080
15
+ rastr-0.5.0.dist-info/RECORD,,
File without changes