Skip to content

Commit 6d1b4da

Browse files
banesullivanclaude
andauthored
Performance: use ImageData when grid has uniform axis spacing (#81)
* Performance: use ImageData when grid has uniform axis spacing * Update actions * Use _generate_rectilinear_coords * Fix pkg_resources deprecation * Some type annotations, we'll deal with mypy later * Add 3D example to README * Bump PyVista * More Python version testing * Typing syntax issue on 3.9 * Only upload coverage with py3.12 * Update README * typing * Add volume rendering example notebook Demonstrates ImageData auto-detection with the cells3d dataset, GPU-accelerated volume rendering, dual-channel visualization, uniform vs non-uniform spacing comparison, and roundtrip conversion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add volume rendering to benchmark script Volume rendering is the primary motivation for the ImageData optimization. The benchmark now measures add_volume + render across mesh types and grid sizes, tests mapper compatibility, and shows ~1.9x speedup on the cells3d dataset. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Remove CI changes * lint * Cleanup * cleanup warnings --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cc606e4 commit 6d1b4da

25 files changed

Lines changed: 981 additions & 150 deletions

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,25 @@ plotting, and lazy evaluation of large datasets.
1414

1515
Try on MyBinder: https://mybinder.org/v2/gh/pyvista/pyvista-xarray/HEAD
1616

17+
The following is an example to visualize a 3D volume with PyVista:
18+
19+
```py
20+
import pvxarray
21+
import pyvista as pv
22+
import xarray as xr
23+
24+
ds = xr.tutorial.load_dataset("cells3d")
25+
da = ds.images
26+
nuclei = da.sel(c='nuclei').pyvista.mesh(x="x", y="y", z="z")
27+
28+
pl = pv.Plotter()
29+
pl.add_volume(nuclei, clim=(0, 30000), opacity='sigmoid')
30+
pl.enable_terrain_style()
31+
pl.show()
32+
```
33+
34+
![cells3d](https://raw.githubusercontent.com/pyvista/pyvista-xarray/main/imgs/cells3d.png)
35+
1736
```py
1837
import pvxarray
1938
import xarray as xr
@@ -254,6 +273,7 @@ directory contains Jupyter notebooks demonstrating various use cases:
254273
| [cartographic.ipynb](examples/cartographic.ipynb) | Geographic projections with GeoVista |
255274
| [radar.ipynb](examples/radar.ipynb) | Radar data with polar coordinates via xradar |
256275
| [sea_temps.ipynb](examples/sea_temps.ipynb) | Sea surface temperature raster data |
276+
| [volume_rendering.ipynb](examples/volume_rendering.ipynb) | Volume rendering with automatic ImageData detection |
257277

258278
There are also Python scripts for interactive Trame web applications:
259279
`examples/level_of_detail.py` and `examples/level_of_detail_geovista.py`.

benchmarks/benchmark_image_data.py

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
"""Benchmark: ImageData vs RectilinearGrid performance."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
import time
7+
8+
import numpy as np
9+
import pyvista as pv
10+
import xarray as xr
11+
12+
import pvxarray # noqa: F401 - registers the accessor
13+
14+
pv.OFF_SCREEN = True
15+
16+
17+
def format_times(times):
18+
"""Format timing array as mean +/- std."""
19+
return f"{times.mean() * 1000:.1f} +/- {times.std() * 1000:.1f} ms"
20+
21+
22+
def benchmark_volume_render(mesh, scalar_name, clim, n_iter=5, warmup=True):
23+
"""Time full volume rendering pipeline: add_volume + screenshot."""
24+
if warmup:
25+
pl = pv.Plotter(off_screen=True, window_size=(400, 400))
26+
pl.add_volume(mesh, scalars=scalar_name, clim=clim, opacity="sigmoid")
27+
pl.screenshot()
28+
pl.close()
29+
30+
times = []
31+
for _ in range(n_iter):
32+
pl = pv.Plotter(off_screen=True, window_size=(400, 400))
33+
start = time.perf_counter()
34+
pl.add_volume(mesh, scalars=scalar_name, clim=clim, opacity="sigmoid")
35+
pl.screenshot()
36+
elapsed = time.perf_counter() - start
37+
times.append(elapsed)
38+
pl.close()
39+
40+
return np.array(times)
41+
42+
43+
def benchmark_mesh_creation(da, x, y, z, n_iter=10):
44+
"""Time mesh creation via the pvxarray accessor."""
45+
_ = da.pyvista.mesh(x=x, y=y, z=z)
46+
47+
times = []
48+
for _ in range(n_iter):
49+
if hasattr(da.pyvista, "_mesh"):
50+
del da.pyvista._mesh
51+
start = time.perf_counter()
52+
mesh = da.pyvista.mesh(x=x, y=y, z=z)
53+
times.append(time.perf_counter() - start)
54+
55+
return mesh, np.array(times)
56+
57+
58+
def make_rectilinear(da, x, y, z):
59+
"""Create a RectilinearGrid from the same data (bypassing ImageData optimization)."""
60+
rg = pv.RectilinearGrid()
61+
rg.x = da[x].values.astype(float)
62+
rg.y = da[y].values.astype(float)
63+
rg.z = da[z].values.astype(float)
64+
rg[da.name or "data"] = da.values.ravel()
65+
return rg
66+
67+
68+
def make_image_data(da, x, y, z):
69+
"""Create an ImageData from the same data."""
70+
xx = da[x].values.astype(float)
71+
yy = da[y].values.astype(float)
72+
zz = da[z].values.astype(float)
73+
im = pv.ImageData(
74+
origin=(xx[0], yy[0], zz[0]),
75+
spacing=(np.diff(xx[:2])[0], np.diff(yy[:2])[0], np.diff(zz[:2])[0]),
76+
dimensions=(len(xx), len(yy), len(zz)),
77+
)
78+
im[da.name or "data"] = da.values.ravel()
79+
return im
80+
81+
82+
def _test_mapper(mesh, mapper_name):
83+
"""Test whether a volume mapper works with a given mesh type."""
84+
try:
85+
pl = pv.Plotter(off_screen=True)
86+
pl.add_volume(mesh, mapper=mapper_name)
87+
pl.render()
88+
pl.close()
89+
except Exception:
90+
return "NOT SUPPORTED"
91+
return "OK"
92+
93+
94+
def print_table(rows, headers):
95+
"""Print a formatted table."""
96+
widths = [max(len(str(r[i])) for r in [headers, *rows]) for i in range(len(headers))]
97+
fmt = " ".join(f"{{:<{w}}}" for w in widths)
98+
print(fmt.format(*headers))
99+
print(fmt.format(*("-" * w for w in widths)))
100+
for row in rows:
101+
print(fmt.format(*row))
102+
103+
104+
def main():
105+
"""Run ImageData vs RectilinearGrid benchmark.
106+
107+
Demonstrates the performance benefits of using
108+
:class:`pyvista.ImageData` over :class:`pyvista.RectilinearGrid`
109+
when the coordinate axes have uniform spacing. The primary benefit
110+
is volume rendering performance.
111+
112+
Usage::
113+
114+
uv run python benchmarks/benchmark_image_data.py
115+
116+
The cells3d xarray tutorial dataset is used as a realistic 3D
117+
volume with uniform spacing on all axes.
118+
"""
119+
print("=" * 70)
120+
print("PyVista-xarray: ImageData vs RectilinearGrid Benchmark")
121+
print("=" * 70)
122+
print()
123+
print(f"PyVista {pv.__version__} | NumPy {np.__version__} | xarray {xr.__version__}")
124+
print()
125+
126+
# =====================================================================
127+
# Volume Rendering — the primary motivation for this optimization
128+
# =====================================================================
129+
print("=" * 70)
130+
print("VOLUME RENDERING (primary benefit)")
131+
print("=" * 70)
132+
print()
133+
134+
# --- cells3d ---
135+
ds = xr.tutorial.load_dataset("cells3d")
136+
da = ds.images.sel(c="nuclei")
137+
scalar_name = "images"
138+
clim = (0, 30000)
139+
140+
print("Dataset: cells3d nuclei channel")
141+
print(f" Shape: {da.shape} ({da.nbytes / 1024 / 1024:.1f} MB)")
142+
dx = np.diff(da.x.values[:2])[0]
143+
print(f" Uniform spacing: {dx:.4f} on all axes")
144+
print()
145+
146+
# Accessor auto-detection
147+
accessor_mesh, _ = benchmark_mesh_creation(da, "x", "y", "z", n_iter=3)
148+
print(f" Accessor auto-detects: {type(accessor_mesh).__name__}")
149+
print()
150+
151+
# Build both mesh types
152+
im_mesh = make_image_data(da, "x", "y", "z")
153+
rg_mesh = make_rectilinear(da, "x", "y", "z")
154+
155+
n_vol = 5
156+
im_vol = benchmark_volume_render(im_mesh, scalar_name, clim, n_iter=n_vol)
157+
rg_vol = benchmark_volume_render(rg_mesh, scalar_name, clim, n_iter=n_vol)
158+
159+
print(" Volume render (add_volume + render to image):")
160+
rows = [
161+
("ImageData", format_times(im_vol), ""),
162+
("RectilinearGrid", format_times(rg_vol), f"{rg_vol.mean() / im_vol.mean():.2f}x slower"),
163+
]
164+
print_table(rows, ("Mesh Type", "Time", ""))
165+
print()
166+
167+
# --- Synthetic grids at different sizes ---
168+
print("-" * 70)
169+
print("Volume rendering at increasing grid sizes")
170+
print("-" * 70)
171+
print()
172+
173+
vol_rows = []
174+
for n in [60, 100, 150, 200]:
175+
synth_data = np.random.randn(n, n, n).astype(np.float32)
176+
coords = np.linspace(0, 1, n)
177+
178+
im = pv.ImageData(dimensions=(n, n, n), spacing=(1.0 / n, 1.0 / n, 1.0 / n))
179+
im["density"] = synth_data.ravel()
180+
181+
rg = pv.RectilinearGrid(coords, coords, coords)
182+
rg["density"] = synth_data.ravel()
183+
184+
n_vol_synth = 3
185+
im_t = benchmark_volume_render(im, "density", (-2, 2), n_iter=n_vol_synth)
186+
rg_t = benchmark_volume_render(rg, "density", (-2, 2), n_iter=n_vol_synth)
187+
188+
ratio = rg_t.mean() / im_t.mean()
189+
pts = f"{n**3:,}"
190+
vol_rows.append(
191+
(
192+
f"{n}^3",
193+
pts,
194+
f"{im_t.mean() * 1000:.0f} ms",
195+
f"{rg_t.mean() * 1000:.0f} ms",
196+
f"{ratio:.2f}x",
197+
)
198+
)
199+
200+
print_table(vol_rows, ("Grid", "Points", "ImageData", "RectilinearGrid", "Ratio"))
201+
print()
202+
203+
# --- Mapper compatibility ---
204+
print("-" * 70)
205+
print("Mapper compatibility")
206+
print("-" * 70)
207+
print()
208+
209+
small_im = pv.ImageData(dimensions=(10, 10, 10))
210+
small_im["d"] = np.random.randn(small_im.n_points).astype(np.float32)
211+
small_rg = pv.RectilinearGrid(np.arange(10.0), np.arange(10.0), np.arange(10.0))
212+
small_rg["d"] = small_im["d"].copy()
213+
214+
mapper_rows = []
215+
for mapper_name in ["smart", "gpu", "fixed_point"]:
216+
for label, mesh in [("ImageData", small_im), ("RectilinearGrid", small_rg)]:
217+
status = _test_mapper(mesh, mapper_name)
218+
mapper_rows.append((mapper_name, label, status))
219+
220+
print_table(mapper_rows, ("Mapper", "Mesh Type", "Status"))
221+
print()
222+
print(" The 'fixed_point' mapper only supports ImageData.")
223+
print(" RectilinearGrid is limited to 'smart' and 'gpu' mappers.")
224+
print()
225+
226+
# =====================================================================
227+
# Other operations
228+
# =====================================================================
229+
print("=" * 70)
230+
print("OTHER OPERATIONS")
231+
print("=" * 70)
232+
print()
233+
234+
# Mesh creation
235+
print("Mesh creation (cells3d):")
236+
_, im_create = benchmark_mesh_creation(da, "x", "y", "z", n_iter=20)
237+
print(f" Accessor (auto ImageData): {format_times(im_create)}")
238+
print()
239+
240+
# Memory
241+
print("Memory usage:")
242+
mem_rows = []
243+
for label, m in [("cells3d", (im_mesh, rg_mesh))]:
244+
im_kb = m[0].actual_memory_size
245+
rg_kb = m[1].actual_memory_size
246+
mem_rows.append((label, f"{im_kb} kB", f"{rg_kb} kB", f"{rg_kb - im_kb} kB"))
247+
print_table(mem_rows, ("Dataset", "ImageData", "RectilinearGrid", "Overhead"))
248+
print()
249+
print(" Memory difference is small because data arrays dominate.")
250+
print(" The structural savings (no coordinate arrays) matter more")
251+
print(" for VTK's internal pipeline optimization.")
252+
print()
253+
254+
# Threshold filter
255+
print("Threshold filter (cells3d):")
256+
im_thresh = []
257+
rg_thresh = []
258+
for _ in range(5):
259+
vmin, vmax = im_mesh.get_data_range(scalar_name)
260+
mid = (vmin + vmax) / 2
261+
start = time.perf_counter()
262+
im_mesh.threshold(mid, scalars=scalar_name)
263+
im_thresh.append(time.perf_counter() - start)
264+
start = time.perf_counter()
265+
rg_mesh.threshold(mid, scalars=scalar_name)
266+
rg_thresh.append(time.perf_counter() - start)
267+
268+
im_thresh = np.array(im_thresh)
269+
rg_thresh = np.array(rg_thresh)
270+
print(f" ImageData: {format_times(im_thresh)}")
271+
print(f" RectilinearGrid: {format_times(rg_thresh)}")
272+
print()
273+
274+
# =====================================================================
275+
# Summary
276+
# =====================================================================
277+
print("=" * 70)
278+
print("SUMMARY")
279+
print("=" * 70)
280+
print()
281+
print("ImageData is automatically used when coordinate axes have uniform")
282+
print("spacing. Key benefits:")
283+
print()
284+
vol_speedup = rg_vol.mean() / im_vol.mean()
285+
print(f" 1. VOLUME RENDERING: {vol_speedup:.1f}x faster on cells3d")
286+
print(" VTK's volume mapper handles ImageData more efficiently.")
287+
print(" The 'fixed_point' mapper is exclusive to ImageData.")
288+
print()
289+
print(" 2. SEMANTIC CORRECTNESS:")
290+
print(" ImageData is the natural VTK type for uniform grids.")
291+
print(" Many VTK algorithms have optimized ImageData code paths.")
292+
print()
293+
print(" 3. The optimization is AUTOMATIC and TRANSPARENT:")
294+
print(" Users call .pyvista.mesh() as before. Uniform spacing is")
295+
print(" detected via np.allclose. Non-uniform grids still use")
296+
print(" RectilinearGrid.")
297+
print()
298+
299+
return 0
300+
301+
302+
if __name__ == "__main__":
303+
sys.exit(main())

0 commit comments

Comments
 (0)