rastr 0.5.0__py3-none-any.whl → 0.7.0__py3-none-any.whl

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

Potentially problematic release.


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

rastr/__init__.py CHANGED
@@ -0,0 +1,7 @@
1
+ from rastr.meta import RasterMeta
2
+ from rastr.raster import Raster
3
+
4
+ __all__ = [
5
+ "Raster",
6
+ "RasterMeta",
7
+ ]
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.5.0'
32
- __version_tuple__ = version_tuple = (0, 5, 0)
31
+ __version__ = version = '0.7.0'
32
+ __version_tuple__ = version_tuple = (0, 7, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
rastr/arr/fill.py CHANGED
@@ -8,7 +8,7 @@ if TYPE_CHECKING:
8
8
  from numpy.typing import NDArray
9
9
 
10
10
 
11
- def fillna_nearest_neighbours(arr: NDArray[np.float64]) -> NDArray[np.float64]:
11
+ def fillna_nearest_neighbours(arr: NDArray) -> NDArray:
12
12
  """Fill NaN values in an N-dimensional array with their nearest neighbours' values.
13
13
 
14
14
  The nearest neighbour is determined using the Euclidean distance between array
@@ -28,4 +28,5 @@ def fillna_nearest_neighbours(arr: NDArray[np.float64]) -> NDArray[np.float64]:
28
28
  # Interpolate at the array indices
29
29
  interp = NearestNDInterpolator(nonnan_idxs, arr[nonnan_mask])
30
30
  filled_arr = interp(*np.indices(arr.shape))
31
- return filled_arr
31
+ # Preserve the original dtype
32
+ return filled_arr.astype(arr.dtype)
rastr/create.py CHANGED
@@ -11,12 +11,14 @@ from affine import Affine
11
11
  from pyproj import CRS
12
12
  from shapely.geometry import Point
13
13
 
14
+ from rastr.gis.crs import get_affine_sign
14
15
  from rastr.gis.fishnet import create_point_grid, get_point_grid_shape
16
+ from rastr.gis.interpolate import interpn_kernel
15
17
  from rastr.meta import RasterMeta
16
18
  from rastr.raster import Raster
17
19
 
18
20
  if TYPE_CHECKING:
19
- from collections.abc import Iterable
21
+ from collections.abc import Collection, Iterable
20
22
 
21
23
  import geopandas as gpd
22
24
  from numpy.typing import ArrayLike
@@ -141,7 +143,7 @@ def rasterize_gdf(
141
143
  gdf: gpd.GeoDataFrame,
142
144
  *,
143
145
  raster_meta: RasterMeta,
144
- target_cols: list[str],
146
+ target_cols: Collection[str],
145
147
  ) -> list[Raster]:
146
148
  """Rasterize geometries from a GeoDataFrame.
147
149
 
@@ -183,9 +185,10 @@ def rasterize_gdf(
183
185
  shape = get_point_grid_shape(bounds=expanded_bounds, cell_size=cell_size)
184
186
 
185
187
  # Create the affine transform for rasterization
188
+ xs, ys = get_affine_sign(raster_meta.crs)
186
189
  transform = Affine.translation(
187
190
  expanded_bounds[0], expanded_bounds[3]
188
- ) * Affine.scale(cell_size, -cell_size)
191
+ ) * Affine.scale(xs * cell_size, ys * cell_size)
189
192
 
190
193
  # Create rasters for each target column using rasterio.features.rasterize
191
194
  rasters = []
@@ -212,7 +215,9 @@ def rasterize_gdf(
212
215
  return rasters
213
216
 
214
217
 
215
- def _validate_columns_exist(gdf: gpd.GeoDataFrame, target_cols: list[str]) -> None:
218
+ def _validate_columns_exist(
219
+ gdf: gpd.GeoDataFrame, target_cols: Collection[str]
220
+ ) -> None:
216
221
  """Validate that all target columns exist in the GeoDataFrame.
217
222
 
218
223
  Args:
@@ -228,7 +233,9 @@ def _validate_columns_exist(gdf: gpd.GeoDataFrame, target_cols: list[str]) -> No
228
233
  raise MissingColumnsError(msg)
229
234
 
230
235
 
231
- def _validate_columns_numeric(gdf: gpd.GeoDataFrame, target_cols: list[str]) -> None:
236
+ def _validate_columns_numeric(
237
+ gdf: gpd.GeoDataFrame, target_cols: Collection[str]
238
+ ) -> None:
232
239
  """Validate that all target columns contain numeric data.
233
240
 
234
241
  Args:
@@ -308,14 +315,31 @@ def raster_from_point_cloud(
308
315
  Raises:
309
316
  ValueError: If any (x, y) points are duplicated, or if they are all collinear.
310
317
  """
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
318
  crs = CRS.from_user_input(crs)
319
+ x, y, z = _validate_xyz(
320
+ np.asarray(x).ravel(), np.asarray(y).ravel(), np.asarray(z).ravel()
321
+ )
318
322
 
323
+ raster_meta, shape = RasterMeta.infer(x, y, cell_size=cell_size, crs=crs)
324
+ arr = interpn_kernel(
325
+ points=np.column_stack((x, y)),
326
+ values=z,
327
+ xi=np.column_stack(_get_grid(raster_meta, shape=shape)),
328
+ ).reshape(shape)
329
+
330
+ # We only support float rasters for now; we should preserve the input dtype if
331
+ # possible
332
+ if z.dtype in (np.float16, np.float32, np.float64):
333
+ arr = arr.astype(z.dtype)
334
+ else:
335
+ arr = arr.astype(np.float64)
336
+
337
+ return Raster(arr=arr, raster_meta=raster_meta)
338
+
339
+
340
+ def _validate_xyz(
341
+ x: np.ndarray, y: np.ndarray, z: np.ndarray
342
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
319
343
  # Validate input arrays
320
344
  if len(x) != len(y) or len(x) != len(z):
321
345
  msg = "Length of x, y, and z must be equal."
@@ -339,53 +363,18 @@ def raster_from_point_cloud(
339
363
  msg = "Duplicate (x, y) points found. Each (x, y) point must be unique."
340
364
  raise ValueError(msg)
341
365
 
342
- # Heuristic for cell size if not provided
343
- if cell_size is None:
344
- # Half the 5th percentile of nearest neighbor distances between the (x,y) points
345
- tree = KDTree(xy_points)
346
- distances, _ = tree.query(xy_points, k=2)
347
- distances: np.ndarray
348
- cell_size = float(np.percentile(distances[distances > 0], 5)) / 2
349
-
350
- # Compute bounds from data
351
- minx, miny, maxx, maxy = np.min(x), np.min(y), np.max(x), np.max(y)
352
-
353
- # Compute grid shape
354
- width = int(np.ceil((maxx - minx) / cell_size))
355
- height = int(np.ceil((maxy - miny) / cell_size))
356
- shape = (height, width)
366
+ return x, y, z
357
367
 
358
- # Compute transform: upper left corner is (minx, maxy)
359
- transform = Affine.translation(minx, maxy) * Affine.scale(cell_size, -cell_size)
360
368
 
361
- # Create grid coordinates for raster cells
369
+ def _get_grid(
370
+ raster_meta: RasterMeta, *, shape: tuple[int, int]
371
+ ) -> tuple[np.ndarray, np.ndarray]:
372
+ """Get coordinates for raster cell centres based on raster metadata and shape."""
362
373
  rows, cols = np.indices(shape)
363
374
  xs, ys = rasterio.transform.xy(
364
- transform=transform, rows=rows, cols=cols, offset="center"
375
+ transform=raster_meta.transform, rows=rows, cols=cols, offset="center"
365
376
  )
366
377
  grid_x = np.array(xs).ravel()
367
378
  grid_y = np.array(ys).ravel()
368
379
 
369
- # Perform interpolation
370
- try:
371
- interpolator = LinearNDInterpolator(
372
- points=xy_points, values=z, fill_value=np.nan
373
- )
374
- except QhullError as err:
375
- msg = (
376
- "Failed to interpolate. This may be due to insufficient or "
377
- "degenerate input points. Ensure that the (x, y) points are not all "
378
- "collinear (i.e. that the convex hull is non-degenerate)."
379
- )
380
- raise ValueError(msg) from err
381
-
382
- grid_values = np.array(interpolator(np.column_stack((grid_x, grid_y))))
383
-
384
- arr = grid_values.reshape(shape).astype(np.float32)
385
-
386
- raster_meta = RasterMeta(
387
- cell_size=cell_size,
388
- crs=crs,
389
- transform=transform,
390
- )
391
- return Raster(arr=arr, raster_meta=raster_meta)
380
+ return grid_x, grid_y
rastr/gis/crs.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from typing import Literal
5
+
6
+ from pyproj import CRS
7
+
8
+
9
+ def get_affine_sign(crs: CRS | str) -> tuple[Literal[+1, -1], Literal[+1, -1]]:
10
+ """Return (x_sign, y_sign) for an Affine scale, given a CRS.
11
+
12
+ Some coordinate systems may use unconventional axis directions, in which case
13
+ the correct direction may not be possible to infer correctly. In these cases,
14
+ the assumption is that x increases to the right, and y increases upwards.
15
+ """
16
+ crs = CRS.from_user_input(crs)
17
+
18
+ # Try to detect horizontal axis directions from CRS metadata
19
+ dir_x, dir_y, *_ = [(a.direction or "").lower() for a in crs.axis_info]
20
+
21
+ try:
22
+ if _is_conventional_direction(dir_x):
23
+ x_sign = +1
24
+ else:
25
+ x_sign = -1
26
+ except NotImplementedError:
27
+ msg = (
28
+ f"Could not determine x-axis direction from CRS axis info '{dir_x}'. "
29
+ "Falling back to +1 (increasing to the right)."
30
+ )
31
+ warnings.warn(msg, stacklevel=2)
32
+ x_sign = +1
33
+
34
+ try:
35
+ if _is_conventional_direction(dir_y):
36
+ y_sign = -1
37
+ else:
38
+ y_sign = +1
39
+ except NotImplementedError:
40
+ msg = (
41
+ f"Could not determine y-axis direction from CRS axis info '{dir_y}'. "
42
+ "Falling back to -1 (increasing upwards)."
43
+ )
44
+ warnings.warn(msg, stacklevel=2)
45
+ y_sign = -1
46
+
47
+ return x_sign, y_sign
48
+
49
+
50
+ def _is_conventional_direction(direction: str) -> bool:
51
+ """Return True if the axis direction indicates positive increase."""
52
+ if (
53
+ "north" in direction
54
+ or "up" in direction
55
+ or "east" in direction
56
+ or "right" in direction
57
+ ):
58
+ return True
59
+ elif (
60
+ "south" in direction
61
+ or "down" in direction
62
+ or "west" in direction
63
+ or "left" in direction
64
+ ):
65
+ return False
66
+ else:
67
+ raise NotImplementedError
rastr/gis/fishnet.py CHANGED
@@ -41,8 +41,20 @@ def get_point_grid_shape(
41
41
  """Calculate the shape of the point grid based on bounds and cell size."""
42
42
 
43
43
  xmin, ymin, xmax, ymax = bounds
44
- ncols = int(np.ceil((xmax - xmin) / cell_size))
45
- nrows = int(np.ceil((ymax - ymin) / cell_size))
44
+ ncols_exact = (xmax - xmin) / cell_size
45
+ nrows_exact = (ymax - ymin) / cell_size
46
+
47
+ # Use round for values very close to integers to avoid floating-point
48
+ # sensitivity while maintaining ceil behavior for truly fractional values
49
+ if np.isclose(ncols_exact, np.round(ncols_exact)):
50
+ ncols = int(np.round(ncols_exact))
51
+ else:
52
+ ncols = int(np.ceil(ncols_exact))
53
+
54
+ if np.isclose(nrows_exact, np.round(nrows_exact)):
55
+ nrows = int(np.round(nrows_exact))
56
+ else:
57
+ nrows = int(np.ceil(nrows_exact))
46
58
 
47
59
  return nrows, ncols
48
60
 
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import numpy as np
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Callable
9
+
10
+
11
+ def interpn_kernel(
12
+ points: np.ndarray,
13
+ values: np.ndarray,
14
+ *,
15
+ xi: np.ndarray,
16
+ kernel: Callable[[np.ndarray], np.ndarray] | None = None,
17
+ ) -> np.ndarray:
18
+ """Interpolate scattered data to new points, with optional kernel transformation.
19
+
20
+ For example, you could provide a kernel to transform cartesian coordinate points
21
+ to polar coordinates before interpolation, giving interpolation which follows the
22
+ circular pattern of the data.
23
+
24
+ Args:
25
+ points: Array of shape (n_points, n_dimensions) representing the input points.
26
+ values: Array of shape (n_points,) representing the values at each input point.
27
+ xi: Array of shape (m_points, n_dimensions) representing the points to
28
+ interpolate to.
29
+ kernel: Optional function to transform points (and xi) before interpolation.
30
+ """
31
+ from scipy.interpolate import LinearNDInterpolator
32
+ from scipy.spatial import QhullError
33
+
34
+ if kernel is not None:
35
+ xi = kernel(xi)
36
+ points = kernel(points)
37
+ try:
38
+ interpolator = LinearNDInterpolator(
39
+ points=points, values=values, fill_value=np.nan
40
+ )
41
+ except QhullError as err:
42
+ msg = (
43
+ "Failed to interpolate. This may be due to insufficient or "
44
+ "degenerate input points. Ensure that the (x, y) points are not all "
45
+ "collinear (i.e. that the convex hull is non-degenerate)."
46
+ )
47
+ raise ValueError(msg) from err
48
+
49
+ grid_values = np.array(interpolator(xi))
50
+ return grid_values
rastr/gis/smooth.py CHANGED
@@ -5,16 +5,17 @@ Fork + Port of <https://github.com/philipschall/shapelysmooth> (Public domain)
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- from typing import TYPE_CHECKING, TypeAlias
8
+ from typing import TYPE_CHECKING, TypeVar
9
9
 
10
10
  import numpy as np
11
+ from numpy.lib.stride_tricks import sliding_window_view
11
12
  from shapely.geometry import LineString, Polygon
12
13
  from typing_extensions import assert_never
13
14
 
14
15
  if TYPE_CHECKING:
15
16
  from numpy.typing import NDArray
16
17
 
17
- T: TypeAlias = LineString | Polygon
18
+ T = TypeVar("T", bound=LineString | Polygon)
18
19
 
19
20
 
20
21
  class InputeTypeError(TypeError):
@@ -38,12 +39,12 @@ def catmull_rom_smooth(geometry: T, alpha: float = 0.5, subdivs: int = 10) -> T:
38
39
  coords, interior_coords = _get_coords(geometry)
39
40
  coords_smoothed = _catmull_rom(coords, alpha=alpha, subdivs=subdivs)
40
41
  if isinstance(geometry, LineString):
41
- return type(geometry)(coords_smoothed)
42
+ return geometry.__class__(coords_smoothed)
42
43
  elif isinstance(geometry, Polygon):
43
44
  interior_coords_smoothed = [
44
45
  _catmull_rom(c, alpha=alpha, subdivs=subdivs) for c in interior_coords
45
46
  ]
46
- return type(geometry)(coords_smoothed, holes=interior_coords_smoothed)
47
+ return geometry.__class__(coords_smoothed, holes=interior_coords_smoothed)
47
48
  else:
48
49
  assert_never(geometry)
49
50
 
@@ -55,7 +56,8 @@ def _catmull_rom(
55
56
  subdivs: int = 8,
56
57
  ) -> list[tuple[float, float]]:
57
58
  arr = np.asarray(coords, dtype=float)
58
- if arr.shape[0] < 2:
59
+ n = arr.shape[0]
60
+ if n < 2:
59
61
  return arr.tolist()
60
62
 
61
63
  is_closed = np.allclose(arr[0], arr[-1])
@@ -70,63 +72,80 @@ def _catmull_rom(
70
72
  ]
71
73
  )
72
74
 
73
- new_ls = [tuple(arr[1])]
74
- for k in range(len(arr) - 3):
75
- slice4 = arr[k : k + 4]
76
- tangents = [0.0]
77
- for j in range(3):
78
- dist = float(np.linalg.norm(slice4[j + 1] - slice4[j]))
79
- tangents.append(float(tangents[-1] + dist**alpha))
80
-
81
- # Resample: subdivs-1 samples strictly between t1 and t2
82
- seg_len = (tangents[2] - tangents[1]) / float(subdivs)
83
- if subdivs > 1:
84
- ts = np.linspace(tangents[1] + seg_len, tangents[2] - seg_len, subdivs - 1)
85
- else:
86
- ts = np.array([])
87
-
88
- interpolants = _recursive_eval(slice4, tangents, ts)
89
- new_ls.extend(interpolants)
90
- new_ls.append(tuple(slice4[2]))
91
- return new_ls
92
-
93
-
94
- def _recursive_eval(
95
- slice4: NDArray, tangents: list[float], ts: NDArray
96
- ) -> list[tuple[float, float]]:
75
+ # Shape of (segments, 4, D)
76
+ segments = sliding_window_view(arr, (4, arr.shape[1]))[:, 0, :]
77
+
78
+ # Distances and tangent values
79
+ diffs = np.diff(segments, axis=1)
80
+ dists = np.linalg.norm(diffs, axis=2)
81
+ tangents = np.concatenate(
82
+ [np.zeros((len(dists), 1)), np.cumsum(dists**alpha, axis=1)], axis=1
83
+ )
84
+
85
+ # Build ts per segment
86
+ if subdivs > 1:
87
+ seg_lens = (tangents[:, 2] - tangents[:, 1]) / subdivs
88
+ u = np.linspace(1, subdivs - 1, subdivs - 1)
89
+ ts = tangents[:, [1]] + seg_lens[:, None] * u # (N-3, subdivs-1)
90
+ else:
91
+ ts = np.empty((len(segments), 0))
92
+
93
+ # Vectorize over segments
94
+ out_segments = []
95
+ for seg, tang, tvals in zip(segments, tangents, ts, strict=True):
96
+ if tvals.size:
97
+ out_segments.append(
98
+ _recursive_eval(seg, np.asarray(tang), np.asarray(tvals))
99
+ )
100
+ if out_segments:
101
+ all_midpoints = np.vstack(out_segments)
102
+ else:
103
+ all_midpoints = np.empty((0, arr.shape[1]))
104
+
105
+ # Gather final output in order
106
+ result = [tuple(arr[1])]
107
+ idx = 0
108
+ for k in range(len(segments)):
109
+ block = all_midpoints[idx : idx + max(subdivs - 1, 0)]
110
+ result.extend(map(tuple, block))
111
+ result.append(tuple(segments[k, 2]))
112
+ idx += max(subdivs - 1, 0)
113
+
114
+ return result
115
+
116
+
117
+ def _recursive_eval(slice4: NDArray, tangents: NDArray, ts: NDArray) -> NDArray:
97
118
  """De Boor/De Casteljau-style recursive linear interpolation over 4 control points.
98
119
 
99
120
  Parameterized by the non-uniform 'tangents' values.
100
121
  """
101
- # N.B. comments are LLM-generated
102
-
103
- out = []
104
- for tp in ts:
105
- # Start with the 4 control points for this segment
106
- points = slice4.copy()
107
- # Perform 3 levels of linear interpolation (De Casteljau's algorithm)
108
- for r in range(1, 4):
109
- idx = max(r - 2, 0)
110
- new_points = []
111
- # Interpolate between points at this level
112
- for i in range(4 - r):
113
- # Compute denominator for parameterization
114
- denom = tangents[i + r - idx] - tangents[i + idx]
115
- if denom == 0:
116
- # If degenerate (coincident tangents), use midpoint
117
- left_w = right_w = 0.5
118
- else:
119
- # Otherwise, compute weights for linear interpolation
120
- left_w = (tangents[i + r - idx] - tp) / denom
121
- right_w = (tp - tangents[i + idx]) / denom
122
- # Weighted average of the two points
123
- pt = left_w * points[i] + right_w * points[i + 1]
124
- new_points.append(pt)
125
- # Move to the next level with the new set of points
126
- points = np.array(new_points)
127
- # The final point is the interpolated value for this parameter tp
128
- out.append(tuple(points[0]))
129
- return out
122
+ slice4 = np.asarray(slice4, dtype=float)
123
+ tangents = np.asarray(tangents, dtype=float)
124
+ ts = np.asarray(ts, dtype=float)
125
+ bigm = ts.shape[0]
126
+ bigd = slice4.shape[1]
127
+
128
+ # Initialize points for all ts, shape (M, 4, D)
129
+ points = np.broadcast_to(slice4, (bigm, 4, bigd)).copy()
130
+
131
+ # Recursive interpolation, but vectorized across all ts
132
+ for r in range(1, 4):
133
+ idx = max(r - 2, 0)
134
+ denom = tangents[r - idx : 4 - idx] - tangents[idx : 4 - r + idx]
135
+ denom = np.where(denom == 0, np.finfo(float).eps, denom) # avoid div 0
136
+
137
+ # Compute weights for all parameter values at once
138
+ left_w = (tangents[r - idx : 4 - idx][None, :] - ts[:, None]) / denom
139
+ right_w = 1 - left_w
140
+
141
+ # Weighted sums between consecutive points
142
+ points = (
143
+ left_w[..., None] * points[:, 0 : 4 - r, :]
144
+ + right_w[..., None] * points[:, 1 : 5 - r, :]
145
+ )
146
+
147
+ # Result is first (and only) point at this level
148
+ return points[:, 0, :]
130
149
 
131
150
 
132
151
  def _get_coords(
rastr/io.py CHANGED
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
- from typing import TYPE_CHECKING
4
+ from typing import TYPE_CHECKING, TypeVar
5
5
 
6
6
  import numpy as np
7
7
  import rasterio
@@ -14,28 +14,48 @@ from rastr.raster import Raster
14
14
  if TYPE_CHECKING:
15
15
  from numpy.typing import NDArray
16
16
 
17
+ R = TypeVar("R", bound=Raster)
18
+
17
19
 
18
20
  def read_raster_inmem(
19
- raster_path: Path | str, *, crs: CRS | str | None = None
20
- ) -> Raster:
21
- """Read raster data from a file and return an in-memory Raster object."""
21
+ raster_path: Path | str,
22
+ *,
23
+ crs: CRS | str | None = None,
24
+ cls: type[R] = Raster,
25
+ ) -> R:
26
+ """Read raster data from a file and return an in-memory Raster object.
27
+
28
+ Args:
29
+ raster_path: Path to the raster file.
30
+ crs: Optional CRS to override the raster's native CRS.
31
+ cls: The Raster subclass to instantiate. This is mostly for internal use,
32
+ but can be useful if you have a custom `Raster` subclass.
33
+ """
22
34
  crs = CRS.from_user_input(crs) if crs is not None else None
23
35
 
24
36
  with rasterio.open(raster_path, mode="r") as dst:
25
37
  # Read the entire array
26
- arr: NDArray[np.float64] = dst.read()
27
- arr = arr.squeeze().astype(np.float64)
38
+ raw_arr: NDArray = dst.read()
39
+ raw_arr = raw_arr.squeeze()
40
+
28
41
  # Extract metadata
29
42
  cell_size = dst.res[0]
30
43
  if crs is None:
31
44
  crs = CRS.from_user_input(dst.crs)
32
45
  transform = dst.transform
33
46
  nodata = dst.nodata
47
+
48
+ # Cast integers to float16 to handle NaN values
49
+ if np.issubdtype(raw_arr.dtype, np.integer):
50
+ arr = raw_arr.astype(np.float16)
51
+ else:
52
+ arr = raw_arr
53
+
34
54
  if nodata is not None:
35
- arr[arr == nodata] = np.nan
55
+ arr[raw_arr == nodata] = np.nan
36
56
 
37
57
  raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
38
- raster_obj = Raster(arr=arr, raster_meta=raster_meta)
58
+ raster_obj = cls(arr=arr, raster_meta=raster_meta)
39
59
  return raster_obj
40
60
 
41
61
 
@@ -81,10 +101,16 @@ def read_raster_mosaic_inmem(
81
101
  crs = CRS.from_user_input(sources[0].crs)
82
102
 
83
103
  nodata = sources[0].nodata
84
- if nodata is not None:
85
- arr[arr == nodata] = np.nan
104
+ raw_arr = arr.squeeze()
86
105
 
87
- arr = arr.squeeze().astype(np.float64)
106
+ # Cast integers to float16 to handle NaN values
107
+ if np.issubdtype(raw_arr.dtype, np.integer):
108
+ arr = raw_arr.astype(np.float16)
109
+ else:
110
+ arr = raw_arr
111
+
112
+ if nodata is not None:
113
+ arr[raw_arr == nodata] = np.nan
88
114
 
89
115
  raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
90
116
  raster_obj = Raster(arr=arr, raster_meta=raster_meta)
rastr/meta.py CHANGED
@@ -7,6 +7,8 @@ from affine import Affine
7
7
  from pydantic import BaseModel, InstanceOf
8
8
  from pyproj import CRS
9
9
 
10
+ from rastr.gis.crs import get_affine_sign
11
+
10
12
  if TYPE_CHECKING:
11
13
  from numpy.typing import NDArray
12
14
  from typing_extensions import Self
@@ -85,3 +87,99 @@ class RasterMeta(BaseModel, extra="forbid"):
85
87
  y_idx = np.arange(n_rows) + 0.5
86
88
  _, y_coords = self.transform * (x_idx, y_idx) # type: ignore[reportAssignmentType] overloaded tuple size in affine
87
89
  return y_coords
90
+
91
+ @classmethod
92
+ def infer(
93
+ cls,
94
+ x: np.ndarray,
95
+ y: np.ndarray,
96
+ *,
97
+ cell_size: float | None = None,
98
+ crs: CRS,
99
+ ) -> tuple[Self, tuple[int, int]]:
100
+ """Automatically get recommended raster metadata (and shape) using data bounds.
101
+
102
+ The cell size can be provided, or a heuristic will be used based on the spacing
103
+ of the (x, y) points.
104
+ """
105
+ # Heuristic for cell size if not provided
106
+ if cell_size is None:
107
+ cell_size = infer_cell_size(x, y)
108
+
109
+ shape = infer_shape(x, y, cell_size=cell_size)
110
+ transform = infer_transform(x, y, cell_size=cell_size, crs=crs)
111
+
112
+ raster_meta = cls(
113
+ cell_size=cell_size,
114
+ crs=crs,
115
+ transform=transform,
116
+ )
117
+ return raster_meta, shape
118
+
119
+
120
+ def infer_transform(
121
+ x: np.ndarray,
122
+ y: np.ndarray,
123
+ *,
124
+ cell_size: float | None = None,
125
+ crs: CRS,
126
+ ) -> Affine:
127
+ """Infer a suitable raster transform based on the bounds of (x, y) data points."""
128
+ if cell_size is None:
129
+ cell_size = infer_cell_size(x, y)
130
+
131
+ (xs, ys) = get_affine_sign(crs)
132
+ return Affine.translation(*infer_origin(x, y)) * Affine.scale(
133
+ xs * cell_size, ys * cell_size
134
+ )
135
+
136
+
137
+ def infer_origin(x: np.ndarray, y: np.ndarray) -> tuple[float, float]:
138
+ """Infer a suitable raster origin based on the bounds of (x, y) data points."""
139
+ # Compute bounds from data
140
+ minx, _miny, _maxx, maxy = np.min(x), np.min(y), np.max(x), np.max(y)
141
+
142
+ origin = (minx, maxy)
143
+ return origin
144
+
145
+
146
+ def infer_shape(
147
+ x: np.ndarray, y: np.ndarray, *, cell_size: float | None = None
148
+ ) -> tuple[int, int]:
149
+ """Infer a suitable raster shape based on the bounds of (x, y) data points."""
150
+ if cell_size is None:
151
+ cell_size = infer_cell_size(x, y)
152
+
153
+ # Compute bounds from data
154
+ minx, miny, maxx, maxy = np.min(x), np.min(y), np.max(x), np.max(y)
155
+
156
+ # Compute grid shape
157
+ width = int(np.ceil((maxx - minx) / cell_size))
158
+ height = int(np.ceil((maxy - miny) / cell_size))
159
+ shape = (height, width)
160
+
161
+ return shape
162
+
163
+
164
+ def infer_cell_size(x: np.ndarray, y: np.ndarray) -> float:
165
+ """Infer a suitable cell size based on the spacing of (x, y) data points.
166
+
167
+ When points are distributed regularly, this corresponds to roughly half the distance
168
+ between neighboring points.
169
+
170
+ When distributed irregularly, the size is more influenced by the densest clusters of
171
+ points, i.e. the cell size will be small enough to capture the detail in these
172
+ clusters.
173
+
174
+ This is based on a heuristic which has been found to work well in practice.
175
+ """
176
+ from scipy.spatial import KDTree
177
+
178
+ # Half the 5th percentile of nearest neighbor distances between the (x,y) points
179
+ xy_points = np.column_stack((x, y))
180
+ tree = KDTree(xy_points)
181
+ distances, _ = tree.query(xy_points, k=2)
182
+ distances: np.ndarray
183
+ cell_size = float(np.percentile(distances[distances > 0], 5)) / 2
184
+
185
+ return cell_size
rastr/raster.py CHANGED
@@ -108,6 +108,16 @@ class Raster(BaseModel):
108
108
  """Set the transform via meta."""
109
109
  self.meta.transform = value
110
110
 
111
+ @property
112
+ def cell_size(self) -> float:
113
+ """Convenience property to access the cell size via meta."""
114
+ return self.meta.cell_size
115
+
116
+ @cell_size.setter
117
+ def cell_size(self, value: float) -> None:
118
+ """Set the cell size via meta."""
119
+ self.meta.cell_size = value
120
+
111
121
  def __init__(
112
122
  self,
113
123
  *,
@@ -663,8 +673,15 @@ class Raster(BaseModel):
663
673
 
664
674
  return raster_gdf
665
675
 
666
- def to_file(self, path: Path | str) -> None:
667
- """Write the raster to a GeoTIFF file."""
676
+ def to_file(self, path: Path | str, **kwargs: Any) -> None:
677
+ """Write the raster to a GeoTIFF file.
678
+
679
+ Args:
680
+ path: Path to output file.
681
+ **kwargs: Additional keyword arguments to pass to `rasterio.open()`. If
682
+ `nodata` is provided, NaN values in the raster will be replaced
683
+ with the nodata value.
684
+ """
668
685
 
669
686
  path = Path(path)
670
687
 
@@ -679,6 +696,15 @@ class Raster(BaseModel):
679
696
  msg = f"Unsupported file extension: {suffix}"
680
697
  raise ValueError(msg)
681
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
+
682
708
  with rasterio.open(
683
709
  path,
684
710
  "w",
@@ -689,17 +715,18 @@ class Raster(BaseModel):
689
715
  dtype=self.arr.dtype,
690
716
  crs=self.raster_meta.crs,
691
717
  transform=self.raster_meta.transform,
692
- nodata=np.nan,
718
+ nodata=nodata_value,
719
+ **kwargs,
693
720
  ) as dst:
694
721
  try:
695
- dst.write(self.arr, 1)
722
+ dst.write(arr_to_write, 1)
696
723
  except CPLE_BaseError as err:
697
724
  msg = f"Failed to write raster to file: {err}"
698
725
  raise OSError(msg) from err
699
726
 
700
727
  def __str__(self) -> str:
701
728
  cls = self.__class__
702
- mean = np.nanmean(self.arr)
729
+ mean = self.mean()
703
730
  return f"{cls.__name__}(shape={self.arr.shape}, {mean=})"
704
731
 
705
732
  def __repr__(self) -> str:
@@ -719,6 +746,34 @@ class Raster(BaseModel):
719
746
  raster_meta = RasterMeta.example()
720
747
  return cls(arr=arr, raster_meta=raster_meta)
721
748
 
749
+ @classmethod
750
+ def full_like(cls, other: Raster, *, fill_value: float) -> Self:
751
+ """Create a raster with the same metadata as another but filled with a constant.
752
+
753
+ Args:
754
+ other: The raster to copy metadata from.
755
+ fill_value: The constant value to fill all cells with.
756
+
757
+ Returns:
758
+ A new raster with the same shape and metadata as `other`, but with all cells
759
+ set to `fill_value`.
760
+ """
761
+ arr = np.full(other.shape, fill_value, dtype=np.float32)
762
+ return cls(arr=arr, raster_meta=other.raster_meta)
763
+
764
+ @classmethod
765
+ def read_file(cls, filename: Path | str, crs: CRS | str | None = None) -> Self:
766
+ """Read raster data from a file and return an in-memory Raster object.
767
+
768
+ Args:
769
+ filename: Path to the raster file.
770
+ crs: Optional coordinate reference system to override the file's CRS.
771
+ """
772
+ # Import here to avoid circular import (rastr.io imports Raster)
773
+ from rastr.io import read_raster_inmem # noqa: PLC0415
774
+
775
+ return read_raster_inmem(filename, crs=crs, cls=cls)
776
+
722
777
  @overload
723
778
  def apply(
724
779
  self,
@@ -819,6 +874,78 @@ class Raster(BaseModel):
819
874
  new_raster.arr = filled_arr
820
875
  return new_raster
821
876
 
877
+ def replace(
878
+ self, to_replace: float | dict[float, float], value: float | None = None
879
+ ) -> Self:
880
+ """Replace values in the raster with other values.
881
+
882
+ Creates a new raster with the specified values replaced. This is useful for
883
+ operations like replacing zeros with NaNs, or vice versa.
884
+
885
+ The method supports two interfaces:
886
+ 1. Single replacement: `raster.replace(to_replace=0, value=np.nan)`
887
+ 2. Multiple replacements using a dictionary:
888
+ `raster.replace({0: np.nan, -999: np.nan})`
889
+
890
+ Args:
891
+ to_replace: Value to be replaced, or a dictionary mapping values to
892
+ their replacements.
893
+ value: Replacement value. Required when to_replace is a float, must be
894
+ None when to_replace is a dict.
895
+
896
+ Examples:
897
+ >>> # Replace a single value
898
+ >>> raster.replace(to_replace=0, value=np.nan)
899
+ >>> # Replace multiple values
900
+ >>> raster.replace({0: np.nan, -999: np.nan})
901
+ """
902
+ # Determine the replacement map
903
+ if isinstance(to_replace, dict):
904
+ if value is not None:
905
+ msg = "value must be None when to_replace is a dict"
906
+ raise ValueError(msg)
907
+ map_ = to_replace
908
+ else:
909
+ if value is None:
910
+ msg = "value must be specified when to_replace is a float"
911
+ raise ValueError(msg)
912
+ map_ = {to_replace: value}
913
+
914
+ # Start with a copy of the array
915
+ replaced_arr = self.arr.copy()
916
+
917
+ # Check if we need to convert to float (if assigning NaN to non-float array)
918
+ needs_float = any(
919
+ np.isnan(new_val) for new_val in map_.values()
920
+ ) and not np.issubdtype(replaced_arr.dtype, np.floating)
921
+ if needs_float:
922
+ replaced_arr = replaced_arr.astype(float)
923
+
924
+ # Apply each replacement based on the original array values
925
+ # to prevent chained replacements
926
+ for old_val, new_val in map_.items():
927
+ # Handle NaN specially since NaN != NaN
928
+ if np.isnan(old_val):
929
+ mask = np.isnan(self.arr)
930
+ else:
931
+ mask = self.arr == old_val
932
+
933
+ replaced_arr[mask] = new_val
934
+
935
+ new_raster = self.model_copy()
936
+ new_raster.arr = replaced_arr
937
+ return new_raster
938
+
939
+ def copy(self) -> Self: # type: ignore[override]
940
+ """Create a copy of the raster.
941
+
942
+ This method wraps `model_copy()` for convenience.
943
+
944
+ Returns:
945
+ A new Raster instance.
946
+ """
947
+ return self.model_copy(deep=True)
948
+
822
949
  def get_xy(self) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
823
950
  """Get the x and y coordinates of the raster cell centres in meshgrid format.
824
951
 
@@ -835,7 +962,7 @@ class Raster(BaseModel):
835
962
  return coords[:, :, 0], coords[:, :, 1]
836
963
 
837
964
  def contour(
838
- self, levels: list[float] | NDArray, *, smoothing: bool = True
965
+ self, levels: Collection[float] | NDArray, *, smoothing: bool = True
839
966
  ) -> gpd.GeoDataFrame:
840
967
  """Create contour lines from the raster data, optionally with smoothing.
841
968
 
@@ -848,8 +975,8 @@ class Raster(BaseModel):
848
975
  contouring, to denoise the contours.
849
976
 
850
977
  Args:
851
- levels: A list or array of contour levels to generate. The contour lines
852
- will be generated for each level in this sequence.
978
+ levels: A collection or array of contour levels to generate. The contour
979
+ lines will be generated for each level in this sequence.
853
980
  smoothing: Defaults to true, which corresponds to applying a smoothing
854
981
  algorithm to the contour lines. At the moment, this is the
855
982
  Catmull-Rom spline algorithm. If set to False, the raw
@@ -905,19 +1032,40 @@ class Raster(BaseModel):
905
1032
  # Dissolve contours by level to merge all contour lines of the same level
906
1033
  return contour_gdf.dissolve(by="level", as_index=False)
907
1034
 
908
- def blur(self, sigma: float) -> Self:
1035
+ def blur(self, sigma: float, *, preserve_nan: bool = True) -> Self:
909
1036
  """Apply a Gaussian blur to the raster data.
910
1037
 
911
1038
  Args:
912
1039
  sigma: Standard deviation for Gaussian kernel, in units of geographic
913
1040
  coordinate distance (e.g. meters). A larger sigma results in a more
914
1041
  blurred image.
1042
+ preserve_nan: If True, applies NaN-safe blurring by extrapolating NaN values
1043
+ before blurring and restoring them afterwards. This prevents
1044
+ NaNs from spreading into valid data during the blur operation.
915
1045
  """
916
1046
  from scipy.ndimage import gaussian_filter
917
1047
 
918
1048
  cell_sigma = sigma / self.raster_meta.cell_size
919
1049
 
920
- blurred_array = gaussian_filter(self.arr, sigma=cell_sigma)
1050
+ if preserve_nan:
1051
+ # Save the original NaN mask
1052
+ nan_mask = np.isnan(self.arr)
1053
+
1054
+ # If there are no NaNs, just apply regular blur
1055
+ if not np.any(nan_mask):
1056
+ blurred_array = gaussian_filter(self.arr, sigma=cell_sigma)
1057
+ else:
1058
+ # Extrapolate to fill NaN values temporarily
1059
+ extrapolated_arr = fillna_nearest_neighbours(arr=self.arr)
1060
+
1061
+ # Apply blur to the extrapolated array
1062
+ blurred_array = gaussian_filter(extrapolated_arr, sigma=cell_sigma)
1063
+
1064
+ # Restore original NaN values
1065
+ blurred_array = np.where(nan_mask, np.nan, blurred_array)
1066
+ else:
1067
+ blurred_array = gaussian_filter(self.arr, sigma=cell_sigma)
1068
+
921
1069
  new_raster = self.model_copy()
922
1070
  new_raster.arr = blurred_array
923
1071
  return new_raster
@@ -1175,29 +1323,27 @@ class Raster(BaseModel):
1175
1323
 
1176
1324
  return raster
1177
1325
 
1178
- def trim_nan(self) -> Self:
1179
- """Crop the raster by trimming away all-NaN slices at the edges.
1326
+ def _trim_value(self, *, value_mask: NDArray[np.bool_], value_name: str) -> Self:
1327
+ """Crop the raster by trimming away slices matching the mask at the edges.
1180
1328
 
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.
1329
+ Args:
1330
+ value_mask: Boolean mask where True indicates values to trim
1331
+ value_name: Name of the value type for error messages (e.g., 'NaN', 'zero')
1186
1332
  """
1187
1333
  arr = self.arr
1188
1334
 
1189
- # Check if the entire array is NaN
1190
- if np.all(np.isnan(arr)):
1191
- msg = "Cannot crop raster: all values are NaN"
1335
+ # Check if the entire array matches the mask
1336
+ if np.all(value_mask):
1337
+ msg = f"Cannot crop raster: all values are {value_name}"
1192
1338
  raise ValueError(msg)
1193
1339
 
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)
1340
+ # Find rows and columns that are not all matching the mask
1341
+ row_mask = np.all(value_mask, axis=1)
1342
+ col_mask = np.all(value_mask, axis=0)
1197
1343
 
1198
1344
  # Find the bounding indices
1199
- (row_indices,) = np.where(~nan_row_mask)
1200
- (col_indices,) = np.where(~nan_col_mask)
1345
+ (row_indices,) = np.where(~row_mask)
1346
+ (col_indices,) = np.where(~col_mask)
1201
1347
 
1202
1348
  min_row, max_row = row_indices[0], row_indices[-1]
1203
1349
  min_col, max_col = col_indices[0], col_indices[-1]
@@ -1220,6 +1366,26 @@ class Raster(BaseModel):
1220
1366
 
1221
1367
  return self.__class__(arr=cropped_arr, raster_meta=new_meta)
1222
1368
 
1369
+ def trim_nan(self) -> Self:
1370
+ """Crop the raster by trimming away all-NaN slices at the edges.
1371
+
1372
+ This effectively trims the raster to the smallest bounding box that contains all
1373
+ of the non-NaN values. Note that this does not guarantee no NaN values at all
1374
+ around the edges, only that there won't be entire edges which are all-NaN.
1375
+
1376
+ Consider using `.extrapolate()` for further cleanup of NaN values.
1377
+ """
1378
+ return self._trim_value(value_mask=np.isnan(self.arr), value_name="NaN")
1379
+
1380
+ def trim_zeros(self) -> Self:
1381
+ """Crop the raster by trimming away all-zero slices at the edges.
1382
+
1383
+ This effectively trims the raster to the smallest bounding box that contains all
1384
+ of the non-zero values. Note that this does not guarantee no zero values at all
1385
+ around the edges, only that there won't be entire edges which are all-zero.
1386
+ """
1387
+ return self._trim_value(value_mask=(self.arr == 0), value_name="zero")
1388
+
1223
1389
  def resample(
1224
1390
  self, new_cell_size: float, *, method: Literal["bilinear"] = "bilinear"
1225
1391
  ) -> Self:
@@ -1307,11 +1473,11 @@ def _get_vmin_vmax(
1307
1473
  category=RuntimeWarning,
1308
1474
  )
1309
1475
  if vmin is None:
1310
- _vmin = raster.min()
1476
+ _vmin = float(raster.min())
1311
1477
  else:
1312
1478
  _vmin = vmin
1313
1479
  if vmax is None:
1314
- _vmax = raster.max()
1480
+ _vmax = float(raster.max())
1315
1481
  else:
1316
1482
  _vmax = vmax
1317
1483
 
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rastr
3
- Version: 0.5.0
3
+ Version: 0.7.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/fde05e3c098da7ff9e77a37332d55fb7dbacd873.zip
8
+ Project-URL: Source Archive, https://github.com/tonkintaylor/rastr/archive/2b485cc676121c82f468dca7733e444c3033abbe.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
@@ -77,8 +77,8 @@ from rastr.create import full_raster
77
77
  from rastr.meta import RasterMeta
78
78
  from rastr.raster import Raster
79
79
 
80
- # Create an example raster
81
- raster = Raster.example()
80
+ # Read a raster from a file
81
+ raster = Raster.read_file("path/to/raster.tif")
82
82
 
83
83
  # Basic arithmetic operations
84
84
  doubled = raster * 2
@@ -0,0 +1,17 @@
1
+ rastr/__init__.py,sha256=z26KywZdRKwO-N5Qc34SuuGGwH8Y812csKORc3S4SYU,113
2
+ rastr/_version.py,sha256=uLbRjFSUZAgfl7V7O8zKV5Db36k7tz87ZIVq3l2SWs0,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=FeEiO9RNyX9bP_yM2bvgQPLGyF0lHjll20ur52ehp1c,5182
14
+ rastr-0.7.0.dist-info/METADATA,sha256=yc3gYgbIgMLAoUeubfE0rXiJtN1b9YPbz4Yjewrr0QE,5724
15
+ rastr-0.7.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
+ rastr-0.7.0.dist-info/licenses/LICENSE,sha256=7qUsx93G2ATTRLZiSYuQofwAX_uWvrqnAiMK8PzxvNc,1080
17
+ rastr-0.7.0.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- rastr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
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
- rastr/meta.py,sha256=5iDvGkYe8iMMkPV6gSL04jNcLRhuRNFqe9AppUpp55E,2928
6
- rastr/raster.py,sha256=_1Wr78B0_V9r4oh7X-h5lT1QXlnBsSBW6ZkICGSjUFw,47514
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.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