Skip to content

Commit 80cf886

Browse files
authored
added xarray accessor method (#43)
1 parent 641dce5 commit 80cf886

8 files changed

Lines changed: 1513 additions & 659 deletions

examples/ham_radio_viewshed_analysis.ipynb

Lines changed: 63 additions & 61 deletions
Large diffs are not rendered by default.

examples/multi_gas_urban_rf_planning.ipynb

Lines changed: 310 additions & 78 deletions
Large diffs are not rendered by default.

examples/oblique_3d_terrain_visualization.ipynb

Lines changed: 33 additions & 369 deletions
Large diffs are not rendered by default.

examples/playground.py

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Interactive playground for rtxpy viewshed and hillshade analysis.
22
33
This example demonstrates real-time viewshed and hillshade computation
4-
using GPU-accelerated ray tracing. Click on the terrain to move the
5-
viewshed origin point.
4+
using GPU-accelerated ray tracing via the xarray accessor. Click on the
5+
terrain to move the viewshed origin point.
66
77
Requirements:
88
pip install rtxpy[analysis] matplotlib xarray rioxarray requests
@@ -15,7 +15,8 @@
1515
import requests
1616
from pathlib import Path
1717

18-
from rtxpy import RTX, viewshed, hillshade
18+
# Import rtxpy to register the .rtx accessor
19+
import rtxpy
1920

2021

2122
def download_crater_lake_dem(output_path):
@@ -124,8 +125,8 @@ def load_terrain():
124125
elev_min = float(terrain.min())
125126
elev_max = float(terrain.max())
126127

127-
# Convert to cupy for GPU processing
128-
terrain.data = cupy.asarray(terrain.data)
128+
# Convert to cupy for GPU processing using the accessor
129+
terrain = terrain.rtx.to_cupy()
129130

130131
print(f"Terrain loaded: {terrain.shape}, elevation range: "
131132
f"{elev_min:.0f}m to {elev_max:.0f}m (scaled)")
@@ -215,9 +216,6 @@ def run_playground():
215216
runs = 360
216217
H, W = terrain.data.shape
217218

218-
# Create RTX instance for reuse across frames
219-
rtx = RTX()
220-
221219
# Set up the figure with title
222220
fig, ax = plt.subplots(figsize=(12, 10))
223221
fig.canvas.mpl_connect('button_press_event', onclick)
@@ -243,6 +241,7 @@ def run_playground():
243241
print(f"Starting hiking simulation around Crater Lake...")
244242
print(f" Terrain size: {H}x{W}")
245243
print(f" Frames: {runs}")
244+
print(f" Using xarray .rtx accessor for GPU acceleration")
246245
print(f" Click anywhere to teleport the observer!\n")
247246

248247
for i in range(runs):
@@ -255,18 +254,18 @@ def run_playground():
255254
# Slowly rotate sun for dynamic lighting
256255
azimuth = (azimuth + 1) % 360
257256

258-
# Compute hillshade and viewshed
257+
# Compute hillshade and viewshed using the accessor
259258
rt_start = time.time()
260-
hs = hillshade(terrain,
261-
shadows=True,
262-
azimuth=azimuth,
263-
angle_altitude=25,
264-
rtx=rtx)
265-
vs = viewshed(terrain,
266-
x=vsw,
267-
y=vsh,
268-
observer_elev=2.0, # 2 meter observer height (standing hiker)
269-
rtx=rtx)
259+
hs = terrain.rtx.hillshade(
260+
shadows=True,
261+
azimuth=azimuth,
262+
angle_altitude=25
263+
)
264+
vs = terrain.rtx.viewshed(
265+
x=vsw,
266+
y=vsh,
267+
observer_elev=2.0 # 2 meter observer height (standing hiker)
268+
)
270269
rt_time = time.time() - rt_start
271270

272271
# Convert hillshade to grayscale image

rtxpy/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,9 @@
1010
from .analysis import viewshed, hillshade
1111

1212
__version__ = "0.0.5"
13+
14+
# Register xarray accessor if xarray is available
15+
try:
16+
from . import accessor # noqa: F401
17+
except ImportError:
18+
pass # xarray not installed

rtxpy/accessor.py

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
"""xarray accessor for rtxpy GPU-accelerated terrain analysis."""
2+
3+
import xarray as xr
4+
from .rtx import RTX, has_cupy
5+
6+
7+
@xr.register_dataarray_accessor("rtx")
8+
class RTXAccessor:
9+
"""xarray DataArray accessor for rtxpy GPU-accelerated terrain analysis.
10+
11+
This accessor provides convenient access to rtxpy analysis functions
12+
directly on xarray DataArrays.
13+
14+
Examples
15+
--------
16+
>>> import rtxpy
17+
>>> import xarray as xr
18+
>>> import cupy
19+
>>> dem = xr.open_dataarray('dem.tif')
20+
>>> dem = dem.copy(data=cupy.asarray(dem.data))
21+
>>> # Use accessor methods
22+
>>> hs = dem.rtx.hillshade(shadows=True)
23+
>>> vs = dem.rtx.viewshed(x=500000, y=4500000)
24+
"""
25+
26+
def __init__(self, xarray_obj):
27+
self._obj = xarray_obj
28+
self._rtx_instance = None
29+
30+
@property
31+
def _rtx(self):
32+
"""Lazily create and cache an RTX instance."""
33+
if self._rtx_instance is None:
34+
self._rtx_instance = RTX()
35+
return self._rtx_instance
36+
37+
def to_cupy(self):
38+
"""Convert DataArray data to cupy array on GPU.
39+
40+
Returns a new DataArray with data on the GPU. If the data is already
41+
a cupy array, returns the original DataArray unchanged.
42+
43+
Returns
44+
-------
45+
xarray.DataArray
46+
DataArray with cupy array data.
47+
48+
Raises
49+
------
50+
ImportError
51+
If cupy is not available.
52+
53+
Examples
54+
--------
55+
>>> dem_gpu = dem.rtx.to_cupy()
56+
>>> hs = dem_gpu.rtx.hillshade()
57+
"""
58+
if not has_cupy:
59+
raise ImportError(
60+
"cupy is required for GPU operations. "
61+
"Install with: conda install -c conda-forge cupy"
62+
)
63+
import cupy
64+
if isinstance(self._obj.data, cupy.ndarray):
65+
return self._obj
66+
return self._obj.copy(data=cupy.asarray(self._obj.data))
67+
68+
def viewshed(self, x, y, observer_elev=0, target_elev=0, rtx=None):
69+
"""Compute viewshed from observer point.
70+
71+
Determines which areas of the terrain are visible from a given
72+
observer location using GPU-accelerated ray tracing.
73+
74+
Parameters
75+
----------
76+
x : int or float
77+
X coordinate of the observer location (in raster coordinate units).
78+
y : int or float
79+
Y coordinate of the observer location (in raster coordinate units).
80+
observer_elev : float, optional
81+
Height offset above the terrain surface for the observer. Default is 0.
82+
target_elev : float, optional
83+
Height offset above the terrain surface for target points. Default is 0.
84+
rtx : RTX, optional
85+
Existing RTX instance to reuse. If None, uses the accessor's
86+
cached RTX instance.
87+
88+
Returns
89+
-------
90+
xarray.DataArray
91+
Visibility raster with the same coordinates as the input.
92+
Values indicate the viewing angle in degrees for visible cells,
93+
or -1 for cells not visible from the observer.
94+
95+
Raises
96+
------
97+
ValueError
98+
If x or y coordinates are outside the raster extent.
99+
If raster.data is not a cupy array.
100+
101+
Examples
102+
--------
103+
>>> vis = dem.rtx.viewshed(x=500000, y=4500000, observer_elev=2)
104+
"""
105+
from .analysis import viewshed as _viewshed
106+
return _viewshed(
107+
self._obj, x=x, y=y,
108+
observer_elev=observer_elev,
109+
target_elev=target_elev,
110+
rtx=rtx if rtx is not None else self._rtx
111+
)
112+
113+
def hillshade(self, shadows=False, azimuth=225, angle_altitude=25,
114+
name='hillshade', rtx=None):
115+
"""Compute hillshade illumination.
116+
117+
Creates a shaded relief effect by simulating how the terrain
118+
would look when illuminated by the sun from a given direction.
119+
120+
Parameters
121+
----------
122+
shadows : bool, optional
123+
If True, cast shadow rays to determine which areas are in shadow.
124+
Shadows are rendered at half brightness. Default is False.
125+
azimuth : int, optional
126+
Sun azimuth angle in degrees, measured clockwise from north.
127+
Default is 225 (southwest).
128+
angle_altitude : int, optional
129+
Sun altitude angle in degrees above the horizon. Default is 25.
130+
name : str, optional
131+
Name attribute for the output DataArray. Default is 'hillshade'.
132+
rtx : RTX, optional
133+
Existing RTX instance to reuse. If None, uses the accessor's
134+
cached RTX instance.
135+
136+
Returns
137+
-------
138+
xarray.DataArray
139+
Hillshade raster with values from 0 (dark) to 1 (bright).
140+
Edge pixels are set to NaN.
141+
142+
Examples
143+
--------
144+
>>> shade = dem.rtx.hillshade(shadows=True, azimuth=90)
145+
"""
146+
from .analysis import hillshade as _hillshade
147+
return _hillshade(
148+
self._obj, shadows=shadows, azimuth=azimuth,
149+
angle_altitude=angle_altitude, name=name,
150+
rtx=rtx if rtx is not None else self._rtx
151+
)
152+
153+
def clear(self):
154+
"""Remove all geometries and reset to single-GAS mode.
155+
156+
After calling this, the RTX instance can use either build() for
157+
single-GAS mode or add_geometry() for multi-GAS mode.
158+
159+
Examples
160+
--------
161+
>>> dem.rtx.clear()
162+
"""
163+
self._rtx.clear_scene()
164+
165+
def add_geometry(self, geometry_id, vertices, indices, transform=None):
166+
"""Add a geometry to the scene with an optional transform.
167+
168+
This enables multi-GAS mode, allowing multiple geometries to be
169+
traced together. Adding a geometry with an existing ID replaces it.
170+
171+
Parameters
172+
----------
173+
geometry_id : str
174+
Unique identifier for this geometry.
175+
vertices : array-like
176+
Vertex buffer (flattened float32 array, 3 floats per vertex).
177+
indices : array-like
178+
Index buffer (flattened int32 array, 3 ints per triangle).
179+
transform : list of float, optional
180+
12-float list representing a 3x4 row-major affine transform matrix.
181+
Defaults to identity. Format: [Xx, Xy, Xz, Tx, Yx, Yy, Yz, Ty, Zx, Zy, Zz, Tz]
182+
183+
Returns
184+
-------
185+
int
186+
0 on success, non-zero on error.
187+
188+
Examples
189+
--------
190+
>>> dem.rtx.add_geometry('terrain', vertices, indices)
191+
>>> dem.rtx.add_geometry('building', bldg_verts, bldg_idx, transform=[...])
192+
"""
193+
return self._rtx.add_geometry(geometry_id, vertices, indices, transform)
194+
195+
def remove_geometry(self, geometry_id):
196+
"""Remove a geometry from the scene.
197+
198+
Parameters
199+
----------
200+
geometry_id : str
201+
The ID of the geometry to remove.
202+
203+
Returns
204+
-------
205+
int
206+
0 on success, -1 if geometry not found.
207+
208+
Examples
209+
--------
210+
>>> dem.rtx.remove_geometry('building')
211+
"""
212+
return self._rtx.remove_geometry(geometry_id)
213+
214+
def list_geometries(self):
215+
"""Get a list of all geometry IDs in the scene.
216+
217+
Returns
218+
-------
219+
list of str
220+
List of geometry ID strings.
221+
222+
Examples
223+
--------
224+
>>> dem.rtx.list_geometries()
225+
['terrain', 'building1', 'building2']
226+
"""
227+
return self._rtx.list_geometries()
228+
229+
def get_geometry_count(self):
230+
"""Get the number of geometries in the scene.
231+
232+
Returns
233+
-------
234+
int
235+
Number of geometries (0 in single-GAS mode).
236+
237+
Examples
238+
--------
239+
>>> dem.rtx.get_geometry_count()
240+
3
241+
"""
242+
return self._rtx.get_geometry_count()
243+
244+
def has_geometry(self, geometry_id):
245+
"""Check if a geometry with the given ID exists.
246+
247+
Parameters
248+
----------
249+
geometry_id : str
250+
The ID of the geometry to check.
251+
252+
Returns
253+
-------
254+
bool
255+
True if the geometry exists, False otherwise.
256+
257+
Examples
258+
--------
259+
>>> dem.rtx.has_geometry('terrain')
260+
True
261+
"""
262+
return self._rtx.has_geometry(geometry_id)
263+
264+
def trace(self, rays, hits, num_rays, primitive_ids=None, instance_ids=None):
265+
"""Trace rays against the current acceleration structure.
266+
267+
Works with both single-GAS mode (after build()) and multi-GAS mode
268+
(after add_geometry()).
269+
270+
Parameters
271+
----------
272+
rays : array-like
273+
Ray buffer (8 float32 per ray: ox, oy, oz, tmin, dx, dy, dz, tmax).
274+
hits : array-like
275+
Hit buffer (4 float32 per hit: t, nx, ny, nz). t=-1 indicates a miss.
276+
num_rays : int
277+
Number of rays to trace.
278+
primitive_ids : array-like, optional
279+
Output buffer (num_rays x int32) for triangle indices.
280+
Will contain the index of the hit triangle within its geometry,
281+
or -1 for rays that missed.
282+
instance_ids : array-like, optional
283+
Output buffer (num_rays x int32) for geometry/instance indices.
284+
Will contain the instance ID of the hit geometry, or -1 for misses.
285+
Useful in multi-GAS mode to identify which geometry was hit.
286+
287+
Returns
288+
-------
289+
int
290+
0 on success, non-zero on error.
291+
292+
Examples
293+
--------
294+
>>> rays = np.array([ox, oy, oz, 0.0, dx, dy, dz, 1000.0], dtype=np.float32)
295+
>>> hits = np.zeros(4, dtype=np.float32)
296+
>>> dem.rtx.trace(rays, hits, 1)
297+
0
298+
"""
299+
return self._rtx.trace(rays, hits, num_rays, primitive_ids, instance_ids)

0 commit comments

Comments
 (0)