rastr 0.6.0__tar.gz → 0.7.0__tar.gz
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-0.6.0 → rastr-0.7.0}/.github/copilot-instructions.md +3 -1
- {rastr-0.6.0 → rastr-0.7.0}/PKG-INFO +2 -2
- rastr-0.7.0/src/rastr/__init__.py +7 -0
- {rastr-0.6.0 → rastr-0.7.0}/src/rastr/_version.py +2 -2
- {rastr-0.6.0 → rastr-0.7.0}/src/rastr/create.py +34 -49
- rastr-0.7.0/src/rastr/gis/crs.py +67 -0
- rastr-0.7.0/src/rastr/gis/interpolate.py +50 -0
- {rastr-0.6.0 → rastr-0.7.0}/src/rastr/gis/smooth.py +73 -54
- rastr-0.7.0/src/rastr/meta.py +185 -0
- {rastr-0.6.0 → rastr-0.7.0}/src/rastr/raster.py +63 -1
- {rastr-0.6.0 → rastr-0.7.0}/tests/conftest.py +8 -0
- rastr-0.7.0/tests/rastr/gis/test_crs.py +53 -0
- {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/gis/test_smooth.py +5 -3
- {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/test_raster.py +202 -18
- rastr-0.6.0/src/rastr/gis/__init__.py +0 -0
- rastr-0.6.0/src/rastr/meta.py +0 -87
- {rastr-0.6.0 → rastr-0.7.0}/.copier-answers.yml +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/.github/ISSUE_TEMPLATE/enhancement.md +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/.github/workflows/ci.yml +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/.github/workflows/release.yml +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/.gitignore +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/.pre-commit-config.yaml +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/.python-version +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/CONTRIBUTING.md +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/LICENSE +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/README.md +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/docs/index.md +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/docs/logo.svg +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/mkdocs.yml +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/pyproject.toml +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/pyrightconfig.json +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/requirements.txt +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/src/archive/.gitkeep +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/src/notebooks/.gitkeep +0 -0
- {rastr-0.6.0/src/rastr → rastr-0.7.0/src/rastr/arr}/__init__.py +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/src/rastr/arr/fill.py +0 -0
- {rastr-0.6.0/src/rastr/arr → rastr-0.7.0/src/rastr/gis}/__init__.py +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/src/rastr/gis/fishnet.py +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/src/rastr/io.py +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/src/scripts/.gitkeep +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/src/scripts/demo_point_cloud.py +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/src/scripts/demo_taper_border.py +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/ABOUT_TASKS.md +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/dev_sync.ps1 +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/activate_venv.sh +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/configure_project.sh +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/dev_sync.sh +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/install_backend.sh +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/install_venv.sh +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/recover_corrupt_venv.sh +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/sh_runner.ps1 +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/sync_requirements.sh +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/sync_template.sh +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/activate_venv +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/activate_venv.cmd +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/activate_venv.ps1 +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/configure_project +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/configure_project.cmd +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/configure_project.ps1 +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/dev_sync +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/dev_sync.cmd +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/dev_sync.ps1 +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/install_backend +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/install_backend.cmd +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/install_backend.ps1 +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/install_venv +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/install_venv.cmd +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/install_venv.ps1 +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/recover_corrupt_venv +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/recover_corrupt_venv.cmd +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/recover_corrupt_venv.ps1 +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/sync_requirements +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/sync_requirements.cmd +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/sync_requirements.ps1 +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/sync_template +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/sync_template.cmd +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/sync_template.ps1 +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tasks/sync_template.ps1 +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tests/assets/.gitkeep +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tests/assets/pga_g_clipped.grd +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tests/assets/pga_g_clipped.tif +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/.gitkeep +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/gis/test_fishnet.py +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/regression_test_data/test_plot_raster.png +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/regression_test_data/test_write_raster_to_file.tif +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/test_create.py +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/test_io.py +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/test_meta.py +0 -0
- {rastr-0.6.0 → rastr-0.7.0}/uv.lock +0 -0
|
@@ -69,7 +69,9 @@
|
|
|
69
69
|
|
|
70
70
|
- Use `uv run pytest` for testing, and ensure all tests are passing before committing changes.
|
|
71
71
|
- Do not perform equality checks with floating point values; instead, use `pytest.approx`.
|
|
72
|
-
-
|
|
72
|
+
- Follow an arrange-act-assert structure in test functions with explicit comments separating each section where possible (context managers can force the assert and act sections to be unified).
|
|
73
|
+
- In the assert block of each test, use only one contiguous block of assert statements to ensure clarity and simplicity.
|
|
74
|
+
|
|
73
75
|
|
|
74
76
|
## Documentation & Workflow Management
|
|
75
77
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rastr
|
|
3
|
-
Version: 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/
|
|
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
|
|
@@ -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.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.7.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 7, 0)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -11,7 +11,9 @@ 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
|
|
|
@@ -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,
|
|
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 = []
|
|
@@ -312,14 +315,31 @@ def raster_from_point_cloud(
|
|
|
312
315
|
Raises:
|
|
313
316
|
ValueError: If any (x, y) points are duplicated, or if they are all collinear.
|
|
314
317
|
"""
|
|
315
|
-
from scipy.interpolate import LinearNDInterpolator
|
|
316
|
-
from scipy.spatial import KDTree, QhullError
|
|
317
|
-
|
|
318
|
-
x = np.asarray(x).ravel()
|
|
319
|
-
y = np.asarray(y).ravel()
|
|
320
|
-
z = np.asarray(z).ravel()
|
|
321
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
|
+
)
|
|
322
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]:
|
|
323
343
|
# Validate input arrays
|
|
324
344
|
if len(x) != len(y) or len(x) != len(z):
|
|
325
345
|
msg = "Length of x, y, and z must be equal."
|
|
@@ -343,53 +363,18 @@ def raster_from_point_cloud(
|
|
|
343
363
|
msg = "Duplicate (x, y) points found. Each (x, y) point must be unique."
|
|
344
364
|
raise ValueError(msg)
|
|
345
365
|
|
|
346
|
-
|
|
347
|
-
if cell_size is None:
|
|
348
|
-
# Half the 5th percentile of nearest neighbor distances between the (x,y) points
|
|
349
|
-
tree = KDTree(xy_points)
|
|
350
|
-
distances, _ = tree.query(xy_points, k=2)
|
|
351
|
-
distances: np.ndarray
|
|
352
|
-
cell_size = float(np.percentile(distances[distances > 0], 5)) / 2
|
|
353
|
-
|
|
354
|
-
# Compute bounds from data
|
|
355
|
-
minx, miny, maxx, maxy = np.min(x), np.min(y), np.max(x), np.max(y)
|
|
356
|
-
|
|
357
|
-
# Compute grid shape
|
|
358
|
-
width = int(np.ceil((maxx - minx) / cell_size))
|
|
359
|
-
height = int(np.ceil((maxy - miny) / cell_size))
|
|
360
|
-
shape = (height, width)
|
|
366
|
+
return x, y, z
|
|
361
367
|
|
|
362
|
-
# Compute transform: upper left corner is (minx, maxy)
|
|
363
|
-
transform = Affine.translation(minx, maxy) * Affine.scale(cell_size, -cell_size)
|
|
364
368
|
|
|
365
|
-
|
|
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."""
|
|
366
373
|
rows, cols = np.indices(shape)
|
|
367
374
|
xs, ys = rasterio.transform.xy(
|
|
368
|
-
transform=transform, rows=rows, cols=cols, offset="center"
|
|
375
|
+
transform=raster_meta.transform, rows=rows, cols=cols, offset="center"
|
|
369
376
|
)
|
|
370
377
|
grid_x = np.array(xs).ravel()
|
|
371
378
|
grid_y = np.array(ys).ravel()
|
|
372
379
|
|
|
373
|
-
|
|
374
|
-
try:
|
|
375
|
-
interpolator = LinearNDInterpolator(
|
|
376
|
-
points=xy_points, values=z, fill_value=np.nan
|
|
377
|
-
)
|
|
378
|
-
except QhullError as err:
|
|
379
|
-
msg = (
|
|
380
|
-
"Failed to interpolate. This may be due to insufficient or "
|
|
381
|
-
"degenerate input points. Ensure that the (x, y) points are not all "
|
|
382
|
-
"collinear (i.e. that the convex hull is non-degenerate)."
|
|
383
|
-
)
|
|
384
|
-
raise ValueError(msg) from err
|
|
385
|
-
|
|
386
|
-
grid_values = np.array(interpolator(np.column_stack((grid_x, grid_y))))
|
|
387
|
-
|
|
388
|
-
arr = grid_values.reshape(shape).astype(np.float32)
|
|
389
|
-
|
|
390
|
-
raster_meta = RasterMeta(
|
|
391
|
-
cell_size=cell_size,
|
|
392
|
-
crs=crs,
|
|
393
|
-
transform=transform,
|
|
394
|
-
)
|
|
395
|
-
return Raster(arr=arr, raster_meta=raster_meta)
|
|
380
|
+
return grid_x, grid_y
|
|
@@ -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
|
|
@@ -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
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
)
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from affine import Affine
|
|
7
|
+
from pydantic import BaseModel, InstanceOf
|
|
8
|
+
from pyproj import CRS
|
|
9
|
+
|
|
10
|
+
from rastr.gis.crs import get_affine_sign
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from numpy.typing import NDArray
|
|
14
|
+
from typing_extensions import Self
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RasterMeta(BaseModel, extra="forbid"):
|
|
18
|
+
"""Raster metadata.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
cell_size: Cell size in meters.
|
|
22
|
+
crs: Coordinate reference system.
|
|
23
|
+
transform: The affine transformation associated with the raster. This is based
|
|
24
|
+
on the CRS, the cell size, as well as the offset/origin.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
cell_size: float
|
|
28
|
+
crs: InstanceOf[CRS]
|
|
29
|
+
transform: InstanceOf[Affine]
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def example(cls) -> Self:
|
|
33
|
+
"""Create an example RasterMeta object."""
|
|
34
|
+
return cls(
|
|
35
|
+
cell_size=2.0,
|
|
36
|
+
crs=CRS.from_epsg(2193),
|
|
37
|
+
transform=Affine.scale(2.0, 2.0),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def get_cell_centre_coords(self, shape: tuple[int, int]) -> NDArray:
|
|
41
|
+
"""Return an array of (x, y) coordinates for the center of each cell.
|
|
42
|
+
|
|
43
|
+
The coordinates will be in the coordinate system defined by the
|
|
44
|
+
raster's transform.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
shape: (rows, cols) of the raster array.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
(x, y) coordinates for each cell center, with shape (rows, cols, 2)
|
|
51
|
+
"""
|
|
52
|
+
x_coords = self.get_cell_x_coords(shape[1]) # cols for x-coordinates
|
|
53
|
+
y_coords = self.get_cell_y_coords(shape[0]) # rows for y-coordinates
|
|
54
|
+
coords = np.stack(np.meshgrid(x_coords, y_coords), axis=-1)
|
|
55
|
+
return coords
|
|
56
|
+
|
|
57
|
+
def get_cell_x_coords(self, n_columns: int) -> NDArray:
|
|
58
|
+
"""Return an array of x coordinates for the center of each cell.
|
|
59
|
+
|
|
60
|
+
The coordinates will be in the coordinate system defined by the
|
|
61
|
+
raster's transform.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
n_columns: Number of columns in the raster array.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
x_coordinates at cell centers, with shape (n_columns,)
|
|
68
|
+
"""
|
|
69
|
+
x_idx = np.arange(n_columns) + 0.5
|
|
70
|
+
y_idx = np.zeros_like(x_idx) # Use y=0 for a single row
|
|
71
|
+
x_coords, _ = self.transform * (x_idx, y_idx) # type: ignore[reportAssignmentType] overloaded tuple size in affine
|
|
72
|
+
return x_coords
|
|
73
|
+
|
|
74
|
+
def get_cell_y_coords(self, n_rows: int) -> NDArray:
|
|
75
|
+
"""Return an array of y coordinates for the center of each cell.
|
|
76
|
+
|
|
77
|
+
The coordinates will be in the coordinate system defined by the
|
|
78
|
+
raster's transform.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
n_rows: Number of rows in the raster array.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
y_coordinates at cell centers, with shape (n_rows,)
|
|
85
|
+
"""
|
|
86
|
+
x_idx = np.zeros(n_rows) # Use x=0 for a single column
|
|
87
|
+
y_idx = np.arange(n_rows) + 0.5
|
|
88
|
+
_, y_coords = self.transform * (x_idx, y_idx) # type: ignore[reportAssignmentType] overloaded tuple size in affine
|
|
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
|