Skip to content

Commit 89ac5d4

Browse files
authored
feat(surface): Generate dense mesh surface (#997)
### Change list - Currently, as implemented in #976 the SurfaceLayer only supports four vertices and two internal triangles. Over larger areas this will run the risk of larger reprojection non-linearities. - This PR implements a quick initial hack to this problem: offering the user the ability to specify the height and width of the regular grid they'd like to use. ```py import rasterio from sidecar import Sidecar from lonboard import Map from lonboard.experimental._surface import SurfaceLayer # aws s3 cp s3://naip-visualization/ny/2022/60cm/rgb/40073/m_4007307_sw_18_060_20220803.tif ./ --request-payer src = rasterio.open("m_4007307_sw_18_060_20220803.tif") layer = SurfaceLayer.from_rasterio( src, downscale=4, opacity=0.8, wireframe=True ) sidecar = Sidecar(anchor="split-right") m = Map(layer) with sidecar: display(m) ``` <img width="588" height="618" alt="image" src="https://github.com/user-attachments/assets/45943649-4669-4bef-8879-eacc4598a054" /> <img width="468" height="581" alt="image" src="https://github.com/user-attachments/assets/e5dd64e4-6c59-4592-a0df-07ff90fc8f96" />
1 parent c7e6a91 commit 89ac5d4

File tree

3 files changed

+168
-29
lines changed

3 files changed

+168
-29
lines changed

lonboard/experimental/_surface.py

Lines changed: 81 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,71 @@ def load_arr_and_transform(
5050
return arr, overview_transform
5151

5252

53+
def apply_colormap(
54+
arr: NDArray[np.uint8],
55+
cmap: dict[int, tuple[int, int, int] | tuple[int, int, int, int]],
56+
) -> NDArray[np.uint8]:
57+
"""Apply rasterio colormap to single-band array."""
58+
lut = np.zeros((max(cmap.keys()), 4), dtype=np.uint8)
59+
for k, v in cmap.items():
60+
lut[k] = v
61+
62+
return lut[arr]
63+
64+
65+
def generate_mesh_grid(
66+
*,
67+
n_rows: int = 50,
68+
n_cols: int = 50,
69+
) -> tuple[NDArray[np.float32], NDArray[np.uint32]]:
70+
"""Generate a regular grid mesh with the given number of rows and columns.
71+
72+
Creates a mesh covering the unit square [0, 1] x [0, 1] divided into
73+
n_rows x n_cols cells. Each cell is split into two triangles along the diagonal.
74+
75+
Args:
76+
n_rows: Number of rows in the grid
77+
n_cols: Number of columns in the grid
78+
79+
Returns:
80+
positions: Array of shape ((n_rows+1) * (n_cols+1), 2) containing vertex positions
81+
in normalized image coordinates [0, 1]
82+
triangles: Array of shape (n_rows * n_cols * 2, 3) containing triangle vertex indices
83+
84+
"""
85+
# Generate vertex grid
86+
# We need (n_rows + 1) x (n_cols + 1) vertices to create n_rows x n_cols cells
87+
x = np.linspace(0, 1, n_cols + 1, dtype=np.float32)
88+
y = np.linspace(0, 1, n_rows + 1, dtype=np.float32)
89+
90+
# Create meshgrid and flatten to get all vertex positions
91+
xx, yy = np.meshgrid(x, y)
92+
positions = np.stack([xx.ravel(), yy.ravel()], axis=-1)
93+
94+
# Generate triangle indices
95+
# For each cell (i, j), we create two triangles:
96+
# Triangle 1: bottom-left, bottom-right, top-left
97+
# Triangle 2: bottom-right, top-right, top-left
98+
triangles = np.empty((n_rows * n_cols * 2, 3), dtype=np.uint32)
99+
100+
i = 0
101+
for row in range(n_rows):
102+
for col in range(n_cols):
103+
# Vertex indices for the current cell
104+
bottom_left = row * (n_cols + 1) + col
105+
bottom_right = bottom_left + 1
106+
top_left = (row + 1) * (n_cols + 1) + col
107+
top_right = top_left + 1
108+
109+
triangles[i] = [bottom_left, bottom_right, top_left]
110+
triangles[i + 1] = [bottom_right, top_right, top_left]
111+
i += 2
112+
113+
triangles_array = np.array(triangles, dtype=np.uint32)
114+
115+
return positions, triangles_array
116+
117+
53118
def rescale_positions_to_image_crs(
54119
positions: NDArray[np.float32],
55120
*,
@@ -76,30 +141,33 @@ def from_rasterio(
76141
src: DatasetReader,
77142
*,
78143
downscale: int | None = None,
144+
mesh_n_rows: int = 50,
145+
mesh_n_cols: int = 50,
79146
**kwargs: Any,
80147
) -> Self:
81148
import rasterio.plot
82149

83150
arr, transform = load_arr_and_transform(src, downscale=downscale)
84151

85-
# swap axes order from (bands, rows, columns) to (rows, columns, bands)
86-
image_arr: np.ndarray = rasterio.plot.reshape_as_image(arr)
152+
if arr.shape[0] == 1 and src.colormap(1) is not None:
153+
image_arr = apply_colormap(arr[0], src.colormap(1))
154+
else:
155+
# swap axes order from (bands, rows, columns) to (rows, columns, bands)
156+
image_arr = rasterio.plot.reshape_as_image(arr)
87157

88158
image_height = image_arr.shape[0]
89159
image_width = image_arr.shape[1]
90160

91-
positions = np.array(
92-
[
93-
[0, 0], # bottom-left
94-
[1, 0], # bottom-right
95-
[0, 1], # top-left
96-
[1, 1], # top-right
97-
],
98-
dtype=np.float32,
161+
# Generate mesh grid in normalized [0, 1] coordinates
162+
# These positions serve as texture coordinates
163+
tex_coords, triangles = generate_mesh_grid(
164+
n_rows=mesh_n_rows,
165+
n_cols=mesh_n_cols,
99166
)
100167

168+
# Reproject mesh vertices from image CRS to EPSG:4326
101169
source_crs_coords = rescale_positions_to_image_crs(
102-
positions,
170+
tex_coords,
103171
image_height=image_height,
104172
image_width=image_width,
105173
transform=transform,
@@ -113,29 +181,13 @@ def from_rasterio(
113181
)
114182
lonlat_coords = np.stack([lons, lats], axis=-1)
115183

184+
# Add z-coordinate (elevation), setting to zero as we care about a flat mesh on
185+
# the map surface
116186
final_positions = np.concatenate(
117187
[lonlat_coords, np.zeros((lonlat_coords.shape[0], 1), dtype=np.float32)],
118188
axis=-1,
119189
)
120190

121-
triangles = np.array(
122-
[
123-
[0, 1, 2],
124-
[1, 2, 3],
125-
],
126-
dtype=np.uint32,
127-
)
128-
129-
tex_coords = np.array(
130-
[
131-
[0, 0], # bottom-left
132-
[1, 0], # bottom-right
133-
[0, 1], # top-left
134-
[1, 1], # top-right
135-
],
136-
dtype=np.float32,
137-
)
138-
139191
return cls(
140192
positions=final_positions,
141193
triangles=triangles,

tests/layers/surface/__init__.py

Whitespace-only changes.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Test mesh generation for SurfaceLayer."""
2+
3+
import numpy as np
4+
5+
from lonboard.experimental._surface import generate_mesh_grid
6+
7+
8+
def test_generate_mesh_grid_basic():
9+
"""Test basic mesh generation with small grid."""
10+
n_rows, n_cols = 2, 2
11+
positions, triangles = generate_mesh_grid(n_rows=n_rows, n_cols=n_cols)
12+
13+
# Check positions shape: (n_rows+1) * (n_cols+1) vertices
14+
expected_num_vertices = (n_rows + 1) * (n_cols + 1) # 3 * 3 = 9
15+
assert positions.shape == (expected_num_vertices, 2)
16+
17+
# Check triangles shape: n_rows * n_cols * 2 triangles
18+
expected_num_triangles = n_rows * n_cols * 2 # 2 * 2 * 2 = 8
19+
assert triangles.shape == (expected_num_triangles, 3)
20+
21+
# Check all triangle indices are valid (within vertex range)
22+
assert np.all(triangles >= 0)
23+
assert np.all(triangles < expected_num_vertices)
24+
25+
# Check positions are in [0, 1] range
26+
assert np.all(positions >= 0)
27+
assert np.all(positions <= 1)
28+
29+
30+
def test_generate_mesh_grid_single_cell():
31+
"""Test with a single cell (simplest case)."""
32+
positions, triangles = generate_mesh_grid(n_rows=1, n_cols=1)
33+
34+
# Should have 4 vertices
35+
assert positions.shape == (4, 2)
36+
37+
# Should have 2 triangles
38+
assert triangles.shape == (2, 3)
39+
40+
# Verify exact positions for single cell
41+
expected_positions = np.array([[0, 0], [1, 0], [0, 1], [1, 1]], dtype=np.float32)
42+
np.testing.assert_allclose(positions, expected_positions)
43+
44+
# Verify triangle indices
45+
assert np.all(triangles >= 0)
46+
assert np.all(triangles < 4)
47+
48+
49+
def test_generate_mesh_grid_larger():
50+
"""Test with larger grid to ensure scaling works."""
51+
n_rows, n_cols = 50, 50
52+
positions, triangles = generate_mesh_grid(n_rows=n_rows, n_cols=n_cols)
53+
54+
expected_num_vertices = (n_rows + 1) * (n_cols + 1) # 51 * 51 = 2601
55+
expected_num_triangles = n_rows * n_cols * 2 # 50 * 50 * 2 = 5000
56+
57+
assert positions.shape == (expected_num_vertices, 2)
58+
assert triangles.shape == (expected_num_triangles, 3)
59+
60+
# Most important: all indices must be valid
61+
assert np.all(triangles >= 0)
62+
assert np.all(triangles < expected_num_vertices)
63+
64+
# Check corners are correct
65+
np.testing.assert_allclose(positions[0], [0, 0]) # bottom-left
66+
np.testing.assert_allclose(positions[n_cols], [1, 0]) # bottom-right
67+
np.testing.assert_allclose(positions[n_cols * (n_rows + 1)], [0, 1]) # top-left
68+
np.testing.assert_allclose(
69+
positions[(n_rows + 1) * (n_cols + 1) - 1],
70+
[1, 1],
71+
) # top-right
72+
73+
74+
def test_triangle_winding():
75+
"""Test that triangles have consistent winding order."""
76+
positions, triangles = generate_mesh_grid(n_rows=2, n_cols=2)
77+
78+
# Check that all triangles have counter-clockwise winding
79+
for tri in triangles:
80+
v0, v1, v2 = positions[tri]
81+
# Compute cross product to check winding
82+
edge1 = v1 - v0
83+
edge2 = v2 - v0
84+
cross = edge1[0] * edge2[1] - edge1[1] * edge2[0]
85+
# For counter-clockwise winding in screen space, cross product should be positive
86+
# (though this depends on coordinate system)
87+
assert cross > 0

0 commit comments

Comments
 (0)