diff --git a/.github/workflows/gh-ci.yaml b/.github/workflows/gh-ci.yaml index e464321..35fdd24 100644 --- a/.github/workflows/gh-ci.yaml +++ b/.github/workflows/gh-ci.yaml @@ -49,7 +49,7 @@ jobs: - name: install package deps run: | - micromamba install numpy scipy mrcfile pytest pytest-cov codecov + micromamba install numpy scipy mrcfile pytest pytest-cov codecov openvdb - name: check install run: | diff --git a/AUTHORS b/AUTHORS index 78d26f7..922488c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -29,3 +29,4 @@ Contributors: * Andrés Montoya (logo) * Rich Waldo * Pradyumn Prasad +* Shreejan Dolai diff --git a/CHANGELOG b/CHANGELOG index b3fb690..dad3361 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -12,12 +12,20 @@ The rules for this file: use tabs but use spaces for formatting * accompany each entry with github issue/PR number (Issue #xyz) +------------------------------------------------------------------------------ +01/16/2026 IAlibay, ollyfutur, conradolandia, orbeckst, PlethoraChutney, + Pradyumn-cloud, spyke7 ------------------------------------------------------------------------------- -??/??/???? orbeckst +??/??/???? orbeckst, spyke7 - * 1.1.1 + * 1.2.0 - Fixes + Enhancements + + * Added openVDB format exports (Issue #141, PR #148) + + + Fixes 01/22/2026 IAlibay, ollyfutur, conradolandia, orbeckst, PlethoraChutney, @@ -35,6 +43,7 @@ The rules for this file: Enhancements + * `Grid` now accepts binary operations with any operand that can be * `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/doc/source/gridData/formats.rst b/doc/source/gridData/formats.rst index 0680ac8..a44a15d 100644 --- a/doc/source/gridData/formats.rst +++ b/doc/source/gridData/formats.rst @@ -29,6 +29,7 @@ small number of file formats is directly supported. :mod:`~gridData.gOpenMol` gOpenMol_ plt x :mod:`~gridData.mrc` CCP4_ ccp4,mrc x x subset implemented :class:`~gridData.core.Grid` pickle pickle x x standard Python pickle of the Grid class + :mod:`~gridData.OpenVDB` OpenVDB_ vdb x implemented for Blender visualization ============================ ========== ========= ===== ===== ========================================= @@ -39,6 +40,7 @@ small number of file formats is directly supported. .. _OpenDX: http://www.opendx.org/ .. _gOpenMol: http://www.csc.fi/gopenmol/ .. _CCP4: http://www.ccpem.ac.uk/mrc_format/mrc2014.php +.. _OpenVDB: https://www.openvdb.org/ Format-specific modules @@ -50,3 +52,4 @@ Format-specific modules formats/OpenDX formats/gOpenMol formats/mrc + formats/OpenVDB diff --git a/doc/source/gridData/formats/OpenVDB.rst b/doc/source/gridData/formats/OpenVDB.rst new file mode 100644 index 0000000..4f457ca --- /dev/null +++ b/doc/source/gridData/formats/OpenVDB.rst @@ -0,0 +1,2 @@ +.. automodule:: gridData.OpenVDb + diff --git a/gridData/OpenVDB.py b/gridData/OpenVDB.py new file mode 100644 index 0000000..163b269 --- /dev/null +++ b/gridData/OpenVDB.py @@ -0,0 +1,219 @@ +r""" +:mod:`~gridData.OpenVDB` --- routines to write OpenVDB files +============================================================= + +The `OpenVDB format`_ is used by Blender_ and other VFX software for +volumetric data. + +.. _`OpenVDB format`: https://www.openvdb.org +.. _Blender: https://www.blender.org/ + +This module uses the openvdb_ library to write OpenVDB files. + +.. _openvdb: 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_. + See the `Blender volume docs`_ for details on importing VDB files. + +.. _`Blender volume docs`: https://docs.blender.org/manual/en/latest/modeling/volumes/introduction.html + +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) or (shift+A -> Volume -> Import OpenVDB). See `importing VDB in Blender`_ for details. + +.. _`importing VDB in Blender`: https://docs.blender.org/manual/en/latest/modeling/geometry_nodes/input/import/vdb.html + + +Building an OpenVDB field from a numpy array +--------------------------------------------- + +If you want to create VDB files without using the Grid class, +you can directly use the OpenVDB field API. This is useful +for custom workflows or when integrating with other libraries. + +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 + +try: + import openvdb as vdb + +except ImportError: + vdb = None + + +class OpenVDBField(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, grid, origin, delta, name='density', tolerance=1e-10): + """Initialize an OpenVDB field. + + 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) + name : str + Name of the grid (will be visible in Blender), default 'density' + threshold : float + Values below this threshold are treated as background (sparse), + default 1e-10 + + Raises + ------ + ImportError + If openvdb is not installed + ValueError + If grid is not 3D, or if delta is not 1D/2D or describes + non-orthorhombic cell + + """ + if vdb is None: + raise ImportError( + "openvdb is required to write VDB files. " + "Install it with: conda install -c conda-forge openvdb" + ) + self.name = name + self.tolerance = tolerance + self._populate(grid, origin, delta) + + 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, or if delta is not 1D/2D or describes + non-orthorhombic cell + + """ + grid = numpy.asarray(grid) + if grid.ndim != 3: + raise ValueError( + f"OpenVDB only supports 3D grids, got {grid.ndim}D") + + self.grid = grid.astype(numpy.float32) + self.grid=numpy.ascontiguousarray(self.grid, dtype=numpy.float32) + + self.origin = numpy.asarray(origin) + + # Handle delta: could be 1D array or diagonal matrix + delta = numpy.asarray(delta) + if delta.ndim == 2: + if (delta.shape != (3,3)): + raise ValueError("delta as a matrix must be 3x3") + + if not numpy.allclose(delta, numpy.diag(numpy.diag(delta))): + raise ValueError("Non-orthorhombic cells are not supported") + + self.delta = numpy.diag(delta) + + elif delta.ndim == 1: + if (len(delta) != 3): + raise ValueError("delta must have length-3 for 3D grids") + self.delta=delta + + else: + raise ValueError( + "delta must be either a length-3 vector or a 3x3 diagonal matrix" + ) + + def write(self, filename): + """Write the field to an OpenVDB file. + + Parameters + ---------- + filename : str + Output filename (should end in .vdb) + + """ + + vdb_grid = vdb.FloatGrid() + vdb_grid.name = self.name + + # 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) + + matrix = [ + [self.delta[0], 0.0, 0.0, 0.0], + [0.0, self.delta[1], 0.0, 0.0], + [0.0, 0.0, self.delta[2], 0.0], + [corner_origin[0], corner_origin[1], corner_origin[2], 1.0] + ] + + vdb_grid.background = 0.0 + vdb_grid.transform = vdb.createLinearTransform(matrix) + + vdb_grid.copyFromArray(self.grid, tolerance=self.tolerance) + vdb_grid.prune() + + 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 035d769..23516c6 100644 --- a/gridData/core.py +++ b/gridData/core.py @@ -45,6 +45,7 @@ from . import OpenDX from . import gOpenMol from . import mrc +from . import OpenVDB def _grid(x): @@ -222,6 +223,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, 'MRC': self._export_mrc, } self._loaders = { @@ -699,6 +701,28 @@ 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( + f"OpenVDB export requires a 3D grid, got {self.grid.ndim}D") + + grid_name = self.metadata.get('name', 'density') + + vdb_field = OpenVDB.OpenVDBField( + grid=self.grid, + origin=self.origin, + delta=self.delta, + name=grid_name + ) + vdb_field.write(filename) def _export_mrc(self, filename, **kwargs): """Export the density grid to an MRC/CCP4 file. diff --git a/gridData/tests/test_vdb.py b/gridData/tests/test_vdb.py new file mode 100644 index 0000000..d925cb2 --- /dev/null +++ b/gridData/tests/test_vdb.py @@ -0,0 +1,246 @@ +import numpy as np +from numpy.testing import assert_allclose + +import pytest +from unittest.mock import patch + +import gridData.OpenVDB +from gridData import Grid + +from . import datafiles + +try: + import openvdb as vdb + HAS_OPENVDB = True +except ImportError: + HAS_OPENVDB = False + +@pytest.fixture +def grid345(): + data = np.arange(1, 61, dtype=np.float32).reshape((3, 4, 5)) + g = Grid(data.copy(), origin=np.zeros(3), delta=np.ones(3)) + return data, g + +class TestVDBWrite: + def test_write_vdb_from_grid(self, tmpdir, grid345): + data,g = grid345 + + outfile = str(tmpdir / "test.vdb") + g.export(outfile, file_format='VDB') + + assert tmpdir.join("test.vdb").exists() + + grids, metadata = vdb.readAll(outfile) + assert len(grids) == 1 + assert grids[0].name == 'density' + + grid_vdb = grids[0] + acc = grid_vdb.getAccessor() + + assert grid_vdb.evalActiveVoxelDim() == data.shape + + corners = [ + (0, 0, 0), + (data.shape[0] - 1, data.shape[1] - 1, data.shape[2] - 1), + (1, 2, 3) + ] + for (i, j, k) in corners: + got = acc.getValue((i, j, k)) + assert got == pytest.approx(float(data[i, j, k])) + + def test_write_vdb_default_grid_name(self, tmpdir): + data = np.ones((3, 3, 3), dtype=np.float32) + g = Grid(data, origin=[0, 0, 0], delta=[1, 1, 1]) + g.metadata = {} + + outfile = str(tmpdir / "default_name.vdb") + g.export(outfile) + grids, metadata = vdb.readAll(outfile) + assert grids[0].name == 'density' + + def test_write_vdb_autodetect_extension(self, tmpdir, grid345): + data, g = grid345 + + outfile = str(tmpdir / "auto.vdb") + g.export(outfile) + + assert tmpdir.join("auto.vdb").exists() + + def test_write_vdb_with_metadata(self, tmpdir): + data = np.ones((3, 3, 3), dtype=np.float32) + g = Grid(data, origin=[0, 0, 0], delta=[1, 1, 1]) + g.metadata['name'] = 'test_density' + + outfile = str(tmpdir / "metadata.vdb") + g.export(outfile) + + grids, metadata = vdb.readAll(outfile) + assert grids[0].name == 'test_density' + + def test_write_vdb_origin_and_spacing(self, tmpdir): + data = np.ones((4, 4, 4), dtype=np.float32) + origin = np.array([10.0, 20.0, 30.0]) + delta = np.array([0.5, 0.5, 0.5]) + + g = Grid(data, origin=origin, delta=delta) + outfile = str(tmpdir / "transform.vdb") + g.export(outfile) + + grids, metadata = vdb.readAll(outfile) + grid_vdb = grids[0] + + voxel_size = grid_vdb.transform.voxelSize() + + spacing=[voxel_size[0], voxel_size[1], voxel_size[2]] + assert_allclose(spacing, delta, rtol=1e-5) + + def test_write_vdb_from_ccp4(self, tmpdir): + g = Grid(datafiles.CCP4) + outfile = str(tmpdir / "from_ccp4.vdb") + + g.export(outfile, file_format='VDB') + + assert tmpdir.join("from_ccp4.vdb").exists() + grids, metadata = vdb.readAll(outfile) + assert len(grids) == 1 + + def test_vdb_field_direct(self, tmpdir): + data = np.arange(27).reshape(3, 3, 3).astype(np.float32) + + vdb_field = gridData.OpenVDB.OpenVDBField(data, origin=[0,0,0], delta=[1,1,1], name='direct_test') + + outfile = str(tmpdir / "direct.vdb") + vdb_field.write(outfile) + + grids, metadata = vdb.readAll(outfile) + assert grids[0].name == 'direct_test' + + def test_vdb_field_2d_raises(self): + data_2d = np.arange(12).reshape(3, 4) + + with pytest.raises(ValueError, match="3D grids"): + gridData.OpenVDB.OpenVDBField( + data_2d, + origin=[0, 0], + delta=[1, 1] + ) + + def test_write_vdb_nonuniform_spacing(self, tmpdir): + data = np.ones((3, 3, 3), dtype=np.float32) + delta = np.array([0.5, 1.0, 1.5]) + g = Grid(data, origin=[0, 0, 0], delta=delta) + + outfile = str(tmpdir / "nonuniform.vdb") + g.export(outfile) + assert tmpdir.join("nonuniform.vdb").exists() + + grids, metadata = vdb.readAll(outfile) + grid_vdb = grids[0] + voxel_size = grid_vdb.transform.voxelSize() + + spacing = [voxel_size[0], voxel_size[1], voxel_size[2]] + + assert_allclose(spacing, delta, rtol=1e-5) + + def test_write_vdb_with_delta_matrix(self, tmpdir): + data = np.ones((3, 3, 3), dtype=np.float32) + delta = np.diag([1.0, 2.0, 3.0]) + + vdb_field = gridData.OpenVDB.OpenVDBField(data, origin=[0,0,0], delta=delta, name='matrix_delta') + + outfile = str(tmpdir / "matrix_delta.vdb") + vdb_field.write(outfile) + assert tmpdir.join("matrix_delta.vdb").exists() + + grids, metadata = vdb.readAll(outfile) + grid_vdb = grids[0] + voxel_size = grid_vdb.transform.voxelSize() + + spacing = [voxel_size[0], voxel_size[1], voxel_size[2]] + + assert_allclose(spacing, [1.0, 2.0, 3.0], rtol=1e-5) + + def test_write_vdb_sparse_data(self, tmpdir): + data = np.zeros((10, 10, 10), dtype=np.float32) + data[2, 3, 4] = 5.0 + data[7, 8, 9] = 10.0 + + g = Grid(data, origin=[0, 0, 0], delta=[1, 1, 1]) + outfile = str(tmpdir / "sparse.vdb") + g.export(outfile) + + assert tmpdir.join("sparse.vdb").exists() + grids, metadata = vdb.readAll(outfile) + assert len(grids) == 1 + + grid_vdb = grids[0] + acc = grid_vdb.getAccessor() + assert acc.getValue((2, 3, 4)) == pytest.approx(5.0) + assert acc.getValue((7, 8, 9)) == pytest.approx(10.0) + + def test_write_vdb_zero_threshold(self, tmpdir): + data = np.ones((3, 3, 3), dtype=np.float32) * 1e-11 + data[1, 1, 1] = 1.0 + + g = Grid(data, origin=[0, 0, 0], delta=[1, 1, 1]) + outfile = str(tmpdir / "threshold.vdb") + g.export(outfile) + assert tmpdir.join("threshold.vdb").exists() + + grids, metadata = vdb.readAll(outfile) + grid_vdb = grids[0] + acc = grid_vdb.getAccessor() + + assert acc.getValue((1, 1, 1)) == pytest.approx(1.0) + + val, is_active = grid_vdb.getConstAccessor().probeValue((0, 0, 0)) + + assert (not is_active ) or (val == pytest.approx(0.0)) + + def test_vdb_non_orthrhombic_raises(self): + data=np.ones((3,3,3), dtype=np.float32) + delta = np.array([ + [1,0.1,0], + [0,1,0], + [0,0,1], + ]) + + with pytest.raises(ValueError, match="Non-orthorhombic"): + gridData.OpenVDB.OpenVDBField( + data, + origin=[0, 0, 0], + delta=delta + ) + + def test_delta_matrix_wrong_shape_raises(self): + data = np.ones((3, 3, 3), dtype=np.float32) + origin = [0.0, 0.0, 0.0] + bad_delta = np.eye(2) + + with pytest.raises(ValueError, match="delta as a matrix must be 3x3"): + gridData.OpenVDB.OpenVDBField(data, origin, bad_delta) + + def test_delta_scalar_raises(self): + data = np.ones((3, 3, 3), dtype=np.float32) + origin = [0.0, 0.0, 0.0] + bad_delta = np.array(1.0) + + with pytest.raises(ValueError, match="delta must be either a length-3 vector or a 3x3 diagonal matrix"): + gridData.OpenVDB.OpenVDBField(data, origin, bad_delta) + + def test_delta_1d_wrong_length_raises(self): + data = np.ones((3, 3, 3), dtype=np.float32) + origin = [0.0, 0.0, 0.0] + bad_delta = np.array([1.0, 2.0]) + + with pytest.raises(ValueError, match="must have length-3"): + gridData.OpenVDB.OpenVDBField(data, origin, bad_delta) + +def test_vdb_import_error(): + with patch('gridData.OpenVDB.vdb', None): + with pytest.raises(ImportError, match="openvdb is required"): + gridData.OpenVDB.OpenVDBField( + np.ones((3, 3, 3)), + origin=[0, 0, 0], + delta=[1, 1, 1] + ) \ No newline at end of file