From 22a5995ca2942551e4a119d1975388e67c380f2c Mon Sep 17 00:00:00 2001 From: spyke7 Date: Sat, 27 Dec 2025 13:35:32 +0530 Subject: [PATCH 1/6] added a simple program to export files in .vdb format --- AUTHORS | 1 + CHANGELOG | 13 +++ gridData/OpenVDB.py | 190 +++++++++++++++++++++++++++++++++++++ gridData/__init__.py | 3 +- gridData/core.py | 21 ++++ gridData/tests/test_vdb.py | 178 ++++++++++++++++++++++++++++++++++ 6 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 gridData/OpenVDB.py create mode 100644 gridData/tests/test_vdb.py diff --git a/AUTHORS b/AUTHORS index 26a9fbb..0b6dad0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -27,3 +27,4 @@ Contributors: * Zhiyi Wu * Olivier Languin-Cattoën * Andrés Montoya (logo) +* Shreejan Dolai diff --git a/CHANGELOG b/CHANGELOG index 7741a9e..df21398 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -13,6 +13,19 @@ The rules for this file: * accompany each entry with github issue/PR number (Issue #xyz) ------------------------------------------------------------------------------ +27/12/2025 IAlibay, spyke7, orbeckst + * 1.1.0 + + Changes + + * Added `OpenVDB.py` inside `gridData` to simply export and write in .vdb format + * Added `test_vdb.py` inside `gridData\tests` + + Fixes + + * Adding openVDB formats (Issue #141) + + ??/??/???? IAlibay, ollyfutur, conradolandia, orbeckst * 1.1.0 diff --git a/gridData/OpenVDB.py b/gridData/OpenVDB.py new file mode 100644 index 0000000..9afbb6b --- /dev/null +++ b/gridData/OpenVDB.py @@ -0,0 +1,190 @@ +r""" +:mod:`~gridData.OpenVDB` --- routines to write OpenVDB files +============================================================= + +The OpenVDB format is used by Blender and other VFX software for +volumetric data. See https://www.openvdb.org + +.. Note:: This module implements a simple writer for 3D regular grids, + sufficient to export density data for visualization in Blender. + +The OpenVDB format uses a sparse tree structure to efficiently store +volumetric data. It is the native format for Blender's volume system. + + +Writing OpenVDB files +--------------------- + +If you have a :class:`~gridData.core.Grid` object, you can write it to +OpenVDB format:: + + from gridData import Grid + g = Grid("data.dx") + g.export("data.vdb") + +This will create a file that can be imported directly into Blender +(File -> Import -> OpenVDB). + + +Building an OpenVDB field from a numpy array +--------------------------------------------- + +Requires: + +grid + numpy 3D array +origin + cartesian coordinates of the center of the (0,0,0) grid cell +delta + n x n array with the length of a grid cell along each axis + +Example:: + + import OpenVDB + vdb_field = OpenVDB.field('density') + vdb_field.populate(grid, origin, delta) + vdb_field.write('output.vdb') + + +Classes and functions +--------------------- + +""" + +import numpy +import warnings + +try: + import pyopenvdb as vdb +except ImportError: + vdb = None + + +class field(object): + """OpenVDB field object for writing volumetric data. + + This class provides a simple interface to write 3D grid data to + OpenVDB format, which can be imported into Blender and other + VFX software. + + The field object holds grid data and metadata, and can write it + to a .vdb file. + + Example + ------- + Create a field and write it:: + + vdb_field = OpenVDB.field('density') + vdb_field.populate(grid, origin, delta) + vdb_field.write('output.vdb') + + Or use directly from Grid:: + + g = Grid(...) + g.export('output.vdb', format='vdb') + + """ + + def __init__(self, name='density'): + """Initialize an OpenVDB field. + + Parameters + ---------- + name : str + Name of the grid (will be visible in Blender) + + """ + if vdb is None: + raise ImportError( + "pyopenvdb is required to write VDB files. " + ) + self.name = name + self.grid = None + self.origin = None + self.delta = None + + def populate(self, grid, origin, delta): + """Populate the field with grid data. + + Parameters + ---------- + grid : numpy.ndarray + 3D numpy array with the data + origin : numpy.ndarray + Coordinates of the center of grid cell [0,0,0] + delta : numpy.ndarray + Grid spacing (can be 1D array or diagonal matrix) + + Raises + ------ + ValueError + If grid is not 3D + + """ + grid = numpy.asarray(grid) + if grid.ndim != 3: + raise ValueError( + "OpenVDB only supports 3D grids, got {}D".format(grid.ndim)) + + self.grid = grid.astype(numpy.float32) # OpenVDB uses float32 + self.origin = numpy.asarray(origin) + + # Handle delta: could be 1D array or diagonal matrix + delta = numpy.asarray(delta) + if delta.ndim == 2: + # Extract diagonal if it's a matrix + self.delta = numpy.array([delta[i, i] for i in range(3)]) + else: + self.delta = delta + + def write(self, filename): + """Write the field to an OpenVDB file. + + Parameters + ---------- + filename : str + Output filename (should end in .vdb) + + """ + if self.grid is None: + raise ValueError("No data to write. Use populate() first.") + + # Create OpenVDB grid + vdb_grid = vdb.FloatGrid() + vdb_grid.name = self.name + + # Set up transform (voxel size and position) + # Check for uniform spacing + if not numpy.allclose(self.delta, self.delta[0]): + warnings.warn( + "Non-uniform grid spacing {}. Using average spacing.".format( + self.delta)) + voxel_size = float(numpy.mean(self.delta)) + else: + voxel_size = float(self.delta[0]) + + # Create linear transform with uniform voxel size + transform = vdb.createLinearTransform(voxelSize=voxel_size) + + # OpenVDB transform is at corner of voxel [0,0,0], + # but GridDataFormats origin is at center of voxel [0,0,0] + corner_origin = self.origin - 0.5 * self.delta + transform.translate(corner_origin) + vdb_grid.transform = transform + + # Set background value for sparse storage + vdb_grid.background = 0.0 + + # Populate the grid + + accessor = vdb_grid.getAccessor() + threshold = 1e-10 + + for i in range(self.grid.shape[0]): + for j in range(self.grid.shape[1]): + for k in range(self.grid.shape[2]): + value = float(self.grid[i, j, k]) + if abs(value) > threshold: + accessor.setValueOn((i, j, k), value) + + vdb.write(filename, grids=[vdb_grid]) \ No newline at end of file diff --git a/gridData/__init__.py b/gridData/__init__.py index bfe79a1..c5aaa0e 100644 --- a/gridData/__init__.py +++ b/gridData/__init__.py @@ -110,8 +110,9 @@ from . import OpenDX from . import gOpenMol from . import mrc +from . import OpenVDB -__all__ = ['Grid', 'OpenDX', 'gOpenMol', 'mrc'] +__all__ = ['Grid', 'OpenDX', 'gOpenMol', 'mrc', 'OpenVDB'] from importlib.metadata import version __version__ = version("GridDataFormats") diff --git a/gridData/core.py b/gridData/core.py index 3395d62..b120179 100644 --- a/gridData/core.py +++ b/gridData/core.py @@ -35,6 +35,7 @@ from . import OpenDX from . import gOpenMol from . import mrc +from . import OpenVDB def _grid(x): @@ -203,6 +204,7 @@ def __init__(self, grid=None, edges=None, origin=None, delta=None, 'PKL': self._export_python, 'PICKLE': self._export_python, # compatibility 'PYTHON': self._export_python, # compatibility + 'VDB': self._export_vdb, } self._loaders = { 'CCP4': self._load_mrc, @@ -676,7 +678,26 @@ def _export_dx(self, filename, type=None, typequote='"', **kwargs): if ext == '.gz': filename = root + ext dx.write(filename) + + def _export_vdb(self, filename, **kwargs): + """Export the density grid to an OpenVDB file. + The file format is compatible with Blender's volume system. + Only 3D grids are supported. + + For the file format see https://www.openvdb.org + """ + if self.grid.ndim != 3: + raise ValueError( + "OpenVDB export requires a 3D grid, got {}D".format(self.grid.ndim)) + + # Get grid name from metadata if available + grid_name = self.metadata.get('name', 'density') + + # Create and populate VDB field + vdb_field = OpenVDB.field(grid_name) + vdb_field.populate(self.grid, self.origin, self.delta) + vdb_field.write(filename) def save(self, filename): """Save a grid object to `filename` and add ".pickle" extension. diff --git a/gridData/tests/test_vdb.py b/gridData/tests/test_vdb.py new file mode 100644 index 0000000..dcd9abb --- /dev/null +++ b/gridData/tests/test_vdb.py @@ -0,0 +1,178 @@ +import numpy as np +from numpy.testing import (assert_array_equal, assert_array_almost_equal, + assert_almost_equal) + +import pytest + +from gridData import Grid + +def f_arithmetic(g): + return g + g - 2.5 * g / (g + 5.3) + +@pytest.fixture(scope="class") +def data(): + d = dict( + griddata=np.arange(1, 28).reshape(3, 3, 3), + origin=np.zeros(3), + delta=np.ones(3)) + d['grid'] = Grid(d['griddata'], origin=d['origin'], + delta=d['delta']) + return d + +class TestGrid(object): + @pytest.fixture + def pklfile(self, data, tmpdir): + g = data['grid'] + fn = tmpdir.mkdir('grid').join('grid.dat') + g.save(fn) # always saves as pkl + return fn + + def test_init(self, data): + g = Grid(data['griddata'], origin=data['origin'], + delta=1) + assert_array_equal(g.delta, data['delta']) + + def test_init_wrong_origin(self, data): + with pytest.raises(TypeError): + Grid(data['griddata'], origin=np.ones(4), delta=data['delta']) + + def test_init_wrong_delta(self, data): + with pytest.raises(TypeError): + Grid(data['griddata'], origin=data['origin'], delta=np.ones(4)) + + def test_init_missing_delta_ValueError(self, data): + with pytest.raises(ValueError): + Grid(data['griddata'], origin=data['origin']) + + def test_init_missing_origin_ValueError(self, data): + with pytest.raises(ValueError): + Grid(data['griddata'], delta=data['delta']) + + def test_init_wrong_data_exception(self): + with pytest.raises(IOError): + Grid("__does_not_exist__") + + def test_load_wrong_fileformat_ValueError(self): + with pytest.raises(ValueError): + Grid(grid=True, file_format="xxx") + + def test_equality(self, data): + assert data['grid'] == data['grid'] + assert data['grid'] != 'foo' + g = Grid(data['griddata'], origin=data['origin'] + 1, delta=data['delta']) + assert data['grid'] != g + + def test_compatibility_type(self, data): + assert data['grid'].check_compatible(data['grid']) + assert data['grid'].check_compatible(3) + g = Grid(data['griddata'], origin=data['origin'], delta=data['delta']) + assert data['grid'].check_compatible(g) + assert data['grid'].check_compatible(g.grid) + + def test_wrong_compatibile_type(self, data): + g = Grid(data['griddata'], origin=data['origin'] + 1, delta=data['delta']) + with pytest.raises(TypeError): + data['grid'].check_compatible(g) + + arr = np.zeros(data['griddata'].shape[-1] + 1) # Not broadcastable + with pytest.raises(TypeError): + data['grid'].check_compatible(arr) + + def test_non_orthonormal_boxes(self, data): + delta = np.eye(3) + with pytest.raises(NotImplementedError): + Grid(data['griddata'], origin=data['origin'], delta=delta) + + def test_centers(self, data): + g = Grid(data['griddata'], origin=np.ones(3), delta=data['delta']) + centers = np.array(list(g.centers())) + assert_array_equal(centers[0], g.origin) + assert_array_equal(centers[-1] - g.origin, + (np.array(g.grid.shape) - 1) * data['delta']) + + def test_resample_factor_failure(self, data): + pytest.importorskip('scipy') + + with pytest.raises(ValueError): + g = data['grid'].resample_factor(0) + + def test_resample_factor(self, data): + pytest.importorskip('scipy') + + g = data['grid'].resample_factor(2) + assert_array_equal(g.delta, np.ones(3) * .5) + + assert_array_equal(g.grid.shape, np.ones(3) * 5) + + assert_array_almost_equal(g.grid[::2, ::2, ::2], + data['grid'].grid) + + def test_load_pickle(self, data, tmpdir): + g = data['grid'] + fn = str(tmpdir.mkdir('grid').join('grid.pkl')) + g.save(fn) + + h = Grid() + h.load(fn) + + assert h == g + + def test_init_pickle_pathobjects(self, data, tmpdir): + g = data['grid'] + fn = tmpdir.mkdir('grid').join('grid.pickle') + g.save(fn) + + h = Grid(fn) + + assert h == g + + @pytest.mark.parametrize("fileformat", ("pkl", "PKL", "pickle", "python")) + def test_load_fileformat(self, data, pklfile, fileformat): + h = Grid(pklfile, file_format="pkl") + assert h == data['grid'] + + @pytest.mark.xfail + @pytest.mark.parametrize("fileformat", ("ccp4", "plt", "dx")) + def test_load_wrong_fileformat(self, data, pklfile, fileformat): + with pytest.raises('ValueError'): + Grid(pklfile, file_format=fileformat) + + @pytest.mark.parametrize("fileformat", ("dx", "pkl")) + def test_export(self, data, fileformat, tmpdir): + g = data['grid'] + fn = tmpdir.mkdir('grid_export').join("grid.{}".format(fileformat)) + g.export(fn) + h = Grid(fn) + assert g == h + + @pytest.mark.parametrize("fileformat", ("ccp4", "plt")) + def test_export_not_supported(self, data, fileformat, tmpdir): + g = data['grid'] + fn = tmpdir.mkdir('grid_export').join("grid.{}".format(fileformat)) + with pytest.raises(ValueError): + g.export(fn) + + +def test_inheritance(data): + class DerivedGrid(Grid): + pass + + dg = DerivedGrid(data['griddata'], origin=data['origin'], + delta=data['delta']) + result = f_arithmetic(dg) + + assert isinstance(result, DerivedGrid) + + ref = f_arithmetic(data['grid']) + assert_almost_equal(result.grid, ref.grid) + +def test_anyarray(data): + ma = np.ma.MaskedArray(data['griddata']) + mg = Grid(ma, origin=data['origin'], delta=data['delta']) + + assert isinstance(mg.grid, ma.__class__) + + result = f_arithmetic(mg) + ref = f_arithmetic(data['grid']) + + assert_almost_equal(result.grid, ref.grid) From d8091987310495418a96dbb16bc65ab8eee5f8f3 Mon Sep 17 00:00:00 2001 From: spyke7 Date: Sun, 18 Jan 2026 12:04:04 +0530 Subject: [PATCH 2/6] updated changelog and OpenVDB.py --- CHANGELOG | 17 +++-------------- gridData/OpenVDB.py | 21 ++++++++++++++------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index df21398..0307c1d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -13,24 +13,12 @@ The rules for this file: * accompany each entry with github issue/PR number (Issue #xyz) ------------------------------------------------------------------------------ -27/12/2025 IAlibay, spyke7, orbeckst - * 1.1.0 - - Changes - - * Added `OpenVDB.py` inside `gridData` to simply export and write in .vdb format - * Added `test_vdb.py` inside `gridData\tests` - - Fixes - - * Adding openVDB formats (Issue #141) - - -??/??/???? IAlibay, ollyfutur, conradolandia, orbeckst +??/??/???? IAlibay, ollyfutur, conradolandia, orbeckst, spyke7 * 1.1.0 Changes + * Added OpenVDB module for exporting to .vdb format (PR #148) * update logo from https://github.com/MDAnalysis/mdanalysis-subprojects-branding (issue #143) * Python 3.13 and 3.14 are now supported (PR #140) @@ -38,6 +26,7 @@ The rules for this file: Enhancements + * Added openVDB format exports (Issue #141) * `Grid` now accepts binary operations with any operand that can be broadcasted to the grid's shape according to `numpy` broadcasting rules (PR #142) diff --git a/gridData/OpenVDB.py b/gridData/OpenVDB.py index 9afbb6b..2cd97d5 100644 --- a/gridData/OpenVDB.py +++ b/gridData/OpenVDB.py @@ -5,6 +5,8 @@ The OpenVDB format is used by Blender and other VFX software for volumetric data. See https://www.openvdb.org +pyopenvdb: https://github.com/AcademySoftwareFoundation/openvdb + .. Note:: This module implements a simple writer for 3D regular grids, sufficient to export density data for visualization in Blender. @@ -57,7 +59,10 @@ try: import pyopenvdb as vdb except ImportError: - vdb = None + try: + import openvdb as vdb + except ImportError: + vdb = None class field(object): @@ -97,6 +102,7 @@ def __init__(self, name='density'): if vdb is None: raise ImportError( "pyopenvdb is required to write VDB files. " + "Install it with: conda install -c conda-forge openvdb" ) self.name = name self.grid = None @@ -180,11 +186,12 @@ def write(self, filename): accessor = vdb_grid.getAccessor() threshold = 1e-10 - for i in range(self.grid.shape[0]): - for j in range(self.grid.shape[1]): - for k in range(self.grid.shape[2]): - value = float(self.grid[i, j, k]) - if abs(value) > threshold: - accessor.setValueOn((i, j, k), value) + mask = numpy.abs(slef.grid) > threshold + indices = numpy.argwhere(mask) + + for idx in indices: + i, j, k = idx + value = float(self.grid[i, j, k]) + accessor.setValueOn((i, j, k), value) vdb.write(filename, grids=[vdb_grid]) \ No newline at end of file From 79c96de2423e1553d28f2cf162261626f3adec69 Mon Sep 17 00:00:00 2001 From: spyke7 Date: Sun, 18 Jan 2026 12:16:01 +0530 Subject: [PATCH 3/6] fixed core.py errors --- gridData/core.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/gridData/core.py b/gridData/core.py index 8c2ecc3..f92db91 100644 --- a/gridData/core.py +++ b/gridData/core.py @@ -704,6 +704,23 @@ def _export_dx(self, filename, type=None, typequote='"', **kwargs): def _export_vdb(self, filename, **kwargs): """Export the density grid to an OpenVDB file. + + The file format is compatible with Blender's volume system. + Only 3D grids are supported. + + For the file format see https://www.openvdb.org + """ + if self.grid.ndim != 3: + raise ValueError( + "OpenVDB export requires a 3D grid, got {}D".format(self.grid.ndim)) + + # Get grid name from metadata if available + grid_name = self.metadata.get('name', 'density') + + # Create and populate VDB field + vdb_field = OpenVDB.field(grid_name) + vdb_field.populate(self.grid, self.origin, self.delta) + vdb_field.write(filename) def _export_mrc(self, filename, **kwargs): """Export the density grid to an MRC/CCP4 file. @@ -740,22 +757,6 @@ def _export_mrc(self, filename, **kwargs): # Write to file mrc_file.write(filename) - The file format is compatible with Blender's volume system. - Only 3D grids are supported. - - For the file format see https://www.openvdb.org - """ - if self.grid.ndim != 3: - raise ValueError( - "OpenVDB export requires a 3D grid, got {}D".format(self.grid.ndim)) - - # Get grid name from metadata if available - grid_name = self.metadata.get('name', 'density') - - # Create and populate VDB field - vdb_field = OpenVDB.field(grid_name) - vdb_field.populate(self.grid, self.origin, self.delta) - vdb_field.write(filename) def save(self, filename): """Save a grid object to `filename` and add ".pickle" extension. From 0649210b2ec1acf35cdbfc449475dd412427753c Mon Sep 17 00:00:00 2001 From: spyke7 Date: Sun, 18 Jan 2026 13:19:24 +0530 Subject: [PATCH 4/6] fixed typo in OpenVDB.py --- gridData/OpenVDB.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gridData/OpenVDB.py b/gridData/OpenVDB.py index 2cd97d5..3ab119e 100644 --- a/gridData/OpenVDB.py +++ b/gridData/OpenVDB.py @@ -186,7 +186,7 @@ def write(self, filename): accessor = vdb_grid.getAccessor() threshold = 1e-10 - mask = numpy.abs(slef.grid) > threshold + mask = numpy.abs(self.grid) > threshold indices = numpy.argwhere(mask) for idx in indices: From f83f7090c6c9ba409360a891a04b3ce844e8cb00 Mon Sep 17 00:00:00 2001 From: spyke7 Date: Mon, 19 Jan 2026 18:42:34 +0530 Subject: [PATCH 5/6] updated OpenVDB.py --- gridData/OpenVDB.py | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/gridData/OpenVDB.py b/gridData/OpenVDB.py index 3ab119e..c865b07 100644 --- a/gridData/OpenVDB.py +++ b/gridData/OpenVDB.py @@ -132,13 +132,12 @@ def populate(self, grid, origin, delta): raise ValueError( "OpenVDB only supports 3D grids, got {}D".format(grid.ndim)) - self.grid = grid.astype(numpy.float32) # OpenVDB uses float32 + self.grid = numpy.transpose(grid, (2, 1, 0)).astype(numpy.float32) self.origin = numpy.asarray(origin) # Handle delta: could be 1D array or diagonal matrix delta = numpy.asarray(delta) if delta.ndim == 2: - # Extract diagonal if it's a matrix self.delta = numpy.array([delta[i, i] for i in range(3)]) else: self.delta = delta @@ -159,30 +158,24 @@ def write(self, filename): vdb_grid = vdb.FloatGrid() vdb_grid.name = self.name - # Set up transform (voxel size and position) - # Check for uniform spacing - if not numpy.allclose(self.delta, self.delta[0]): - warnings.warn( - "Non-uniform grid spacing {}. Using average spacing.".format( - self.delta)) - voxel_size = float(numpy.mean(self.delta)) - else: - voxel_size = float(self.delta[0]) + # this is an explicit linear transform using per-axis voxel sizes + # world = diag(delta) * index + corner_origin + corner_origin = (self.origin - 0.5 * self.delta).astype(float) - # Create linear transform with uniform voxel size - transform = vdb.createLinearTransform(voxelSize=voxel_size) + # Constructing 4x4 row-major matrix where the last row is the translation + matrix = [ + [float(self.delta[0]), 0.0, 0.0, 0.0], + [0.0, float(self.delta[1]), 0.0, 0.0], + [0.0, 0.0, float(self.delta[2]), 0.0], + [float(corner_origin[0]), float(corner_origin[1]), float(corner_origin[2]), 1.0] + ] - # OpenVDB transform is at corner of voxel [0,0,0], - # but GridDataFormats origin is at center of voxel [0,0,0] - corner_origin = self.origin - 0.5 * self.delta - transform.translate(corner_origin) + transform = vdb.createLinearTransform(matrix) vdb_grid.transform = transform - # Set background value for sparse storage vdb_grid.background = 0.0 # Populate the grid - accessor = vdb_grid.getAccessor() threshold = 1e-10 From 09b88a3489667a6a3ea073692ef235125806c0ef Mon Sep 17 00:00:00 2001 From: spyke7 Date: Tue, 20 Jan 2026 00:56:54 +0530 Subject: [PATCH 6/6] removed transpose inside populate() --- gridData/OpenVDB.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gridData/OpenVDB.py b/gridData/OpenVDB.py index c865b07..55e8fa5 100644 --- a/gridData/OpenVDB.py +++ b/gridData/OpenVDB.py @@ -132,7 +132,7 @@ def populate(self, grid, origin, delta): raise ValueError( "OpenVDB only supports 3D grids, got {}D".format(grid.ndim)) - self.grid = numpy.transpose(grid, (2, 1, 0)).astype(numpy.float32) + self.grid = grid.astype(numpy.float32) self.origin = numpy.asarray(origin) # Handle delta: could be 1D array or diagonal matrix