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.

Files changed (90) hide show
  1. {rastr-0.6.0 → rastr-0.7.0}/.github/copilot-instructions.md +3 -1
  2. {rastr-0.6.0 → rastr-0.7.0}/PKG-INFO +2 -2
  3. rastr-0.7.0/src/rastr/__init__.py +7 -0
  4. {rastr-0.6.0 → rastr-0.7.0}/src/rastr/_version.py +2 -2
  5. {rastr-0.6.0 → rastr-0.7.0}/src/rastr/create.py +34 -49
  6. rastr-0.7.0/src/rastr/gis/crs.py +67 -0
  7. rastr-0.7.0/src/rastr/gis/interpolate.py +50 -0
  8. {rastr-0.6.0 → rastr-0.7.0}/src/rastr/gis/smooth.py +73 -54
  9. rastr-0.7.0/src/rastr/meta.py +185 -0
  10. {rastr-0.6.0 → rastr-0.7.0}/src/rastr/raster.py +63 -1
  11. {rastr-0.6.0 → rastr-0.7.0}/tests/conftest.py +8 -0
  12. rastr-0.7.0/tests/rastr/gis/test_crs.py +53 -0
  13. {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/gis/test_smooth.py +5 -3
  14. {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/test_raster.py +202 -18
  15. rastr-0.6.0/src/rastr/gis/__init__.py +0 -0
  16. rastr-0.6.0/src/rastr/meta.py +0 -87
  17. {rastr-0.6.0 → rastr-0.7.0}/.copier-answers.yml +0 -0
  18. {rastr-0.6.0 → rastr-0.7.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  19. {rastr-0.6.0 → rastr-0.7.0}/.github/ISSUE_TEMPLATE/enhancement.md +0 -0
  20. {rastr-0.6.0 → rastr-0.7.0}/.github/workflows/ci.yml +0 -0
  21. {rastr-0.6.0 → rastr-0.7.0}/.github/workflows/release.yml +0 -0
  22. {rastr-0.6.0 → rastr-0.7.0}/.gitignore +0 -0
  23. {rastr-0.6.0 → rastr-0.7.0}/.pre-commit-config.yaml +0 -0
  24. {rastr-0.6.0 → rastr-0.7.0}/.python-version +0 -0
  25. {rastr-0.6.0 → rastr-0.7.0}/CONTRIBUTING.md +0 -0
  26. {rastr-0.6.0 → rastr-0.7.0}/LICENSE +0 -0
  27. {rastr-0.6.0 → rastr-0.7.0}/README.md +0 -0
  28. {rastr-0.6.0 → rastr-0.7.0}/docs/index.md +0 -0
  29. {rastr-0.6.0 → rastr-0.7.0}/docs/logo.svg +0 -0
  30. {rastr-0.6.0 → rastr-0.7.0}/mkdocs.yml +0 -0
  31. {rastr-0.6.0 → rastr-0.7.0}/pyproject.toml +0 -0
  32. {rastr-0.6.0 → rastr-0.7.0}/pyrightconfig.json +0 -0
  33. {rastr-0.6.0 → rastr-0.7.0}/requirements.txt +0 -0
  34. {rastr-0.6.0 → rastr-0.7.0}/src/archive/.gitkeep +0 -0
  35. {rastr-0.6.0 → rastr-0.7.0}/src/notebooks/.gitkeep +0 -0
  36. {rastr-0.6.0/src/rastr → rastr-0.7.0/src/rastr/arr}/__init__.py +0 -0
  37. {rastr-0.6.0 → rastr-0.7.0}/src/rastr/arr/fill.py +0 -0
  38. {rastr-0.6.0/src/rastr/arr → rastr-0.7.0/src/rastr/gis}/__init__.py +0 -0
  39. {rastr-0.6.0 → rastr-0.7.0}/src/rastr/gis/fishnet.py +0 -0
  40. {rastr-0.6.0 → rastr-0.7.0}/src/rastr/io.py +0 -0
  41. {rastr-0.6.0 → rastr-0.7.0}/src/scripts/.gitkeep +0 -0
  42. {rastr-0.6.0 → rastr-0.7.0}/src/scripts/demo_point_cloud.py +0 -0
  43. {rastr-0.6.0 → rastr-0.7.0}/src/scripts/demo_taper_border.py +0 -0
  44. {rastr-0.6.0 → rastr-0.7.0}/tasks/ABOUT_TASKS.md +0 -0
  45. {rastr-0.6.0 → rastr-0.7.0}/tasks/dev_sync.ps1 +0 -0
  46. {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/activate_venv.sh +0 -0
  47. {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/configure_project.sh +0 -0
  48. {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/dev_sync.sh +0 -0
  49. {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/install_backend.sh +0 -0
  50. {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/install_venv.sh +0 -0
  51. {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/recover_corrupt_venv.sh +0 -0
  52. {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/sh_runner.ps1 +0 -0
  53. {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/sync_requirements.sh +0 -0
  54. {rastr-0.6.0 → rastr-0.7.0}/tasks/scripts/sync_template.sh +0 -0
  55. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/activate_venv +0 -0
  56. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/activate_venv.cmd +0 -0
  57. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/activate_venv.ps1 +0 -0
  58. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/configure_project +0 -0
  59. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/configure_project.cmd +0 -0
  60. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/configure_project.ps1 +0 -0
  61. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/dev_sync +0 -0
  62. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/dev_sync.cmd +0 -0
  63. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/dev_sync.ps1 +0 -0
  64. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/install_backend +0 -0
  65. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/install_backend.cmd +0 -0
  66. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/install_backend.ps1 +0 -0
  67. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/install_venv +0 -0
  68. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/install_venv.cmd +0 -0
  69. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/install_venv.ps1 +0 -0
  70. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/recover_corrupt_venv +0 -0
  71. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/recover_corrupt_venv.cmd +0 -0
  72. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/recover_corrupt_venv.ps1 +0 -0
  73. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/sync_requirements +0 -0
  74. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/sync_requirements.cmd +0 -0
  75. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/sync_requirements.ps1 +0 -0
  76. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/sync_template +0 -0
  77. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/sync_template.cmd +0 -0
  78. {rastr-0.6.0 → rastr-0.7.0}/tasks/shims/sync_template.ps1 +0 -0
  79. {rastr-0.6.0 → rastr-0.7.0}/tasks/sync_template.ps1 +0 -0
  80. {rastr-0.6.0 → rastr-0.7.0}/tests/assets/.gitkeep +0 -0
  81. {rastr-0.6.0 → rastr-0.7.0}/tests/assets/pga_g_clipped.grd +0 -0
  82. {rastr-0.6.0 → rastr-0.7.0}/tests/assets/pga_g_clipped.tif +0 -0
  83. {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/.gitkeep +0 -0
  84. {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/gis/test_fishnet.py +0 -0
  85. {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/regression_test_data/test_plot_raster.png +0 -0
  86. {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/regression_test_data/test_write_raster_to_file.tif +0 -0
  87. {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/test_create.py +0 -0
  88. {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/test_io.py +0 -0
  89. {rastr-0.6.0 → rastr-0.7.0}/tests/rastr/test_meta.py +0 -0
  90. {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
- - Use only one assert statement per test function to ensure clarity and simplicity.
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.6.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/a1b755ea9cde7f81b2963dcb12917090019f2b47.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
@@ -0,0 +1,7 @@
1
+ from rastr.meta import RasterMeta
2
+ from rastr.raster import Raster
3
+
4
+ __all__ = [
5
+ "Raster",
6
+ "RasterMeta",
7
+ ]
@@ -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.6.0'
32
- __version_tuple__ = version_tuple = (0, 6, 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, -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
- # Heuristic for cell size if not provided
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
- # 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."""
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
- # Perform interpolation
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
- 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(
@@ -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