|
| 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