Skip to content

Commit c29945a

Browse files
authored
Add point cloud gridding (#553)
1 parent 86bc449 commit c29945a

File tree

3 files changed

+186
-2
lines changed

3 files changed

+186
-2
lines changed

geoutils/pointcloud.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Module for point cloud manipulation."""
2+
3+
import warnings
4+
from typing import Literal
5+
6+
import geopandas as gpd
7+
import numpy as np
8+
from scipy.interpolate import griddata
9+
10+
from geoutils._typing import NDArrayNum
11+
12+
13+
def _grid_pointcloud(
14+
pc: gpd.GeoDataFrame,
15+
grid_coords: tuple[NDArrayNum, NDArrayNum],
16+
data_column_name: str = "b1",
17+
resampling: Literal["nearest", "linear", "cubic"] = "linear",
18+
dist_nodata_pixel: float = 1.0,
19+
) -> NDArrayNum:
20+
"""
21+
Grid point cloud (possibly irregular coordinates) to raster (regular grid) using delaunay triangles interpolation.
22+
23+
Based on scipy.interpolate.griddata combined to a nearest point search to replace values of grid cells further than
24+
a certain distance (in number of pixels) by nodata values (as griddata interpolates all values in convex hull, no
25+
matter the distance).
26+
27+
:param pc: Point cloud.
28+
:param grid_coords: Grid coordinates for X and Y.
29+
:param data_column_name: Name of data column for point cloud (only if passed as a geodataframe).
30+
:param resampling: Resampling method within delauney triangles (defaults to linear).
31+
:param dist_nodata_pixel: Distance from the point cloud after which grid cells are filled by nodata values,
32+
expressed in number of pixels.
33+
"""
34+
35+
# 1/ Interpolate irregular point cloud on a regular grid
36+
37+
# Get meshgrid coordinates
38+
xx, yy = np.meshgrid(grid_coords[0], grid_coords[1])
39+
40+
# Use griddata on all points
41+
aligned_dem = griddata(
42+
points=(pc.geometry.x.values, pc.geometry.y.values),
43+
values=pc[data_column_name].values,
44+
xi=(xx, yy),
45+
method=resampling,
46+
rescale=True, # Rescale inputs to unit cube to avoid precision issues
47+
)
48+
49+
# 2/ Identify which grid points are more than X pixels away from the point cloud, and convert to NaNs
50+
# (otherwise all grid points in the convex hull of the irregular triangulation are filled, no matter the distance)
51+
52+
# Get the nearest point for each grid point
53+
grid_pc = gpd.GeoDataFrame(
54+
data={"placeholder": np.ones(len(xx.ravel()))}, geometry=gpd.points_from_xy(x=xx.ravel(), y=yy.ravel())
55+
)
56+
with warnings.catch_warnings():
57+
warnings.filterwarnings("ignore", category=UserWarning, message="Geometry is in a geographic CRS.*")
58+
near = gpd.sjoin_nearest(grid_pc, pc)
59+
# In case there are several points at the same distance, it doesn't matter which one is used to compute the
60+
# distance, so we keep the first index of closest point
61+
index_right = near.groupby(by=near.index)["index_right"].min()
62+
63+
# Compute distance between points as a function of the pixel sizes in X and Y
64+
res_x = np.abs(grid_coords[0][1] - grid_coords[0][0])
65+
res_y = np.abs(grid_coords[1][1] - grid_coords[1][0])
66+
dist = np.sqrt(
67+
((pc.geometry.x.values[index_right] - grid_pc.geometry.x.values) / res_x) ** 2
68+
+ ((pc.geometry.y.values[index_right] - grid_pc.geometry.y.values) / res_y) ** 2
69+
)
70+
71+
# Replace all points further away than the distance of nodata by NaNs
72+
aligned_dem[dist.reshape(aligned_dem.shape) > dist_nodata_pixel] = np.nan
73+
74+
# Flip Y axis of grid
75+
aligned_dem = np.flip(aligned_dem, axis=0)
76+
77+
return aligned_dem

geoutils/raster/raster.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3603,7 +3603,7 @@ def ij2xy(
36033603

36043604
def coords(
36053605
self, grid: bool = True, shift_area_or_point: bool | None = None, force_offset: str | None = None
3606-
) -> tuple[NDArrayNum, ...]:
3606+
) -> tuple[NDArrayNum, NDArrayNum]:
36073607
"""
36083608
Get coordinates (x,y) of all pixels in the raster.
36093609
@@ -3631,7 +3631,7 @@ def coords(
36313631
# If grid is True, return coordinate grids
36323632
if grid:
36333633
meshgrid = tuple(np.meshgrid(xx, np.flip(yy)))
3634-
return meshgrid
3634+
return meshgrid # type: ignore
36353635
else:
36363636
return np.asarray(xx), np.asarray(yy)
36373637

tests/test_pointcloud.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Test module for point cloud functionalities."""
2+
3+
import geopandas as gpd
4+
import numpy as np
5+
import rasterio as rio
6+
from shapely import geometry
7+
8+
from geoutils import Raster
9+
from geoutils.pointcloud import _grid_pointcloud
10+
11+
12+
class TestPointCloud:
13+
def test_grid_pc__chull(self) -> None:
14+
"""Test point cloud gridding."""
15+
16+
# 1/ Check gridding interpolation falls back exactly on original raster
17+
18+
# Create a point cloud from interpolating a grid, so we can compare back after to check consistency
19+
rng = np.random.default_rng(42)
20+
shape = (10, 12)
21+
rst_arr = np.linspace(0, 10, int(np.prod(shape))).reshape(*shape)
22+
transform = rio.transform.from_origin(0, shape[0] - 1, 1, 1)
23+
rst = Raster.from_array(rst_arr, transform=transform, crs=4326, nodata=100)
24+
25+
# Generate random coordinates to interpolate, to create an irregular point cloud
26+
points = rng.integers(low=1, high=shape[0] - 1, size=(100, 2)) + rng.normal(0, 0.15, size=(100, 2))
27+
b1_value = rst.interp_points((points[:, 0], points[:, 1]))
28+
pc = gpd.GeoDataFrame(data={"b1": b1_value}, geometry=gpd.points_from_xy(x=points[:, 0], y=points[:, 1]))
29+
grid_coords = rst.coords(grid=False)
30+
31+
# Grid the point cloud
32+
gridded_pc = _grid_pointcloud(pc, grid_coords=grid_coords)
33+
34+
# Compare back to raster, all should be very close (but not exact, some info is lost due to interpolations)
35+
valids = np.isfinite(gridded_pc)
36+
assert np.allclose(gridded_pc[valids], rst.data.data[valids], rtol=10e-5)
37+
38+
# 2/ Check the propagation of nodata values
39+
40+
# 2.1/ Grid points outside the convex hull of all points should always be nodata
41+
42+
# We convert the full raster to a point cloud, keeping all cells even nodata
43+
rst_pc = rst.to_pointcloud(skip_nodata=False).ds
44+
45+
# We define a multi-point geometry from the individual points, and compute its convex hull
46+
poly = geometry.MultiPoint([[p.x, p.y] for p in pc.geometry])
47+
chull = poly.convex_hull
48+
49+
# We compute the index of grid cells interesting the convex hull
50+
ind_inters_convhull = rst_pc.intersects(chull)
51+
52+
# We get corresponding 1D indexes for gridded output
53+
i, j = rst.xy2ij(x=rst_pc.geometry.x.values, y=rst_pc.geometry.y.values)
54+
55+
# Check all values outside convex hull are NaNs
56+
assert all(~np.isfinite(gridded_pc[i[~ind_inters_convhull], j[~ind_inters_convhull]]))
57+
58+
# 2.2/ For the rest of the points, data should be valid only if a point exists within 1 pixel of their
59+
# coordinate, that is the closest rounded number
60+
# TODO: Replace by check with distance, because some pixel not rounded can also be at less than 1 from a point
61+
62+
# Compute min distance to irregular point cloud for each grid point
63+
list_min_dist = []
64+
for p in rst_pc.geometry:
65+
min_dist = np.min(np.sqrt((p.x - pc.geometry.x.values) ** 2 + (p.y - pc.geometry.y.values) ** 2))
66+
list_min_dist.append(min_dist)
67+
68+
ind_close = np.array(list_min_dist) <= 1
69+
# We get the indexes for these coordinates
70+
iround, jround = rst.xy2ij(x=rst_pc.geometry.x.values[ind_close], y=rst_pc.geometry.y.values[ind_close])
71+
72+
# Keep only indexes in the convex hull
73+
indexes_close = [(iround[k], jround[k]) for k in range(len(iround))]
74+
indexes_chull = [(i[k], j[k]) for k in range(len(i)) if ind_inters_convhull[k]]
75+
close_in_chull = [tup for tup in indexes_close if tup in indexes_chull]
76+
iclosechull, jclosehull = list(zip(*close_in_chull))
77+
78+
# All values close pixel in the convex hull should be valid
79+
assert all(np.isfinite(gridded_pc[iclosechull, jclosehull]))
80+
81+
# Other values in the convex hull should not be
82+
far_in_chull = [tup for tup in indexes_chull if tup not in indexes_close]
83+
ifarchull, jfarchull = list(zip(*far_in_chull))
84+
85+
assert all(~np.isfinite(gridded_pc[ifarchull, jfarchull]))
86+
87+
# Check for a different distance value
88+
gridded_pc = _grid_pointcloud(pc, grid_coords=grid_coords, dist_nodata_pixel=0.5)
89+
ind_close = np.array(list_min_dist) <= 0.5
90+
91+
# We get the indexes for these coordinates
92+
iround, jround = rst.xy2ij(x=rst_pc.geometry.x.values[ind_close], y=rst_pc.geometry.y.values[ind_close])
93+
94+
# Keep only indexes in the convex hull
95+
indexes_close = [(iround[k], jround[k]) for k in range(len(iround))]
96+
indexes_chull = [(i[k], j[k]) for k in range(len(i)) if ind_inters_convhull[k]]
97+
close_in_chull = [tup for tup in indexes_close if tup in indexes_chull]
98+
iclosechull, jclosehull = list(zip(*close_in_chull))
99+
100+
# All values close pixel in the convex hull should be valid
101+
assert all(np.isfinite(gridded_pc[iclosechull, jclosehull]))
102+
103+
# Other values in the convex hull should not be
104+
far_in_chull = [tup for tup in indexes_chull if tup not in indexes_close]
105+
ifarchull, jfarchull = list(zip(*far_in_chull))
106+
107+
assert all(~np.isfinite(gridded_pc[ifarchull, jfarchull]))

0 commit comments

Comments
 (0)