├── doc ├── __init__.py ├── normals.gif ├── nerf │ ├── density.png │ ├── dual03.png │ ├── midp02.png │ ├── naive01.png │ ├── neus200.png │ └── naive25600.png ├── surfacenets.png ├── vertex_strategies_rot_box.gif ├── vertex_strategies_aligned_box.gif ├── doc_plots.py ├── SDF.md ├── frames.svg └── edges.svg ├── tests ├── __init__.py ├── test_normals.py ├── test_roots.py ├── test_sdfs.py └── test_grid.py ├── examples ├── __init__.py ├── benchmark.py ├── show_normals.py ├── hello_dualiso.py ├── generate_lod.py ├── boolean_sdfs.py ├── compare.py ├── debug_cube.py └── nerf2mesh.py ├── sdftoolbox ├── __version__.py ├── types.py ├── utils.py ├── __init__.py ├── mesh.py ├── maths.py ├── io.py ├── roots.py ├── dual_isosurfaces.py ├── plotting.py ├── grid.py ├── dual_strategies.py └── sdfs.py ├── .flake8 ├── requirements ├── dev-requirements.txt └── requirements.txt ├── LICENSE ├── setup.py ├── .github └── workflows │ └── python-package.yml ├── .gitignore └── README.md /doc/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sdftoolbox/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.0" 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 -------------------------------------------------------------------------------- /requirements/dev-requirements.txt: -------------------------------------------------------------------------------- 1 | black 2 | flake8 3 | pytest 4 | scikit-image -------------------------------------------------------------------------------- /doc/normals.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheind/sdftoolbox/HEAD/doc/normals.gif -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | numpy >= 1.22.4 2 | matplotlib >= 3.5 3 | imageio >= 2.19 -------------------------------------------------------------------------------- /doc/nerf/density.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheind/sdftoolbox/HEAD/doc/nerf/density.png -------------------------------------------------------------------------------- /doc/nerf/dual03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheind/sdftoolbox/HEAD/doc/nerf/dual03.png -------------------------------------------------------------------------------- /doc/nerf/midp02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheind/sdftoolbox/HEAD/doc/nerf/midp02.png -------------------------------------------------------------------------------- /doc/nerf/naive01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheind/sdftoolbox/HEAD/doc/nerf/naive01.png -------------------------------------------------------------------------------- /doc/nerf/neus200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheind/sdftoolbox/HEAD/doc/nerf/neus200.png -------------------------------------------------------------------------------- /doc/surfacenets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheind/sdftoolbox/HEAD/doc/surfacenets.png -------------------------------------------------------------------------------- /doc/nerf/naive25600.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheind/sdftoolbox/HEAD/doc/nerf/naive25600.png -------------------------------------------------------------------------------- /doc/vertex_strategies_rot_box.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheind/sdftoolbox/HEAD/doc/vertex_strategies_rot_box.gif -------------------------------------------------------------------------------- /doc/vertex_strategies_aligned_box.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheind/sdftoolbox/HEAD/doc/vertex_strategies_aligned_box.gif -------------------------------------------------------------------------------- /sdftoolbox/types.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from contextlib import contextmanager 3 | 4 | float_dtype = np.float_ 5 | 6 | 7 | @contextmanager 8 | def default_dtype(new_dtype: np.dtype): 9 | global float_dtype 10 | old_type = float_dtype 11 | try: 12 | float_dtype = new_dtype 13 | yield 14 | finally: 15 | float_dtype = old_type 16 | -------------------------------------------------------------------------------- /sdftoolbox/utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def reorient_volume(x: np.ndarray): 5 | """Reorients the given volume for easier inspection. 6 | 7 | In particular the volume is reoriented, so that when printing the volume 8 | you see ij-slices (k from from front to back) and each ij-slice has the origin 9 | at lower left. 10 | """ 11 | return np.flip(x.transpose((2, 1, 0)), 1) 12 | -------------------------------------------------------------------------------- /sdftoolbox/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .dual_isosurfaces import dual_isosurface 3 | from .dual_strategies import ( 4 | MidpointVertexStrategy, 5 | NaiveSurfaceNetVertexStrategy, 6 | DualContouringVertexStrategy, 7 | LinearEdgeStrategy, 8 | NewtonEdgeStrategy, 9 | BisectionEdgeStrategy, 10 | ) 11 | from .grid import Grid 12 | from . import sdfs 13 | from . import plotting 14 | from . import mesh 15 | from . import io 16 | from . import maths 17 | -------------------------------------------------------------------------------- /examples/benchmark.py: -------------------------------------------------------------------------------- 1 | """Benchmarking different methods""" 2 | import logging 3 | import numpy as np 4 | 5 | import sdftoolbox as sdftoolbox 6 | 7 | 8 | def main(): 9 | logging.basicConfig(level=logging.DEBUG) 10 | 11 | scene = sdftoolbox.sdfs.Union( 12 | [ 13 | sdftoolbox.sdfs.Sphere.create(center=(0, 0, 0), radius=0.5), 14 | sdftoolbox.sdfs.Sphere.create(center=(0, 0, 0.6), radius=0.3), 15 | sdftoolbox.sdfs.Sphere.create(center=(0, 0, 1.0), radius=0.2), 16 | ], 17 | alpha=8, 18 | ) 19 | 20 | xyz, spacing = sdftoolbox.sdfs.Discretized.sampling_coords(res=(100, 100, 100)) 21 | sdfv = scene.sample(xyz).astype(np.float32) 22 | verts, faces = sdftoolbox.surface_nets( 23 | sdfv, 24 | spacing=spacing, 25 | vertex_placement_mode="naive", 26 | triangulate=False, 27 | ) 28 | verts += xyz[0, 0, 0] 29 | 30 | 31 | if __name__ == "__main__": 32 | main() 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Christoph Heindl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from pathlib import Path 3 | 4 | THISDIR = Path(__file__).parent 5 | 6 | 7 | def read_requirements(fname): 8 | with open(THISDIR / "requirements" / fname, "r") as f: 9 | return f.read().splitlines() 10 | 11 | 12 | core_required = read_requirements("requirements.txt") 13 | dev_required = read_requirements("dev-requirements.txt") + core_required 14 | 15 | main_ns = {} 16 | with open(THISDIR / "sdftoolbox" / "__version__.py") as ver_file: 17 | exec(ver_file.read(), main_ns) 18 | 19 | setup( 20 | name="sdftoolbox", 21 | version=main_ns["__version__"], 22 | description=( 23 | "Vectorized Python methods for creating, manipulating and tessellating signed" 24 | " distance fields." 25 | ), 26 | author="Christoph Heindl", 27 | url="https://github.com/cheind/sdf-surfacenets", 28 | license="MIT", 29 | install_requires=core_required, 30 | packages=find_packages(".", include="sdftoolbox*"), 31 | include_package_data=True, 32 | keywords="sdf isoextraction dual contouring", 33 | extras_require={ 34 | "dev": dev_required, 35 | }, 36 | ) 37 | -------------------------------------------------------------------------------- /tests/test_normals.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import sdftoolbox 3 | 4 | 5 | def test_plane_normals(): 6 | def gen_normals(n: np.ndarray) -> tuple[np.ndarray, np.ndarray]: 7 | scene = sdftoolbox.sdfs.Plane.create((0.01, 0.01, 0.01), normal=n) 8 | grid = sdftoolbox.Grid(res=(3, 3, 3)) 9 | 10 | # Extract the surface using quadliterals 11 | verts, faces = sdftoolbox.dual_isosurface( 12 | scene, 13 | grid, 14 | vertex_strategy=sdftoolbox.NaiveSurfaceNetVertexStrategy(), 15 | triangulate=False, 16 | ) 17 | face_normals = sdftoolbox.mesh.compute_face_normals(verts, faces) 18 | vert_normals = sdftoolbox.mesh.compute_vertex_normals( 19 | verts, faces, face_normals 20 | ) 21 | return face_normals, vert_normals 22 | 23 | for n in np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]): 24 | fn, vn = gen_normals(n=n) 25 | assert np.allclose(fn, [n], atol=1e-5) 26 | assert np.allclose(vn, [n], atol=1e-5) 27 | 28 | fn, vn = gen_normals(n=-n) 29 | assert np.allclose(fn, [-n], atol=1e-5) 30 | assert np.allclose(vn, [-n], atol=1e-5) 31 | -------------------------------------------------------------------------------- /examples/show_normals.py: -------------------------------------------------------------------------------- 1 | """Compute and render surface normals""" 2 | 3 | import matplotlib.pyplot as plt 4 | import sdftoolbox 5 | 6 | 7 | def main(): 8 | 9 | # Setup the scene 10 | scene = sdftoolbox.sdfs.Box((1.1, 1.1, 1.1)).transform(rot=(1, 1, 1, 0.75)) 11 | grid = sdftoolbox.Grid((3, 3, 3)) 12 | 13 | # Generate mesh 14 | verts, faces = sdftoolbox.dual_isosurface( 15 | scene, 16 | grid, 17 | edge_strategy=sdftoolbox.NewtonEdgeStrategy(), 18 | vertex_strategy=sdftoolbox.DualContouringVertexStrategy(), 19 | triangulate=False, 20 | vertex_relaxation_percent=0.25, 21 | ) 22 | 23 | # Compute normals 24 | face_normals = sdftoolbox.mesh.compute_face_normals(verts, faces) 25 | vert_normals = scene.gradient(verts, normalize=True) 26 | # Alternatively via averaging face normals 27 | # vert_normals = sn.mesh.compute_vertex_normals(verts, faces, face_normals) 28 | 29 | # Plot mesh+normals 30 | fig, ax = sdftoolbox.plotting.create_mesh_figure( 31 | verts, faces, face_normals, vert_normals 32 | ) 33 | sdftoolbox.plotting.plot_samples(ax, grid.xyz, scene.sample(grid.xyz)) 34 | sdftoolbox.plotting.setup_axes(ax, grid.min_corner, grid.max_corner) 35 | # sn.plotting.generate_rotation_gif("normals.gif", fig, ax) 36 | plt.show() 37 | 38 | 39 | if __name__ == "__main__": 40 | main() 41 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.9", "3.10"] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install flake8 pytest 31 | pip install -r requirements/requirements.txt 32 | pip install -r requirements/dev-requirements.txt 33 | - name: Lint with flake8 34 | run: | 35 | # stop the build if there are Python syntax errors or undefined names 36 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 37 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 38 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 39 | - name: Test with pytest 40 | run: | 41 | pytest 42 | -------------------------------------------------------------------------------- /examples/hello_dualiso.py: -------------------------------------------------------------------------------- 1 | """Introductory example to surfacenet usage""" 2 | 3 | 4 | def main(): 5 | 6 | # Main import 7 | import sdftoolbox 8 | 9 | # Setup a snowman-scene 10 | snowman = sdftoolbox.sdfs.Union( 11 | [ 12 | sdftoolbox.sdfs.Sphere.create(center=(0, 0, 0), radius=0.4), 13 | sdftoolbox.sdfs.Sphere.create(center=(0, 0, 0.45), radius=0.3), 14 | sdftoolbox.sdfs.Sphere.create(center=(0, 0, 0.8), radius=0.2), 15 | ], 16 | ) 17 | family = sdftoolbox.sdfs.Union( 18 | [ 19 | snowman.transform(trans=(-0.75, 0.0, 0.0)), 20 | snowman.transform(trans=(0.0, -0.3, 0.0), scale=0.8), 21 | snowman.transform(trans=(0.75, 0.0, 0.0), scale=0.6), 22 | ] 23 | ) 24 | scene = sdftoolbox.sdfs.Difference( 25 | [ 26 | family, 27 | sdftoolbox.sdfs.Plane().transform(trans=(0, 0, -0.2)), 28 | ] 29 | ) 30 | 31 | # Generate the sampling locations. Here we use the default params 32 | grid = sdftoolbox.Grid( 33 | res=(65, 65, 65), 34 | min_corner=(-1.5, -1.5, -1.5), 35 | max_corner=(1.5, 1.5, 1.5), 36 | ) 37 | 38 | # Extract the surface using dual contouring 39 | verts, faces = sdftoolbox.dual_isosurface( 40 | scene, 41 | grid, 42 | vertex_strategy=sdftoolbox.NaiveSurfaceNetVertexStrategy(), 43 | triangulate=False, 44 | ) 45 | 46 | # Export 47 | sdftoolbox.io.export_stl("surfacenets.stl", verts, faces) 48 | 49 | # Visualize 50 | import matplotlib.pyplot as plt 51 | 52 | # plt.style.use("dark_background") 53 | fig, ax = sdftoolbox.plotting.create_mesh_figure(verts, faces) 54 | plt.show() 55 | 56 | 57 | if __name__ == "__main__": 58 | main() 59 | -------------------------------------------------------------------------------- /examples/generate_lod.py: -------------------------------------------------------------------------------- 1 | """Successively increasing the level of detail""" 2 | 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | 6 | import sdftoolbox 7 | 8 | 9 | def main(): 10 | 11 | # Setup the scene 12 | scene = sdftoolbox.sdfs.Sphere.create(center=(0, 0, 0), radius=1.0) 13 | 14 | fig, ax = sdftoolbox.plotting.create_figure(fig_aspect=9 / 16, proj_type="persp") 15 | 16 | max_corner = np.array([-np.inf, -np.inf, -np.inf]) 17 | min_corner = np.array([np.inf, np.inf, np.inf]) 18 | 19 | # Note, the resolution is chosen such that stepping in powers of 2 20 | # always contains the endpoint. This is important, since the sampling 21 | # bounds are close the surface of the sphere. 22 | grid = sdftoolbox.Grid( 23 | res=(65, 65, 65), 24 | min_corner=(-1.1, -1.1, -1.1), 25 | max_corner=(1.1, 1.1, 1.1), 26 | ) 27 | 28 | for idx in range(1, 6): 29 | step = 2**idx 30 | verts, faces = sdftoolbox.dual_isosurface( 31 | scene, 32 | grid.subsample(step), 33 | triangulate=False, 34 | ) 35 | # The lower the resolution the higher the chance of violating the 36 | # linearity assumptions when determining edge intersections. Here we 37 | # improve by reprojecting vertices onto the SDF. This also counterfights 38 | # shrinkage induced by vertex placement strategies. 39 | verts = sdftoolbox.mesh.project_vertices(scene, verts) 40 | verts += (idx * 3, 0, 0) 41 | max_corner = np.maximum(verts.max(0), max_corner) 42 | min_corner = np.minimum(verts.min(0), min_corner) 43 | sdftoolbox.plotting.plot_mesh(ax, verts, faces) 44 | sdftoolbox.plotting.setup_axes(ax, min_corner, max_corner, num_grid=0) 45 | 46 | plt.show() 47 | 48 | 49 | if __name__ == "__main__": 50 | main() 51 | -------------------------------------------------------------------------------- /examples/boolean_sdfs.py: -------------------------------------------------------------------------------- 1 | """Compute and render surface normals""" 2 | 3 | import matplotlib.pyplot as plt 4 | import sdftoolbox 5 | import numpy as np 6 | 7 | 8 | def extract(scene: sdftoolbox.sdfs.SDF, grid: sdftoolbox.Grid): 9 | 10 | verts, faces = sdftoolbox.dual_isosurface( 11 | scene, 12 | grid, 13 | vertex_strategy=sdftoolbox.DualContouringVertexStrategy(), 14 | edge_strategy=sdftoolbox.NewtonEdgeStrategy(), 15 | triangulate=False, 16 | ) 17 | return verts, faces 18 | 19 | 20 | def main(): 21 | 22 | fig, ax = sdftoolbox.plotting.create_figure(fig_aspect=9 / 16, proj_type="persp") 23 | max_corner = np.array([-np.inf, -np.inf, -np.inf]) 24 | min_corner = np.array([np.inf, np.inf, np.inf]) 25 | 26 | box = sdftoolbox.sdfs.Box.create((1, 2, 0.5)) 27 | sphere = sdftoolbox.sdfs.Sphere.create(radius=0.4) 28 | grid = sdftoolbox.Grid( 29 | res=(40, 40, 40), min_corner=(-1.2, -1.2, -1.2), max_corner=(1.2, 1.2, 1.2) 30 | ) 31 | 32 | # Union 33 | scene = box.merge(sphere, alpha=np.inf) 34 | verts, faces = extract(scene, grid) 35 | sdftoolbox.plotting.plot_mesh(ax, verts, faces) 36 | max_corner = np.maximum(verts.max(0), max_corner) 37 | min_corner = np.minimum(verts.min(0), min_corner) 38 | 39 | # Intersection 40 | scene = box.intersect(sphere, alpha=np.inf) 41 | verts, faces = extract(scene, grid) 42 | verts += (1.5, 0.0, 0.0) 43 | sdftoolbox.plotting.plot_mesh(ax, verts, faces) 44 | max_corner = np.maximum(verts.max(0), max_corner) 45 | min_corner = np.minimum(verts.min(0), min_corner) 46 | 47 | # Difference 48 | scene = box.subtract(sphere, alpha=np.inf) 49 | verts, faces = extract(scene, grid) 50 | verts += (3.0, 0.0, 0.0) 51 | sdftoolbox.plotting.plot_mesh(ax, verts, faces) 52 | max_corner = np.maximum(verts.max(0), max_corner) 53 | min_corner = np.minimum(verts.min(0), min_corner) 54 | 55 | sdftoolbox.plotting.setup_axes(ax, min_corner, max_corner) 56 | plt.show() 57 | 58 | 59 | if __name__ == "__main__": 60 | main() 61 | -------------------------------------------------------------------------------- /examples/compare.py: -------------------------------------------------------------------------------- 1 | """Compares surface nets to other methods. 2 | 3 | This code compares the result of surface nets to marching cubes. 4 | We use the MC implementation from scikit-image, which is required 5 | to run this example. 6 | """ 7 | 8 | import numpy as np 9 | import time 10 | import matplotlib.pyplot as plt 11 | from skimage.measure import marching_cubes 12 | 13 | import sdftoolbox 14 | 15 | 16 | def main(): 17 | 18 | scene = sdftoolbox.sdfs.Sphere.create([0, 0, 0], 1.0) 19 | scene = sdftoolbox.sdfs.Displacement( 20 | scene, lambda xyz: 0.15 * np.sin(10 * xyz).prod(-1) 21 | ) 22 | 23 | # Define the sampling locations. 24 | grid = sdftoolbox.Grid( 25 | res=(48, 48, 48), min_corner=(-1.5, -1.5, -1.5), max_corner=(1.5, 1.5, 1.5) 26 | ) 27 | 28 | # Evaluate the SDF 29 | t0 = time.perf_counter() 30 | sdfv = scene.sample(grid.xyz) 31 | print(f"SDF sampling took {time.perf_counter() - t0:.3f} secs") 32 | 33 | t0 = time.perf_counter() 34 | verts_sn, faces_sn = sdftoolbox.dual_isosurface( 35 | scene, 36 | grid, 37 | triangulate=False, 38 | ) 39 | print( 40 | f"SurfaceNets took {time.perf_counter() - t0:.3f} secs", "#faces", len(faces_sn) 41 | ) 42 | 43 | t0 = time.perf_counter() 44 | verts_mc, faces_mc, _, _ = marching_cubes( 45 | sdfv, 46 | 0.0, 47 | spacing=grid.spacing, 48 | ) 49 | verts_mc += grid.min_corner 50 | print( 51 | f"MarchingCubes took {time.perf_counter() - t0:.3f} secs", 52 | "#faces", 53 | len(faces_mc), 54 | ) 55 | 56 | # plt.style.use("dark_background") 57 | minc = verts_mc.min(0) 58 | maxc = verts_mc.max(0) 59 | fig, (ax0, ax1) = sdftoolbox.plotting.create_split_figure(sync=True) 60 | sdftoolbox.plotting.plot_mesh(ax0, verts_sn, faces_sn) 61 | sdftoolbox.plotting.plot_mesh(ax1, verts_mc, faces_mc) 62 | sdftoolbox.plotting.setup_axes(ax0, minc, maxc) 63 | sdftoolbox.plotting.setup_axes(ax1, minc, maxc) 64 | ax0.set_title("SurfaceNets") 65 | ax1.set_title("Marching Cubes") 66 | # plt.tight_layout() 67 | plt.show() 68 | 69 | 70 | if __name__ == "__main__": 71 | main() 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | /surfacenets.stl 132 | /normals.gif -------------------------------------------------------------------------------- /examples/debug_cube.py: -------------------------------------------------------------------------------- 1 | """Compute and render surface normals""" 2 | 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | import sdftoolbox 6 | 7 | 8 | def get_rotated_box(rot): 9 | scene = sdftoolbox.sdfs.Box((1.1, 1.1, 1.1)) 10 | grid = sdftoolbox.Grid((3, 3, 3)) 11 | 12 | # Generate mesh 13 | verts, faces = sdftoolbox.dual_isosurface( 14 | scene, 15 | grid, 16 | vertex_strategy=sdftoolbox.DualContouringVertexStrategy(), 17 | edge_strategy=sdftoolbox.BisectionEdgeStrategy(), 18 | triangulate=False, 19 | ) 20 | 21 | t = sdftoolbox.maths.rotate(rot[:3], rot[3]) 22 | 23 | verts = sdftoolbox.maths.dehom(sdftoolbox.maths.hom(verts) @ t.T) 24 | return verts, faces 25 | 26 | 27 | def main(): 28 | 29 | # Setup the scene 30 | rot = (1.0, 1.0, 1.0, np.pi / 4) 31 | scene = sdftoolbox.sdfs.Box((1.1, 1.1, 1.1)).transform(rot=rot) 32 | grid = sdftoolbox.Grid((3, 3, 3)) 33 | 34 | # Generate mesh 35 | verts, faces, debug = sdftoolbox.dual_isosurface( 36 | scene, 37 | grid, 38 | edge_strategy=sdftoolbox.BisectionEdgeStrategy(), 39 | vertex_strategy=sdftoolbox.DualContouringVertexStrategy(), 40 | triangulate=False, 41 | vertex_relaxation_percent=0.25, 42 | return_debug_info=True, 43 | ) 44 | 45 | # Plot mesh+normals 46 | fig, ax = sdftoolbox.plotting.create_mesh_figure( 47 | verts, faces 48 | ) # face_normals, vert_normals) 49 | 50 | v, f = get_rotated_box(rot) 51 | sdftoolbox.plotting.plot_mesh(ax, v, f, alpha=0.1, color="gray") 52 | sdftoolbox.plotting.plot_normals( 53 | ax, v, scene.gradient(v, normalize=False, h=1e-12), color="r" 54 | ) # Strange normals, even for rotated box?? 55 | print(scene.sample(v)) 56 | 57 | isect = grid.grid_to_data(debug.edges_isect_coords[debug.edges_active_mask]) 58 | isect_n = scene.gradient(isect, normalize=True) 59 | 60 | active_src, active_dst = grid.find_edge_vertices( 61 | np.where(debug.edges_active_mask)[0], ravel=False 62 | ) 63 | active_src = grid.grid_to_data(active_src) 64 | active_dst = grid.grid_to_data(active_dst) 65 | sdftoolbox.plotting.plot_edges( 66 | ax, active_src, active_dst, color="yellow", linewidth=0.5 67 | ) 68 | sdftoolbox.plotting.plot_normals(ax, isect, isect_n, color="yellow") 69 | 70 | sdftoolbox.plotting.plot_samples(ax, grid.xyz, scene.sample(grid.xyz)) 71 | sdftoolbox.plotting.setup_axes(ax, grid.min_corner, grid.max_corner) 72 | # sn.plotting.generate_rotation_gif("normals.gif", fig, ax) 73 | plt.show() 74 | 75 | 76 | if __name__ == "__main__": 77 | main() 78 | -------------------------------------------------------------------------------- /sdftoolbox/mesh.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import numpy as np 4 | 5 | from .roots import directional_newton_roots 6 | 7 | if TYPE_CHECKING: 8 | from .sdfs import SDF 9 | 10 | 11 | def compute_face_normals(verts: np.ndarray, faces: np.ndarray) -> np.ndarray: 12 | """Computes face normals for the given mesh. 13 | 14 | This assumes that faces are ordered ccw when viewed from the face normal. 15 | Note that vertices of quads are not guaranteed to be coplanar and hence normals 16 | depend on which vertices are chosen. This implementation uses always the the first, 17 | the second and the last vertex to estimate a normal. 18 | 19 | Params: 20 | verts: (N,3) array of vertices 21 | faces: (M,F) array of faces with F=3 for triangles and F=4 for quads 22 | 23 | Returns: 24 | normals: (M,3) array of face normals 25 | """ 26 | xyz = verts[faces] # (N,F,3) 27 | normals = np.cross(xyz[:, 1] - xyz[:, 0], xyz[:, -1] - xyz[:, 0], axis=-1) # (N,3) 28 | normals /= np.linalg.norm(normals, axis=-1, keepdims=True) 29 | return normals 30 | 31 | 32 | def compute_vertex_normals( 33 | verts: np.ndarray, faces: np.ndarray, face_normals: np.ndarray 34 | ) -> np.ndarray: 35 | """Computes vertex normals for the given mesh. 36 | 37 | Each vertex normal is the average of the adjacent face normals. 38 | 39 | Params: 40 | verts: (N,3) array of vertices 41 | faces: (M,F) array of faces with F=3 for triangles and F=4 for quads 42 | face_normals: (M,3) array of face normals 43 | 44 | Returns: 45 | normals: (N,3) array of vertex normals 46 | """ 47 | # Repeat face normal for each face vertex 48 | vertex_normals = np.zeros_like(verts) 49 | vertex_counts = np.zeros((verts.shape[0]), dtype=int) 50 | 51 | for f, fn in zip(faces, face_normals): 52 | vertex_normals[f] += fn 53 | vertex_counts[f] += 1 54 | 55 | vertex_normals /= vertex_counts.reshape(-1, 1) 56 | return vertex_normals 57 | 58 | 59 | def triangulate_quads(quads: np.ndarray) -> np.ndarray: 60 | """Triangulates a quadliteral mesh. 61 | 62 | Assumes CCW winding order. 63 | 64 | Params: 65 | quads: (M,4) array of quadliterals 66 | 67 | Returns: 68 | tris: (M*2,3) array of triangles 69 | """ 70 | tris = np.empty((quads.shape[0], 2, 3), dtype=quads.dtype) 71 | tris[:, 0, :] = quads[:, [0, 1, 2]] 72 | tris[:, 1, :] = quads[:, [0, 2, 3]] 73 | return tris.reshape(-1, 3) 74 | 75 | 76 | def project_vertices(node: "SDF", verts: np.ndarray, **newton_kwargs): 77 | """Projects vertices onto the surface.""" 78 | return directional_newton_roots(node, verts, **newton_kwargs) 79 | -------------------------------------------------------------------------------- /tests/test_roots.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from sdftoolbox.sdfs import Box, Sphere 3 | from sdftoolbox.roots import directional_newton_roots, bisect_roots 4 | 5 | 6 | def test_newton_root_finding(): 7 | np.random.seed(123) 8 | s = Sphere() 9 | x0 = np.random.uniform(-2, 2, size=(20, 3)) 10 | x = directional_newton_roots(s, x0, dirs=None) 11 | 12 | # Result should be on sphere 13 | np.allclose(np.linalg.norm(x, axis=-1), 1.0) 14 | 15 | # The way the points moved should coincide with the initial 16 | # gradient direction 17 | n0 = s.gradient(x0, normalize=True) 18 | d = (x - x0) / np.linalg.norm(x - x0, axis=-1, keepdims=True) 19 | np.allclose(np.abs(n0[:, None, :] @ d[..., None]), 0) 20 | 21 | # Another test with fixed directions 22 | x0 = np.array([[0.1, 0.0, 0.0], [0.0, 0.1, 0.0], [0.0, 0.0, 1.1]]) 23 | d = np.array([[1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0]]) 24 | x = directional_newton_roots(s, x0, dirs=d) 25 | np.allclose(x, np.eye(3)) 26 | 27 | 28 | def test_newton_single_dir(): 29 | np.random.seed(123) 30 | s = Sphere() 31 | x0 = np.random.randn(100, 3) * 1e-2 32 | x = directional_newton_roots(s, x0, dirs=np.array([1.0, 0.0, 0.0])) 33 | # Starting from close to center they should fly off to ~ -1/1 in x. 34 | assert np.allclose(np.linalg.norm(x, axis=-1), 1.0) 35 | assert np.allclose( 36 | np.abs(x[:, None, :] @ np.array([[1.0, 0.0, 0.0]])[..., None]).squeeze(-1), 37 | 1.0, 38 | atol=1e-2, 39 | ) 40 | 41 | 42 | def test_bisect_root_finding(): 43 | np.random.seed(123) 44 | s = Sphere() 45 | dirs = np.random.uniform(-2, 2, size=(20, 3)) 46 | dirs /= np.linalg.norm(dirs, axis=-1, keepdims=True) 47 | 48 | a = np.zeros_like(dirs) 49 | b = dirs * 2 50 | x0 = b * np.random.uniform(0, 1, size=(len(dirs), 1)) 51 | 52 | # Midpoint bisection 53 | x = bisect_roots(s, a, b, x0, max_steps=50) 54 | assert np.allclose(s.sample(x), 0, 1e-5) 55 | 56 | # Less iterations should fail 57 | x = bisect_roots(s, a, b, x0, max_steps=1) 58 | assert not np.allclose(s.sample(x), 0, 1e-5) 59 | 60 | # Linear interpolation shoudl speed up though (at least for sphere) 61 | x = bisect_roots(s, a, b, x0, max_steps=1, linear_interp=True) 62 | assert np.allclose(s.sample(x), 0, 1e-5) 63 | 64 | # Same thing with a tricky box SDF 65 | s = Box() 66 | a = np.array([[0.48, 0.6, 0.0]]) 67 | b = np.array([[0.48, -0.3, 0.0]]) 68 | assert np.allclose(s.sample(a), 0.1) 69 | assert np.allclose(s.sample(b), -0.02) 70 | 71 | # Standard bisect converges faster as linear interp. is not misleading 72 | x = bisect_roots(s, a, b, max_steps=12) 73 | assert np.allclose(x, [[0.48, 0.5, 0.0]], 1e-3) 74 | 75 | # Linear interp. converges slower 76 | x = bisect_roots(s, a, b, linear_interp=True, max_steps=12) 77 | assert not np.allclose(x, [[0.48, 0.5, 0.0]], 1e-3) 78 | assert np.allclose(x, [[0.48, 0.5, 0.0]], 1e-1) 79 | -------------------------------------------------------------------------------- /sdftoolbox/maths.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from typing import TypeVar, Any 3 | 4 | from .types import float_dtype 5 | 6 | ShapeLike = TypeVar("ShapeLike", bound=Any) 7 | 8 | 9 | def generalized_max(x: np.ndarray, axis: ShapeLike = None, alpha: float = np.inf): 10 | """Generalized maximum function. 11 | 12 | As `alpha` goes to infinity this function transforms from a smooth 13 | maximum to standard maximum. This function is frequently used by 14 | boolean CSG operations to avoid hard transitions. 15 | 16 | Based on the wikipedia article 17 | https://en.wikipedia.org/wiki/Smooth_maximum 18 | 19 | Params 20 | x: The input values to take the maximum over 21 | axis: Optional axis to perform operation on 22 | alpha: Defines the smoothness of the maximum approximation. Lower values 23 | give smoother results. 24 | 25 | Returns 26 | y: (smooth) maximum along axis. 27 | """ 28 | if np.isfinite(alpha): 29 | xmax = np.max(x, axis=axis, keepdims=True) 30 | ex = np.exp(alpha * (x - xmax)) 31 | smax = np.sum(x * ex, axis=axis) / np.sum(ex, axis=axis) 32 | return smax 33 | else: 34 | return np.max(x, axis=axis) 35 | 36 | 37 | def hom(v, value=1): 38 | """Returns v as homogeneous vectors.""" 39 | v = np.asanyarray(v, dtype=float_dtype) 40 | return np.insert(v, v.shape[-1], value, axis=-1) 41 | 42 | 43 | def dehom(a): 44 | """Makes homogeneous vectors inhomogenious.""" 45 | a = np.asfarray(a, dtype=float_dtype) 46 | return a[..., :-1] / a[..., None, -1] 47 | 48 | 49 | def translate(values: np.ndarray) -> np.ndarray: 50 | """Construct and return a translation matrix""" 51 | values = np.asfarray(values, dtype=float_dtype) 52 | m = np.eye(4, dtype=values.dtype) 53 | m[:3, 3] = values 54 | return m 55 | 56 | 57 | def scale(values: np.ndarray) -> np.ndarray: 58 | """Construct and return a scaling matrix""" 59 | values = np.asfarray(values, dtype=float_dtype) 60 | m = np.eye(4, dtype=values.dtype) 61 | m[[0, 1, 2], [0, 1, 2]] = values 62 | return m 63 | 64 | 65 | def _skew(a): 66 | return np.array( 67 | [[0, -a[2], a[1]], [a[2], 0, -a[0]], [-a[1], a[0], 0]], dtype=a.dtype 68 | ) 69 | 70 | 71 | def rotate(axis: np.ndarray, angle: float) -> np.ndarray: 72 | """Construct a rotation matrix given axis/angle pair.""" 73 | # See 74 | # https://en.wikipedia.org/wiki/Rotation_matrix#Rotation_matrix_from_axis_and_angle 75 | axis = np.asfarray(axis, dtype=float_dtype) 76 | sina = np.sin(angle) 77 | cosa = np.cos(angle) 78 | d = axis / np.linalg.norm(axis) 79 | R = ( 80 | cosa * np.eye(3, dtype=axis.dtype) 81 | + sina * _skew(d) 82 | + (1 - cosa) * np.outer(d, d) 83 | ) 84 | m = np.eye(4, dtype=axis.dtype) 85 | m[:3, :3] = R 86 | return m 87 | 88 | 89 | if __name__ == "__main__": 90 | import matplotlib.pyplot as plt 91 | 92 | x = np.linspace(-1, 1, 1000) 93 | xnx = np.stack((x, -x), -1) 94 | 95 | plt.plot(x, generalized_max(xnx, -1, 0.5), label=r"$\alpha$=0.5") 96 | plt.plot(x, generalized_max(xnx, -1, 1), label=r"$\alpha$=1.0") 97 | plt.plot(x, generalized_max(xnx, -1, 2), label=r"$\alpha$=2.0") 98 | plt.plot(x, generalized_max(xnx, -1, 4), label=r"$\alpha$=4.0") 99 | plt.plot(x, generalized_max(xnx, -1, 8), label=r"$\alpha$=4.0") 100 | plt.plot(x, generalized_max(xnx, -1), label=r"$\alpha$=$\inf$", color="k") 101 | plt.legend( 102 | bbox_to_anchor=(0, 1.02, 1, 0.2), 103 | loc="lower left", 104 | mode="expand", 105 | borderaxespad=0, 106 | ncol=3, 107 | ) 108 | plt.tight_layout() 109 | plt.show() 110 | -------------------------------------------------------------------------------- /tests/test_sdfs.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import sdftoolbox 3 | import pytest 4 | 5 | 6 | def test_anisotropic_scaling_not_supported(): 7 | scene = sdftoolbox.sdfs.Sphere.create(radius=2) 8 | 9 | with pytest.raises(ValueError): 10 | scene.t_world_local = np.diag([1.0, 2.0, 3.0]) 11 | 12 | 13 | def test_sphere(): 14 | scene = sdftoolbox.sdfs.Sphere.create(radius=2) 15 | 16 | sdfv = scene.sample(np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]])) 17 | assert np.allclose(sdfv, [-2.0, -1.0, 0.0]) 18 | 19 | 20 | def test_plane(): 21 | scene = sdftoolbox.sdfs.Plane.create(origin=(1.0, 1.0, 1.0), normal=(1.0, 0.0, 0.0)) 22 | 23 | sdfv = scene.sample(np.array([[0.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]])) 24 | assert np.allclose(sdfv, [-1.0, -2.0, 0.0], atol=1e-5) 25 | 26 | 27 | def test_box(): 28 | scene = sdftoolbox.sdfs.Box((1, 1, 1)) 29 | 30 | sdfv = scene.sample( 31 | np.array( 32 | [ 33 | [0.0, 0.0, 0.0], 34 | [-0.5, 0.0, 0.0], 35 | [1.0, 0.0, 0.0], 36 | [-0.5, -0.5, -0.5], 37 | [0.5, 0.5, 0.5], 38 | [1.0, 1.0, 1.0], 39 | ] 40 | ) 41 | ) 42 | assert np.allclose(sdfv, [-0.5, 0.0, 0.5, 0.0, 0.0, 0.8660254], atol=1e-3) 43 | 44 | scene = sdftoolbox.sdfs.Box((1.1, 1.1, 1.1)) 45 | sdfv = scene.sample( 46 | np.array( 47 | [ 48 | [-1.0, 0.0, 0.0], 49 | [-0.333333333333, 0.0, 0.0], 50 | ] 51 | ) 52 | ) 53 | assert np.allclose(sdfv, [0.45, -0.216666667], atol=1e-3) 54 | 55 | scene = sdftoolbox.sdfs.Box((1.1, 1.1, 1.1)) 56 | scene = sdftoolbox.sdfs.Transform( 57 | scene, sdftoolbox.maths.rotate([1, 0, 0], np.pi / 4) 58 | ) 59 | sdfv = scene.sample( 60 | np.array( 61 | [ 62 | [-1.0, 0.0, 0.0], 63 | [-0.333333333333, 0.0, 0.0], 64 | ] 65 | ) 66 | ) 67 | assert np.allclose(sdfv, [0.45, -0.216666667], atol=1e-3) 68 | 69 | 70 | def test_root(): 71 | scene = sdftoolbox.sdfs.Box((1.1, 1.1, 1.1)) 72 | scene = sdftoolbox.sdfs.Transform( 73 | scene, sdftoolbox.maths.rotate([1, 0, 0], np.pi / 4) 74 | ) 75 | xyz = np.array( 76 | [ 77 | [-1.0, -0.333333333333, -0.333333333333], 78 | [-0.333333333333, -0.333333333333, -0.333333333333], 79 | ] 80 | ) 81 | sdfv = scene.sample(xyz) 82 | assert np.allclose(sdfv, [0.45, -0.07859548], atol=1e-3) 83 | 84 | x0 = xyz[1] + (-0.07859548, 0.0, 0.0) 85 | print(scene.sample([x0])) 86 | print(scene.gradient(x0.reshape(1, 3))) 87 | 88 | 89 | def test_gradients(): 90 | scene = sdftoolbox.sdfs.Sphere.create(radius=1) 91 | 92 | def analytic(x): 93 | return x / np.linalg.norm(x, axis=-1, keepdims=True) 94 | 95 | xyz = np.array( 96 | [ 97 | [1.0, 0.0, 0.0], 98 | [0.0, 1.0, 0.0], 99 | [0.0, 0.0, 1.0], 100 | [1.0, 1.0, 1.0], 101 | ] 102 | ) 103 | g = analytic(xyz) 104 | 105 | g_central = scene.gradient(xyz, mode="central") 106 | assert np.allclose(g_central, g, atol=1e-3) 107 | 108 | 109 | def test_discretized(): 110 | scene = sdftoolbox.sdfs.Sphere.create(radius=1) 111 | grid = sdftoolbox.Grid(res=(20, 20, 20)) 112 | sdf_scene = scene.sample(grid.xyz) 113 | disc = sdftoolbox.sdfs.Discretized(grid, sdf_scene) 114 | sdf_disc = disc.sample(grid.xyz) 115 | assert np.allclose(sdf_scene, sdf_disc) 116 | 117 | # Shift coords test 118 | scene = sdftoolbox.sdfs.Plane.create() 119 | grid = sdftoolbox.Grid(res=(3, 3, 3), min_corner=(-1, -1, -1), max_corner=(1, 1, 1)) 120 | sdf_scene = scene.sample(grid.xyz) 121 | disc = sdftoolbox.sdfs.Discretized(grid, sdf_scene) 122 | 123 | from sdftoolbox.utils import reorient_volume 124 | 125 | sdf_disc = reorient_volume(disc.sample(grid.xyz + (0.0, 0.0, 0.5))) 126 | assert np.allclose(sdf_disc[0], -0.5) 127 | assert np.allclose(sdf_disc[1], 0.5) 128 | assert np.allclose(sdf_disc[2], 1.0, atol=1e-5) # out of 129 | -------------------------------------------------------------------------------- /examples/nerf2mesh.py: -------------------------------------------------------------------------------- 1 | """Convert stored SDF volumes to meshes. 2 | 3 | Loads a 'density image' as an SDF volume and triangulates 4 | it using dual contouring. 5 | 6 | Density images are generated by tools like 7 | 8 | - instant-ngp https://github.com/NVlabs/instant-ngp 9 | - NeuS/S2 https://github.com/19reborn/NeuS2/ 10 | 11 | by sampling the learnt SDF network at grid locations. The 12 | resulting SDF values mapped to flat image space, converted 13 | to intensity values and then saved as an ordinary image. 14 | 15 | This tool reverses the above process to reconstruct a 3D 16 | grid of SDF values and then applies the contouring algorithm 17 | to generate the final mesh. 18 | """ 19 | 20 | import logging 21 | import argparse 22 | 23 | import sdftoolbox 24 | 25 | _logger = logging.getLogger("sdftoolbox") 26 | 27 | 28 | def main(): 29 | logging.basicConfig(level=logging.INFO) 30 | 31 | parser = argparse.ArgumentParser() 32 | parser.add_argument( 33 | "-r", 34 | "--resolution", 35 | type=int, 36 | help="grid resolution in each direction", 37 | required=True, 38 | ) 39 | parser.add_argument( 40 | "-s", 41 | "--spacing", 42 | type=float, 43 | help=( 44 | "grid spacing between voxels in each direction. Affects the scaling of the" 45 | " resulting mesh." 46 | ), 47 | ) 48 | parser.add_argument( 49 | "-t", 50 | "--sdf-truncation", 51 | type=float, 52 | help="+/- sdf range encoded in pixel intensity", 53 | default=4.0, 54 | ) 55 | parser.add_argument( 56 | "-o", 57 | "--offset", 58 | type=float, 59 | help="grid offset in each direction. Affects the origin of the resulting mesh.", 60 | default=0.0, 61 | ) 62 | parser.add_argument( 63 | "--sdf-flip", 64 | action="store_true", 65 | help=( 66 | "flip sdf values. Needed for Instant-NGP but not for NeuS. When set" 67 | " incorrectly, resulting mesh has wrongly ordered triangles" 68 | ), 69 | ) 70 | parser.add_argument( 71 | "--output", help="Path to resulting mesh (.STL)", default="dual.stl" 72 | ) 73 | parser.add_argument( 74 | "--show", 75 | action="store_true", 76 | help="show the resulting mesh using matplotlib. Slow for large meshes.", 77 | ) 78 | parser.add_argument( 79 | "fpath", 80 | help="Path to density image (.PNG)", 81 | ) 82 | parser.add_argument( 83 | "--vstrategy", choices=["naive", "dual", "mid"], default="naive" 84 | ) 85 | 86 | args = parser.parse_args() 87 | if args.spacing is None: 88 | args.spacing = 1.0 / args.resolution 89 | 90 | res = [args.resolution] * 3 91 | 92 | # Load volumentric SDF values from density image atlas 93 | sdfvalues = sdftoolbox.io.import_volume_from_density_image( 94 | fname=args.fpath, res=res, density_range=args.sdf_truncation, flip=args.sdf_flip 95 | ) 96 | 97 | # Define the sampling locations associated with SDF values 98 | grid = sdftoolbox.Grid( 99 | res=res, 100 | min_corner=[args.offset] * 3, 101 | max_corner=[args.offset + args.spacing * args.resolution] * 3, 102 | ) 103 | 104 | # Wrap grid and SDF values i a discrectized SDF volume 105 | scene = sdftoolbox.sdfs.Discretized(grid, sdfvalues) 106 | 107 | vstrategy = { 108 | "naive": sdftoolbox.NaiveSurfaceNetVertexStrategy, 109 | "dual": sdftoolbox.DualContouringVertexStrategy, 110 | "mid": sdftoolbox.MidpointVertexStrategy, 111 | }[args.vstrategy]() 112 | _logger.info(f"Using {vstrategy.__class__.__name__} strategy") 113 | 114 | # Extract the surface using dual contouring 115 | verts, faces = sdftoolbox.dual_isosurface( 116 | scene, 117 | grid, 118 | triangulate=False, 119 | vertex_strategy=vstrategy, 120 | edge_strategy=sdftoolbox.LinearEdgeStrategy(), 121 | ) 122 | 123 | # Export 124 | _logger.info(f"Saving to {args.output}") 125 | sdftoolbox.io.export_stl(args.output, verts, faces) 126 | 127 | if args.show: 128 | import matplotlib.pyplot as plt 129 | 130 | fig, ax = sdftoolbox.plotting.create_mesh_figure(verts, faces) 131 | plt.show() 132 | 133 | 134 | if __name__ == "__main__": 135 | main() 136 | -------------------------------------------------------------------------------- /sdftoolbox/io.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from pathlib import Path 4 | 5 | import imageio 6 | import numpy as np 7 | 8 | from .mesh import compute_face_normals, triangulate_quads 9 | 10 | _logger = logging.getLogger("sdftoolbox") 11 | 12 | 13 | def export_stl( 14 | fname: Path, verts: np.ndarray, faces: np.ndarray, face_normals: np.ndarray = None 15 | ): 16 | """Export mesh to (ASCII) STL format. 17 | 18 | Params: 19 | fname: output path 20 | verts: (N,3) array of vertices 21 | faces: (M,F) array of faces with F=3 for triangles and F=4 for quads 22 | face_normals: (M,3) array of face normals (optional.) 23 | """ 24 | if faces.shape[-1] == 4: 25 | faces = triangulate_quads(faces) 26 | face_normals = None 27 | if face_normals is None: 28 | face_normals = compute_face_normals(verts, faces) 29 | sep = os.linesep 30 | with open(fname, "w") as fd: 31 | fd.write("solid sdftoolbox" + sep) 32 | for tri, n in zip(faces, face_normals): 33 | fd.write(f"facet normal {n[0]:.4e} {n[1]:.4e} {n[2]:.4e}" + sep) 34 | fd.write("outer loop" + sep) 35 | for v in verts[tri]: 36 | fd.write(f"vertex {v[0]:.4e} {v[1]:.4e} {v[2]:.4e}" + sep) 37 | fd.write("endloop" + sep) 38 | fd.write("endfacet" + sep) 39 | fd.write("endsolid sdftoolbox" + sep) 40 | 41 | 42 | def import_volume_from_density_image( 43 | fname: Path, 44 | res: tuple[int, int, int], 45 | density_range: float = 4.0, 46 | flip: bool = False, 47 | ) -> np.ndarray: 48 | """Loads SDF values from a density 2D image. 49 | 50 | Both, Instant-NGP and NeuS, provide methods to export grid sampled SDF values 51 | as a single PNG image. Grid positions are uniquely mapped to 2D pixel locations 52 | in the target image and the SDF values are transformed to intensity values by 53 | a lossy linear transform 54 | 55 | intensity = int(clip((density-threshold)*scale + 128.5, 0, 255.0)) 56 | 57 | where 58 | 59 | scale = 128.0 / density_range 60 | 61 | Note, these equations seem to be slightly wrong. Disregarding the threshold, 62 | a linear mapping 63 | 64 | y = k*x + d 65 | 66 | from [-range,range] to [0,255] should use d=127.5, k=127.5/range. 67 | 68 | Also note, the `threshold` defines the new zero crossing to differentiate inside 69 | and outside and should hence not be applied when reading the density image to 70 | generate sdfs. 71 | 72 | Additionally, when loading instant-ngp files we need to flip the resulting 73 | sdf values, since in instant-ngp higher density values (positive sdf) represent 74 | the inside. 75 | 76 | Params: 77 | fname: input path 78 | res: grid resolution in x,y,z directions 79 | density_range: range (+/-) of sdf values mapped to 0..255 80 | flip: Flip SDF values (i.e change inside/outside). Needed for instant-ngp but 81 | not for NeuS2 82 | 83 | Returns: 84 | sdfvalues: (I,J,K) array of SDF values 85 | See: 86 | https://github.com/NVlabs/instant-ngp/blob/7d5e858bba5885bbc593fc65e337e4410b992bef/src/marching_cubes.cu#L958 87 | 88 | """ 89 | scale = 128.0 / density_range 90 | 91 | # Load the intensity values as image 92 | I = np.asarray(imageio.v2.imread(fname)).astype(np.float32) 93 | # Convert back to 'density' which is SDF in our case 94 | # See comment in docs for more info 95 | D = (I - 128.5) / scale 96 | if flip: 97 | D *= -1.0 98 | 99 | # Convert pixel coordinates to grid coordinates 100 | U, V = np.meshgrid( 101 | np.arange(I.shape[1], dtype=int), np.arange(I.shape[0], dtype=int) 102 | ) 103 | ndown = int(np.sqrt(res[2])) 104 | nacross = int(res[2] + ndown - 1) // ndown 105 | 106 | X = U % res[0] 107 | Y = V % res[1] 108 | Z = (U // res[0] + (V // res[1]) * nacross).astype(int) 109 | mask = Z < res[2] 110 | _logger.info( 111 | f"Intensity range {I[mask].min()},{I[mask].max()} mapped to SDF range" 112 | f" {D[mask].min():.3f},{D[mask].max():.3f}" 113 | ) 114 | 115 | # Map 2D -> 3D 116 | sdf = np.zeros(res) 117 | sdf[ 118 | X[mask].flatten(), 119 | res[1] - Y[mask].flatten() - 1, # adjust to be y-up like NeuS/Instant-NGP 120 | Z[mask].flatten(), 121 | ] = D[V[mask].flatten(), U[mask].flatten()] 122 | return sdf 123 | -------------------------------------------------------------------------------- /sdftoolbox/roots.py: -------------------------------------------------------------------------------- 1 | """Root finding methods for SDFs""" 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import numpy as np 6 | 7 | if TYPE_CHECKING: 8 | from .sdfs import SDF 9 | 10 | 11 | def directional_newton_roots( 12 | node: "SDF", x: np.ndarray, dirs: np.ndarray = None, max_steps: int = 10, eps=1e-8 13 | ) -> np.ndarray: 14 | """Direction Netwon method for root finding of scalar valued vector functions. 15 | 16 | Root finding on SDFs amounts to finding any point for which f(x)=0, f: R^3->R. Note 17 | that standard multivariate Newton methods do not apply, as the only consider the 18 | case f: R^N->R^N. 19 | 20 | Note, if the directional derivative has zero length no progress is made. Keep in mind, 21 | for example when optimizing around the SDF of a box. 22 | 23 | Params: 24 | node: SDF root node 25 | x: (N,3) initial locations 26 | dirs: (N,3) or (3,) fixed directions (optional). When not given, the directions are 27 | chosen to be the directions of gradient estimates. When (3,) the same constant 28 | direction for all locations is assumed. 29 | max_steps: max number of iterations 30 | eps: SDF value tolerance. locations within tolerance are excluded from 31 | further optimization. 32 | 33 | Returns: 34 | x: (N,3) optimized locations 35 | 36 | See: 37 | Levin, Yuri, and Adi Ben-Israel. 38 | "Directional Newton methods in n variables." 39 | Mathematics of Computation 71.237 (2002): 251-262. 40 | """ 41 | x = np.atleast_2d(x).copy() 42 | 43 | for _ in range(max_steps): 44 | y = node.sample(x) 45 | mask = np.abs(y) > eps 46 | if np.sum(mask) == 0: 47 | break 48 | g = node.gradient(x[mask]) 49 | if dirs is None: 50 | d = g / np.linalg.norm(g, axis=-1, keepdims=True) 51 | elif dirs.ndim == 1: 52 | d = np.expand_dims(dirs, 0) 53 | else: 54 | d = dirs[mask] 55 | dot = (g[:, None, :] @ d[..., None]).squeeze(-1) 56 | noinfo = dot == 0.0 # gradient is orthogonal to direction 57 | with np.errstate(divide="ignore", invalid="ignore"): 58 | scale = y[mask, None] / dot 59 | scale[noinfo] = 0 60 | x[mask] = x[mask] - scale * d 61 | return x 62 | 63 | 64 | def bisect_roots( 65 | node: "SDF", 66 | a: np.ndarray, 67 | b: np.ndarray, 68 | x: np.ndarray = None, 69 | max_steps: int = 10, 70 | eps=1e-8, 71 | linear_interp: bool = False, 72 | ) -> np.ndarray: 73 | """Bisect method for root finding of a SDF. 74 | 75 | This method makes progress even in the case when the directional derivative along 76 | the line a/b is zero. Its similar to the effect of decreasing the edge length in 77 | grid sampling. 78 | 79 | For convenience, this method also takes an initial starting value array `x`, which 80 | is usually superfluous in bisection, but useful in our case. 81 | 82 | Params: 83 | node: SDF root node 84 | x: (N,3) initial locations 85 | a: (N, 3) endpoints of line segments 86 | b: (N, 3) endpoints of line segments 87 | max_steps: max number of iterations 88 | eps: SDF value tolerance. locations within SDF tolerance are excluded 89 | from further optimization. 90 | 91 | Returns: 92 | x: (N,3) optimized locations 93 | """ 94 | 95 | def _select(a, b, x, sdf_a, sdf_b, sdf_x): 96 | new_a = a.copy() 97 | new_sdf_a = sdf_a.copy() 98 | mask = np.sign(sdf_a) == np.sign(sdf_x) 99 | new_a[mask] = b[mask] 100 | new_sdf_a[mask] = sdf_b[mask] 101 | 102 | return new_a, x, new_sdf_a, sdf_x 103 | 104 | # Initial segment choice 105 | sdf_a = node.sample(a) 106 | sdf_b = node.sample(b) 107 | 108 | if x is not None: 109 | sdf_x = node.sample(x) 110 | a, b, sdf_a, sdf_b = _select(a, b, x, sdf_a, sdf_b, sdf_x) 111 | 112 | for _ in range(max_steps): 113 | if not linear_interp: 114 | x = (a + b) * 0.5 115 | else: 116 | d = sdf_b - sdf_a 117 | d[d == 0.0] = 1e-8 118 | t = -sdf_a / d 119 | x = (1 - t[:, None]) * a + t[:, None] * b 120 | sdf_x = node.sample(x) 121 | 122 | if np.sum(np.abs(sdf_x) > eps) == 0: 123 | break 124 | a, b, sdf_a, sdf_b = _select(a, b, x, sdf_a, sdf_b, sdf_x) 125 | 126 | return x 127 | -------------------------------------------------------------------------------- /tests/test_grid.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from numpy.testing import assert_allclose 3 | from sdftoolbox import Grid 4 | from sdftoolbox.utils import reorient_volume 5 | 6 | 7 | def test_correct_shapes(): 8 | g = Grid((2, 2, 2)) 9 | assert g.shape == (2, 2, 2) 10 | assert g.padded_shape == (3, 3, 3) 11 | assert g.num_edges == np.prod((2, 2, 2, 3)) 12 | assert g.edge_shape == (2, 2, 2, 3) 13 | 14 | 15 | def test_find_edge_vertices(): 16 | g = Grid((2, 2, 2)) 17 | test = np.zeros(g.padded_shape, dtype=np.int32) 18 | 19 | sijk, tijk = g.find_edge_vertices(range(g.num_edges), ravel=False) 20 | si, sj, sk = sijk.T 21 | ti, tj, tk = tijk.T 22 | 23 | test[si, sj, sk] = 1 24 | 25 | assert_allclose( 26 | reorient_volume(test), 27 | np.array( 28 | [ 29 | [ # first ij slice (origin lower left) 30 | [0, 0, 0], 31 | [1, 1, 0], 32 | [1, 1, 0], 33 | ], 34 | [ 35 | [0, 0, 0], 36 | [1, 1, 0], 37 | [1, 1, 0], 38 | ], 39 | [ 40 | [0, 0, 0], 41 | [0, 0, 0], 42 | [0, 0, 0], 43 | ], 44 | ] 45 | ), 46 | ) 47 | 48 | test[:] = 0 49 | test[ti, tj, tk] = 1 50 | # print(repr(reorient_volume(test))) 51 | assert_allclose( 52 | reorient_volume(test), 53 | np.array( 54 | [ 55 | [ # first ij slice (origin lower left) 56 | [1, 1, 0], 57 | [1, 1, 1], 58 | [0, 1, 1], 59 | ], 60 | [ 61 | [1, 1, 0], 62 | [1, 1, 1], 63 | [1, 1, 1], 64 | ], 65 | [ 66 | [0, 0, 0], 67 | [1, 1, 0], 68 | [1, 1, 0], 69 | ], 70 | ] 71 | ), 72 | ) 73 | 74 | 75 | # def test_correct_voxel_indices(): 76 | # t = VolumeTopology((4, 4, 4)) 77 | # assert t.voxel_indices.shape == (3, 3, 3, 3) 78 | 79 | # assert_allclose(t.voxel_indices[0, 0, 0], (0, 0, 0)) 80 | # assert_allclose(t.voxel_indices[0, 1, 0], (0, 1, 0)) 81 | # assert_allclose(t.voxel_indices[0, 1, 1], (0, 1, 1)) 82 | 83 | 84 | # def test_correct_edge_indices(): 85 | # t = VolumeTopology((3, 3, 3)) 86 | 87 | # # No duplicate edge indices 88 | # edge_set = {tuple(e.tolist()) for e in t.edge_indices} 89 | # assert len(edge_set) == len(t.edge_indices) 90 | 91 | # # No edge out of bounds 92 | # low = (t.edge_indices[:, :3] >= 0).all() 93 | # high = (t.edge_indices[:, :3] < (3, 3, 3)).all() 94 | # assert low and high 95 | 96 | 97 | # def test_source_target_indices(): 98 | # t = VolumeTopology((3, 3, 3)) 99 | # e = np.array( 100 | # [ 101 | # [0, 0, 0, 1], 102 | # [1, 1, 1, 0], 103 | # [1, 1, 1, 1], 104 | # [1, 1, 1, 2], 105 | # ] 106 | # ) 107 | # sources = t.edge_sources(e) 108 | # targets = t.edge_targets(e) 109 | # assert_allclose(sources, e[:, :3]) 110 | # assert_allclose( 111 | # targets, 112 | # [ 113 | # [0, 1, 0], 114 | # [2, 1, 1], 115 | # [1, 2, 1], 116 | # [1, 1, 2], 117 | # ], 118 | # ) 119 | 120 | 121 | # def test_correct_edge_neighbors(): 122 | # t = VolumeTopology((3, 3, 3)) 123 | # ns, mask = t.edge_neighbors([[0, 0, 0, 1], [1, 1, 1, 0], [2, 2, 2, 0]]) 124 | # assert ns.shape == (3, 4, 3) 125 | # assert mask.shape == (3, 4) 126 | 127 | # assert_allclose( 128 | # ns, 129 | # [ 130 | # [ 131 | # [0, 0, 0], 132 | # [-1, 0, 0], 133 | # [-1, 0, -1], 134 | # [0, 0, -1], 135 | # ], 136 | # [ 137 | # [1, 1, 1], 138 | # [1, 0, 1], 139 | # [1, 0, 0], 140 | # [1, 1, 0], 141 | # ], 142 | # [ 143 | # [2, 2, 2], 144 | # [2, 1, 2], 145 | # [2, 1, 1], 146 | # [2, 2, 1], 147 | # ], 148 | # ], 149 | # ) 150 | 151 | # assert_allclose( 152 | # mask, 153 | # [ 154 | # [1, 0, 0, 0], 155 | # [1, 1, 1, 1], 156 | # [1, 1, 1, 1], 157 | # ], 158 | # ) 159 | 160 | 161 | # def test_correct_unique_voxels(): 162 | # t = VolumeTopology((4, 4, 4)) 163 | # uv = t.unique_voxels( 164 | # [ 165 | # [0, 1, 0], 166 | # [0, 2, 1], 167 | # [0, 2, 1], 168 | # [0, 0, 0], 169 | # ] 170 | # ) 171 | # assert_allclose( 172 | # uv, 173 | # [ 174 | # [0, 0, 0], 175 | # [0, 1, 0], 176 | # [0, 2, 1], 177 | # ], 178 | # ) 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://github.com/cheind/sdftoolbox/actions/workflows/python-package.yml/badge.svg) 2 | 3 | # sdftoolbox 4 | 5 | This repository provides vectorized Python methods for creating, manipulating and tessellating signed distance fields (SDFs). This library was started to investigate variants of dual isosurface extraction methods, but has since evolved into a useful toolbox around SDFs. 6 | 7 |
8 | 9 |
10 | 11 | The image above shows two reconstructions of a sphere displaced by waves. The reconstruction on the left uses (dual) SurfaceNets from this library, the right side shows the result of applying (primal) Marching Cubes algorithm from scikit-image. 12 | 13 | See [examples/compare.py](examples/compare.py) for details and [doc/SDF.md](doc/SDF.md) for an in-depth documentation. 14 | 15 | ## Features 16 | 17 | - A generic blueprint algorithm for dual iso-surface generation from SDFs 18 | - providing the following vertex placement strategies 19 | - (Naive) SurfaceNets 20 | - Dual Contouring 21 | - Midpoint to generate Minecraft like reconstructions 22 | - providing the following edge/surface boundary intersection strategies 23 | - Linear (single step) 24 | - Newton (iterative) 25 | - Bisection (iterative) 26 | - Mesh postprocessing 27 | - Vertex reprojection onto SDFs 28 | - Quad/Triangle topology support 29 | - Vertex/Face normal support 30 | - Tools for programmatically creating and modifying SDFs 31 | - Importing volumetric SDFs from NeRF tools such as [instant-ngp](https://www.google.com/search?channel=fs&client=ubuntu-sn&q=instant-ngp) and [NeuS2](https://github.com/19reborn/NeuS2) 32 | - Inline plotting support for reconstructed meshes using matplotlib 33 | - Exporting (STL) of tesselated isosurfaces 34 | 35 | ## Documentation 36 | 37 | Algorithmic ideas, mathematical details and results are discussed in [doc/SDF.md](doc/SDF.md). 38 | 39 | ## Example Code 40 | 41 | ```python 42 | # Main import 43 | import sdftoolbox 44 | 45 | # Setup a snowman-scene 46 | snowman = sdftoolbox.sdfs.Union( 47 | [ 48 | sdftoolbox.sdfs.Sphere.create(center=(0, 0, 0), radius=0.4), 49 | sdftoolbox.sdfs.Sphere.create(center=(0, 0, 0.45), radius=0.3), 50 | sdftoolbox.sdfs.Sphere.create(center=(0, 0, 0.8), radius=0.2), 51 | ], 52 | ) 53 | family = sdftoolbox.sdfs.Union( 54 | [ 55 | snowman.transform(trans=(-0.75, 0.0, 0.0)), 56 | snowman.transform(trans=(0.0, -0.3, 0.0), scale=0.8), 57 | snowman.transform(trans=(0.75, 0.0, 0.0), scale=0.6), 58 | ] 59 | ) 60 | scene = sdftoolbox.sdfs.Difference( 61 | [ 62 | family, 63 | sdftoolbox.sdfs.Plane().transform(trans=(0, 0, -0.2)), 64 | ] 65 | ) 66 | 67 | # Generate the sampling locations. Here we use the default params 68 | grid = sdftoolbox.Grid( 69 | res=(65, 65, 65), 70 | min_corner=(-1.5, -1.5, -1.5), 71 | max_corner=(1.5, 1.5, 1.5), 72 | ) 73 | 74 | # Extract the surface using dual contouring 75 | verts, faces = sdftoolbox.dual_isosurface( 76 | scene, 77 | grid, 78 | vertex_strategy=sdftoolbox.NaiveSurfaceNetVertexStrategy(), 79 | triangulate=False, 80 | ) 81 | ``` 82 | 83 | generates 84 | 85 |
86 | 87 |
88 | 89 | See [examples/hello_dualiso.py](examples/hello_dualiso.py) for details. 90 | 91 | ## Install 92 | 93 | Install with development extras to run all the examples. 94 | 95 | ``` 96 | pip install git+https://github.com/cheind/sdf-surfacenets#egg=sdf-surfacenets[dev] 97 | ``` 98 | 99 | ## Examples 100 | 101 | The examples can be found in [./examples/](./examples/). Each example can be invoked as a module 102 | 103 | ``` 104 | python -m examples. 105 | ``` 106 | 107 | ### Mesh from NeRF 108 | 109 | This library supports loading SDFs from density fields provided by various NeRF implementations (instant-ngp and NeuS2). These discretized SDF fields can then be manipulated/triangulated using **sdftoolbox** routines. 110 | 111 | The following image shows the resulting triangulated mesh from the Lego scene generated by **sdftoolbox** from an instant-ngp density image atlas. 112 | 113 | ![](doc/nerf/naive25600.png) 114 | 115 | Command 116 | 117 | ```shell 118 | python -m examples.nerf2mesh \ 119 | -r 256 -t 4.0 -o -1.5 -s 0.0117 \ 120 | --sdf-flip \ 121 | doc/nerf/density.png 122 | 123 | # Use 'python -m examples.nerf2mesh --help' for more options. 124 | ``` 125 | 126 | Note, meshes generated by **sdftoolbox** seem to be of better quality than those generated by the respective NeRF tools. The following compares a low-res reconstruction (res=64,drange=1) of the same scene 127 | 128 | | sdftoolbox | NeuS2 | 129 | | :-----------------------: | :-----------------------: | 130 | | ![](doc/nerf/naive01.png) | ![](doc/nerf/neus200.png) | 131 | 132 | See https://github.com/19reborn/NeuS2/issues/22 for a discussion 133 | 134 | ### Gallery 135 | 136 | Here are some additional plots from various examples 137 | 138 |
139 | 140 |
141 |
142 | 143 |
144 |
145 | 146 |
147 |
148 | 149 |
150 | 151 | ## References 152 | 153 | See [doc/SDF.md](doc/SDF.md). 154 | -------------------------------------------------------------------------------- /sdftoolbox/dual_isosurfaces.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from typing import TYPE_CHECKING, Union 4 | from dataclasses import dataclass 5 | 6 | import numpy as np 7 | 8 | from .dual_strategies import LinearEdgeStrategy, NaiveSurfaceNetVertexStrategy 9 | from .mesh import triangulate_quads 10 | 11 | if TYPE_CHECKING: 12 | from .dual_strategies import DualEdgeStrategy, DualVertexStrategy 13 | from .grid import Grid 14 | from .sdfs import SDF 15 | 16 | 17 | _logger = logging.getLogger("sdftoolbox") 18 | 19 | 20 | @dataclass 21 | class DebugInfo: 22 | edges_active_mask: np.ndarray 23 | edges_isect_coords: np.ndarray 24 | 25 | 26 | def dual_isosurface( 27 | node: "SDF", 28 | grid: "Grid", 29 | edge_strategy: "DualEdgeStrategy" = None, 30 | vertex_strategy: "DualVertexStrategy" = None, 31 | triangulate: bool = False, 32 | return_debug_info: bool = False, 33 | vertex_relaxation_percent: float = 0.1, 34 | ) -> Union[tuple[np.ndarray, np.ndarray], tuple[np.ndarray, np.ndarray, DebugInfo]]: 35 | """A vectorized dual iso-surface extraction algorithm for signed distance fields. 36 | 37 | This implementation approximates the relaxation based vertex placement 38 | method of [1] using a `naive` [2] average. This method is fully vectorized. 39 | 40 | This method does not compute surface normals, see `surfacenets.normals` 41 | for details. 42 | 43 | Params: 44 | node: the root node of the SDF. If you already have discretized SDF values in 45 | grid like fashion, wrap them using sdfs.Discretized. 46 | grid: (I,J,K) spatial sampling locations 47 | edge_strategy: Defines how edge/surface boundary intersection are determined. 48 | If not specified defaults to LinearEdgeStrategy. 49 | vertex_strategy: Defines how vertices are placed inside of voxels. If not 50 | specified defaults to NaiveSurfaceNetVertexStrategy. 51 | triangulate: When true, returns triangles instead of quadliterals. 52 | vertex_relaxation_percent: Edge intersection values outside of [0,1) will 53 | be tolerated up to this percentage. Increasing this value allows for 54 | more accurate shapes when the resolution of the grid is low and multiple 55 | vertices per cell are required. 56 | return_debug_info: Whether to return additional intermediate results 57 | 58 | Returns: 59 | verts: (N,3) array of vertices 60 | faces: (M,F) index array of faces into vertices. For quadliterals F is 4, 61 | otherwise 3. 62 | debug: instance of DebugInfo when return_debug_info is True. 63 | 64 | References: 65 | Gibson, S. F. F. (1999). Constrained elastic surfacenets: Generating smooth 66 | models from binary segmented data. 67 | """ 68 | t0 = time.perf_counter() 69 | # Defaults 70 | if vertex_strategy is None: 71 | vertex_strategy = NaiveSurfaceNetVertexStrategy() 72 | if edge_strategy is None: 73 | edge_strategy = LinearEdgeStrategy() 74 | 75 | # First, we pad the sample volume on each outer boundary single (nan) value to 76 | # avoid having to deal with most out-of-bounds issues. 77 | padded_sdf_values = np.pad( 78 | node.sample(grid.xyz), 79 | ((0, 1), (0, 1), (0, 1)), 80 | mode="constant", 81 | constant_values=np.nan, 82 | ) 83 | _logger.debug(f"After padding; elapsed {time.perf_counter() - t0:.4f} secs") 84 | 85 | # 1. Step - Active Edges 86 | # We find the set of active edges that cross the surface boundary. Note, 87 | # edges are defined in terms of originating voxel index + a number {0,1,2} 88 | # that defines the positive canonical edge direction. Only considering forward edges 89 | # has the advantage of not having to deal with no duplicate edges. Also, through 90 | # padding we can ensure that no edges will be missed on the volume boundary. 91 | # In the code below, we perform one iteration per axis. Compared to full 92 | # vectorization, this avoids fancy indexing the SDF volume and has performance 93 | # advantages. 94 | 95 | edges_active_mask = np.zeros((grid.num_edges,), dtype=bool) 96 | edges_flip_mask = np.zeros((grid.num_edges,), dtype=bool) 97 | edges_isect_coords = np.full( 98 | (grid.num_edges, 3), np.nan, dtype=padded_sdf_values.dtype 99 | ) 100 | 101 | # Get all possible edge source locations 102 | sijk = grid.get_all_source_vertices() # (N,3) 103 | si, sj, sk = sijk.T 104 | sdf_src = padded_sdf_values[si, sj, sk] # (N,) 105 | 106 | _logger.debug(f"After initialization; elapsed {time.perf_counter() - t0:.4f} secs") 107 | 108 | # For each axis 109 | for aidx, off in enumerate(np.eye(3, dtype=np.int32)): 110 | # Compute the edge target locations and fetch SDF values 111 | tijk = sijk + off[None, :] 112 | ti, tj, tk = tijk.T 113 | sdf_dst = padded_sdf_values[ti, tj, tk] 114 | 115 | # By intermediate value theorem for continuous functions if the sign of src 116 | # and dst is different, there must be a root enclosed. We also avoid edges 117 | # with NaNs, that might occur at boundaries as induced by potential SDF padding. 118 | src_sign = np.sign(sdf_src) 119 | dst_sign = np.sign(sdf_dst) 120 | active = np.logical_and(src_sign != dst_sign, np.isfinite(sdf_dst)) 121 | 122 | # Just like in MC, we compute a parametric value t for each edge that 123 | # tells use where the surface boundary intersects the edge. 124 | t = edge_strategy.find_edge_intersections( 125 | sijk[active], 126 | sdf_src[active], 127 | tijk[active], 128 | sdf_dst[active], 129 | aidx, 130 | off, 131 | node, 132 | grid, 133 | ) 134 | # Compute the floating point grid coords of intersection 135 | isect_coords = sijk[active] + off[None, :] * t[:, None] 136 | need_flip = (sdf_dst[active] - sdf_src[active]) < 0.0 137 | 138 | # We store the partial axis results in the global arrays in interleaved 139 | # fashion. We do this, to comply with np.unravel_index/np.ravel_multi_index 140 | # that are used internally by the grid module. 141 | edges_active_mask[aidx::3] = active 142 | edges_flip_mask[aidx::3][active] = need_flip 143 | edges_isect_coords[aidx::3][active] = isect_coords 144 | 145 | _logger.debug(f"After active edges; elapsed {time.perf_counter() - t0:.4f} secs") 146 | 147 | # 2. Step - Tesselation 148 | # Each active edge gives rise to a quad that is formed by the final vertices of the 149 | # 4 voxels sharing the edge. In this implementation we consider only those quads 150 | # where a full neighborhood exists - i.e non of the adjacent voxels is in the 151 | # padding area. 152 | active_edges = np.where(edges_active_mask)[0] # (A,) 153 | active_quads, complete_mask = grid.find_voxels_sharing_edge(active_edges) # (A,4) 154 | active_edges = active_edges[complete_mask] 155 | active_quads = active_quads[complete_mask] 156 | _logger.debug(f"After finding quads; elapsed {time.perf_counter() - t0:.4f} secs") 157 | 158 | # The active quad indices are are ordered ccw when looking from the positive 159 | # active edge direction. In case the sign difference is negative between edge 160 | # start and end, we need to reverse the indices to maintain a correct ccw 161 | # winding order. 162 | active_edges_flip = edges_flip_mask[active_edges] 163 | active_quads[active_edges_flip] = np.flip(active_quads[active_edges_flip], -1) 164 | _logger.debug( 165 | f"After correcting quads; elapsed {time.perf_counter() - t0:.4f} secs" 166 | ) 167 | 168 | # Voxel indices are not unique, since any active voxel will be part in more than one 169 | # quad. However, each active voxel should give rise to only one vertex. To 170 | # avoid duplicate computations, we compute the set of unique active voxels. Bonus: 171 | # the inverse array is already the flattened final face array. 172 | active_voxels, faces = np.unique(active_quads, return_inverse=True) # (M,) 173 | 174 | # Step 3. Vertex locations 175 | # For each active voxel, we need to find one vertex location. The 176 | # method todo that depennds on `vertex_placement_mode`. No matter which method 177 | # is selected, we expect the returned coordinates to be in voxel space. 178 | grid_verts = vertex_strategy.find_vertex_locations( 179 | active_voxels, edges_isect_coords, node, grid 180 | ) 181 | # Clip vertices to voxel bounds allowing for a relaxation tolerance. 182 | grid_ijk = grid.unravel_nd(active_voxels, grid.padded_shape) 183 | grid_verts = ( 184 | np.clip( 185 | grid_verts - grid_ijk, 186 | 0.0 - vertex_relaxation_percent, 187 | 1.0 + vertex_relaxation_percent, 188 | ) 189 | + grid_ijk 190 | ) 191 | 192 | # Finally, we need to account for the padded voxels and scale them to 193 | # data dimensions 194 | verts = grid.grid_to_data(grid_verts) 195 | _logger.debug( 196 | f"After vertex computation; elapsed {time.perf_counter() - t0:.4f} secs" 197 | ) 198 | 199 | # 4. Step - Postprocessing 200 | # In case triangulation is required, we simply split each quad into two 201 | # triangles. Since the vertex order in faces is ccw, that's easy too. 202 | faces = faces.reshape(-1, 4) 203 | if triangulate: 204 | faces = triangulate_quads(faces) 205 | _logger.debug( 206 | f"After triangulation; elapsed {time.perf_counter() - t0:.4f} secs" 207 | ) 208 | _logger.info(f"Finished after {time.perf_counter() - t0:.4f} secs") 209 | _logger.info(f"Found {len(verts)} vertices and {len(faces)} faces") 210 | 211 | if return_debug_info: 212 | return verts, faces, DebugInfo(edges_active_mask, edges_isect_coords) 213 | else: 214 | return verts, faces 215 | -------------------------------------------------------------------------------- /sdftoolbox/plotting.py: -------------------------------------------------------------------------------- 1 | """Helper functions for plotting meshes through matplotlib.""" 2 | 3 | from typing import Literal, Union 4 | 5 | import matplotlib.pyplot as plt 6 | import numpy as np 7 | from matplotlib.ticker import MaxNLocator 8 | from mpl_toolkits.mplot3d.art3d import Poly3DCollection, Line3DCollection 9 | from matplotlib.backends.backend_agg import FigureCanvasAgg 10 | from matplotlib.figure import Figure 11 | from matplotlib.axes import Axes 12 | 13 | 14 | def create_figure( 15 | proj_type: Literal["persp", "ortho"] = "persp", 16 | fig_aspect: float = 1, 17 | headless: bool = False, 18 | ) -> tuple[Figure, Axes]: 19 | """Returns a figure/axis for 3d plotting.""" 20 | if headless: 21 | fig = Figure(figsize=plt.figaspect(fig_aspect)) 22 | else: 23 | fig = plt.figure(figsize=plt.figaspect(fig_aspect)) 24 | ax = fig.add_subplot(111, projection="3d", computed_zorder=False) 25 | ax.set_proj_type(proj_type) 26 | return fig, ax 27 | 28 | 29 | def create_split_figure( 30 | sync: bool = True, proj_type: Literal["persp", "ortho"] = "persp" 31 | ) -> tuple[Figure, Axes]: 32 | """Returns a figure composed of two axis for side-by-side 3d plotting. 33 | 34 | Params: 35 | sync: Whether or not to sync the two views once an interactive op 36 | has finished. Axis sharing like for 2d plots is not implemented 37 | for matplotlib 3d, so we simulate it with custom code. 38 | proj_type: Projection type 39 | 40 | Returns: 41 | fig: matplotlib figure 42 | axes: tuple of two axis 43 | """ 44 | fig = plt.figure(figsize=plt.figaspect(0.5)) 45 | ax0 = fig.add_subplot( 46 | 1, 2, 1, projection="3d", proj_type=proj_type, computed_zorder=False 47 | ) 48 | ax1 = fig.add_subplot( 49 | 1, 2, 2, projection="3d", proj_type=proj_type, computed_zorder=False 50 | ) 51 | 52 | sync_pending = False 53 | sync_dir = [None, None] 54 | 55 | def sync_views(a, b): 56 | b.view_init(elev=a.elev, azim=a.azim) 57 | b.set_xlim3d(a.get_xlim3d()) 58 | b.set_ylim3d(a.get_ylim3d()) 59 | b.set_zlim3d(a.get_zlim3d()) 60 | 61 | def on_press(event): 62 | nonlocal sync_pending, sync_dir 63 | inaxes = event.inaxes in [ax0, ax1] 64 | if inaxes: 65 | sync_pending = True 66 | sync_dir = [ax0, ax1] if event.inaxes == ax0 else [ax1, ax0] 67 | 68 | def on_release(event): 69 | nonlocal sync_pending 70 | 71 | if sync_pending: 72 | sync_views(*sync_dir) 73 | sync_pending = False 74 | fig.canvas.draw_idle() 75 | 76 | if sync: 77 | fig.canvas.mpl_connect("button_press_event", on_press) 78 | fig.canvas.mpl_connect("button_release_event", on_release) 79 | 80 | return fig, (ax0, ax1) 81 | 82 | 83 | def setup_axes( 84 | ax, 85 | min_corner: tuple[float, ...], 86 | max_corner: tuple[float, ...], 87 | azimuth: float = -139, 88 | elevation: float = 35, 89 | num_grid: int = 3, 90 | ): 91 | """Set axis view options. 92 | 93 | Params: 94 | ax: matplotlib axis 95 | min_corner: min data corner for computing zoom values 96 | max_corner: max data corner for computing zoom values 97 | azimuth: camera view angle in degrees 98 | elevation: camera view angle in degrees 99 | num_grid: the number of grid lines. Set to zero to disable grid. 100 | """ 101 | 102 | ax.set_xlim(min_corner[0], max_corner[0]) 103 | ax.set_ylim(min_corner[1], max_corner[1]) 104 | ax.set_zlim(min_corner[2], max_corner[2]) 105 | if num_grid > 0: 106 | ax.xaxis.set_major_locator(MaxNLocator(num_grid)) 107 | ax.yaxis.set_major_locator(MaxNLocator(num_grid)) 108 | ax.zaxis.set_major_locator(MaxNLocator(num_grid)) 109 | else: 110 | ax.grid(False) 111 | ax.set_axis_off() 112 | ax.w_xaxis.set_pane_color((1.0, 1.0, 1.0, 0.0)) 113 | ax.w_yaxis.set_pane_color((1.0, 1.0, 1.0, 0.0)) 114 | ax.w_zaxis.set_pane_color((1.0, 1.0, 1.0, 0.0)) 115 | ax.set_xlabel("x") 116 | ax.set_ylabel("y") 117 | ax.set_zlabel("z") 118 | ax.set_box_aspect( 119 | ( 120 | max_corner[0] - min_corner[0], 121 | max_corner[1] - min_corner[1], 122 | max_corner[2] - min_corner[2], 123 | ) 124 | ) 125 | ax.view_init(elev=elevation, azim=azimuth) 126 | # https://github.com/matplotlib/matplotlib/blob/v3.5.0/lib/mpl_toolkits/mplot3d/axes3d.py#L1460-L1499 127 | 128 | 129 | def plot_mesh( 130 | ax, 131 | verts: np.ndarray, 132 | faces: np.ndarray, 133 | face_normals: np.ndarray = None, 134 | vertex_normals: np.ndarray = None, 135 | **kwargs, 136 | ): 137 | """Add a mesh to the axis. 138 | 139 | Params: 140 | ax: matplotlib axis 141 | verts: (N,3) array of vertices 142 | faces: (M,F) array of faces with F=3 for triangles and F=4 for quads 143 | face_normals: (M,3) array of face normals (optional). When supplied, additional 144 | arrows will be plotted to indicate the normal per face. 145 | vertex_normals: (N,3) array of vertex normals (optional). When supplied, 146 | additional arrows will be plotted to indicate the normal per vertex. 147 | kwargs: additional arguments passed to Poly3DCollection 148 | """ 149 | # Better colors? https://matplotlib.org/stable/gallery/mplot3d/voxels_rgb.html 150 | # https://stackoverflow.com/questions/56864378/how-to-light-and-shade-a-poly3dcollection 151 | kwargs = {"linewidth": 0.2, "zorder": 1, **kwargs} 152 | mesh = Poly3DCollection(verts[faces], **kwargs) 153 | mesh.set_edgecolor("w") 154 | ax.add_collection3d(mesh) 155 | 156 | if face_normals is not None: 157 | centers = verts[faces].mean(1) 158 | plot_normals(ax, centers, face_normals, color="purple") 159 | 160 | if vertex_normals is not None: 161 | plot_normals(ax, verts, vertex_normals, color="lime") 162 | 163 | 164 | def plot_samples(ax, xyz: np.ndarray, sdf_values: np.ndarray = None): 165 | """Plots sampling points and colorizes them based on sdf classification.""" 166 | colors = np.zeros_like(xyz) 167 | if sdf_values is not None: 168 | colors[sdf_values <= 0] = (1.0, 1.0, 0.0) 169 | ax.scatter( 170 | xyz[..., 0], xyz[..., 1], xyz[..., 2], s=2, c=colors.reshape(-1, 3), alpha=0.5 171 | ) 172 | 173 | 174 | def plot_normals(ax, origins: np.ndarray, dirs: np.ndarray, **kwargs): 175 | """Plots normals from points and directions""" 176 | kwargs = {"linewidth": 0.5, "zorder": 2, "length": 0.1, **kwargs} 177 | 178 | ax.quiver( 179 | origins[:, 0], 180 | origins[:, 1], 181 | origins[:, 2], 182 | dirs[:, 0], 183 | dirs[:, 1], 184 | dirs[:, 2], 185 | **kwargs, 186 | ) 187 | 188 | 189 | def plot_edges(ax, src, dst, **kwargs): 190 | """Plots pair of points as edges""" 191 | lines = np.stack((src, dst), 1) 192 | art = Line3DCollection(lines, **kwargs) 193 | ax.add_collection3d(art) 194 | 195 | 196 | def create_mesh_figure( 197 | verts: np.ndarray, 198 | faces: np.ndarray, 199 | face_normals: np.ndarray = None, 200 | vertex_normals: np.ndarray = None, 201 | fig_kwargs: dict = None, 202 | ) -> tuple[Figure, Axes]: 203 | """Helper to quickly plot a single mesh. 204 | 205 | This method creates a 3d enabled figure, adds the given mesh to the plot 206 | and estimates the view limit from mesh bounds. 207 | 208 | Params: 209 | verts: (N,3) array of vertices 210 | faces: (M,F) array of faces with F=3 for triangles and F=4 for quads 211 | face_normals: (M,3) array of face normals (optional). When supplied, additional 212 | arrows will be plotted to indicate the normal per face. 213 | vertex_normals: (N,3) array of vertex normals (optional). When supplied, 214 | additional arrows will be plotted to indicate the normal per vertex. 215 | fig_kwargs: additional arguments passed to create_figure 216 | 217 | Returns: 218 | fig: matplotlib figure 219 | ax: matplotlib axis 220 | """ 221 | min_corner = verts.min(0) 222 | max_corner = verts.max(0) 223 | mask = abs(max_corner - min_corner) < 1e-5 224 | min_corner[mask] -= 0.5 225 | max_corner[mask] += 0.5 226 | 227 | fig_kwargs = fig_kwargs or {} 228 | 229 | fig, ax = create_figure(**fig_kwargs) 230 | plot_mesh(ax, verts, faces, face_normals, vertex_normals) 231 | setup_axes(ax, min_corner, max_corner) 232 | return fig, ax 233 | 234 | 235 | def generate_rotation_gif( 236 | filename: str, 237 | fig: Figure, 238 | axs: Union[Axes, list[Axes]], 239 | azimuth_range: tuple[float, float] = (0, 2 * np.pi), 240 | num_images: int = 64, 241 | total_time: float = 5.0, 242 | ): 243 | """Generates a rotating figure and stores it as animated GIF. 244 | 245 | Params: 246 | filename: path to resulting file 247 | fig: matplotlib figure 248 | ax: matplotlib 3d axis or list of axis 249 | azimuth_range: the incremental range of the rotation. Animation 250 | starts at ax.azimuth + azimut_range[0] 251 | num_images: total number of frames 252 | total_time: total animation time in seconds 253 | """ 254 | import imageio 255 | 256 | azimuth_incs = np.degrees( 257 | np.linspace(azimuth_range[0], azimuth_range[1], num_images, endpoint=False) 258 | ) 259 | 260 | if isinstance(axs, Axes): 261 | axs = [Axes] 262 | 263 | azimuth0, elevation0 = zip(*[(ax.azim, ax.elev) for ax in axs]) 264 | 265 | with imageio.get_writer( 266 | filename, mode="I", duration=total_time / num_images 267 | ) as writer: 268 | canvas = FigureCanvasAgg(fig) 269 | for ainc in azimuth_incs: 270 | for ax, az0, el0 in zip(axs, azimuth0, elevation0): 271 | ax.view_init(elev=el0, azim=az0 + ainc) 272 | canvas.draw() 273 | buf = canvas.buffer_rgba() 274 | img = np.asarray(buf) 275 | writer.append_data(img) 276 | -------------------------------------------------------------------------------- /sdftoolbox/grid.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from .types import float_dtype 4 | 5 | 6 | class Grid: 7 | """A 3D sampling grid 8 | 9 | This class provides helper methods to determine sampling locations 10 | and methods to traverse the topology of grids in vectorized fashion. 11 | """ 12 | 13 | """Offsets used to compute the edge indices for a single voxel.""" 14 | VOXEL_EDGE_OFFSETS = np.array( 15 | [ 16 | [0, 0, 0, 0], 17 | [0, 0, 0, 1], 18 | [0, 0, 0, 2], 19 | [1, 0, 0, 1], 20 | [1, 0, 0, 2], 21 | [0, 1, 0, 0], 22 | [0, 1, 0, 2], 23 | [0, 0, 1, 0], 24 | [0, 0, 1, 1], 25 | [1, 1, 0, 2], 26 | [0, 1, 1, 0], 27 | [1, 0, 1, 1], 28 | ], 29 | dtype=np.int32, 30 | ).reshape(1, 12, 4) 31 | 32 | """Offsets for computing voxel indices neighboring a given edge. 33 | The ordering is such that voxel indices are CCW when looking from 34 | positive edge dir, always starting with maximum voxel index. 35 | """ 36 | EDGE_VOXEL_OFFSETS = np.array( 37 | [ 38 | [ # i 39 | [0, 0, 0], 40 | [0, -1, 0], 41 | [0, -1, -1], 42 | [0, 0, -1], 43 | ], 44 | [ # j 45 | [0, 0, 0], 46 | [0, 0, -1], 47 | [-1, 0, -1], 48 | [-1, 0, 0], 49 | ], 50 | [ # k 51 | [0, 0, 0], 52 | [-1, 0, 0], 53 | [-1, -1, 0], 54 | [0, -1, 0], 55 | ], 56 | ], 57 | dtype=np.int32, 58 | ) 59 | 60 | @staticmethod 61 | def sampling_coords( 62 | res: tuple[int, int, int], 63 | min_corner: tuple[float, float, float], 64 | max_corner: tuple[float, float, float], 65 | ) -> tuple[np.ndarray, np.ndarray]: 66 | """Generates volumentric sampling locations. 67 | 68 | Params: 69 | res: resolution for each axis 70 | min_corner: bounds for the sampling volume 71 | max_corner: bounds for the sampling volume 72 | dtype: floating point data type of result 73 | 74 | Returns: 75 | xyz: (I,J,K,3) array of sampling locations 76 | spacing: (3,) the spatial spacing between two voxels 77 | """ 78 | 79 | ranges = [ 80 | np.linspace(min_corner[0], max_corner[0], res[0], dtype=float_dtype), 81 | np.linspace(min_corner[1], max_corner[1], res[1], dtype=float_dtype), 82 | np.linspace(min_corner[2], max_corner[2], res[2], dtype=float_dtype), 83 | ] 84 | 85 | X, Y, Z = np.meshgrid(*ranges, indexing="ij") 86 | xyz = np.stack((X, Y, Z), -1) 87 | return xyz 88 | 89 | def __init__( 90 | self, 91 | res: tuple[int, int, int] = (33, 33, 33), 92 | min_corner: tuple[float, float, float] = (-1.0, -1.0, -1.0), 93 | max_corner: tuple[float, float, float] = (1.0, 1.0, 1.0), 94 | xyz: np.ndarray = None, 95 | ): 96 | if xyz is None: 97 | xyz = Grid.sampling_coords(res, min_corner, max_corner) 98 | self.xyz = xyz 99 | self.padded_shape = ( 100 | self.xyz.shape[0] + 1, 101 | self.xyz.shape[1] + 1, 102 | self.xyz.shape[2] + 1, 103 | ) 104 | self.edge_shape = self.xyz.shape[:3] + (3,) 105 | self.num_edges = np.prod(self.edge_shape) 106 | 107 | @property 108 | def spacing(self): 109 | """The spatial step size in each dimension""" 110 | return self.xyz[1, 1, 1] - self.xyz[0, 0, 0] 111 | 112 | @property 113 | def min_corner(self): 114 | """Minimum sampling point""" 115 | return self.xyz[0, 0, 0] 116 | 117 | @property 118 | def max_corner(self): 119 | """Maximum sampling corner""" 120 | return self.xyz[-1, -1, -1] 121 | 122 | @property 123 | def shape(self): 124 | """Shape of the grid""" 125 | return self.xyz.shape[:3] 126 | 127 | def subsample(self, step: int) -> "Grid": 128 | """Subsample the grid using every nth sample point.""" 129 | return Grid(xyz=self.xyz[::step, ::step, ::step]) 130 | 131 | def ravel_nd(self, nd_indices: np.ndarray, shape: tuple[int, ...]) -> np.ndarray: 132 | """Convert multi-dimensional indices to a flat indices.""" 133 | return np.ravel_multi_index(list(nd_indices.T), dims=shape) 134 | 135 | def unravel_nd(self, indices: np.ndarray, shape: tuple[int, ...]) -> np.ndarray: 136 | """Convert flat indices back to a multi-dimensional indices.""" 137 | ur = np.unravel_index(indices, shape) 138 | return np.stack(ur, -1) 139 | 140 | def find_edge_vertices( 141 | self, edges: np.ndarray, ravel: bool = True 142 | ) -> tuple[np.ndarray, np.ndarray]: 143 | """Find start/end voxel indices for the given edges. 144 | 145 | Params: 146 | edges: (N,) or (N,4) array of edge indices 147 | ravel: Whether to return voxel as flat indices or nd indices 148 | 149 | Returns: 150 | s: (N,) or (N,3) array of source voxel indices for each edge 151 | t: (N,) or (N,3) array of target voxel indices for each edge 152 | """ 153 | edges = np.asarray(edges, dtype=np.int32) 154 | if edges.ndim == 1: 155 | edges = self.unravel_nd(edges, self.edge_shape) 156 | offs = np.eye(3, dtype=np.int32) 157 | src = edges[:, :3] 158 | dst = src + offs[edges[:, -1]] 159 | if ravel: 160 | src = self.ravel_nd(src, self.padded_shape) 161 | dst = self.ravel_nd(dst, self.padded_shape) 162 | return src, dst 163 | 164 | def get_all_edge_vertices(self, ravel: bool = True): 165 | """Find start/end voxels for all possible edges. 166 | 167 | This method is quite a bit faster than 168 | 169 | find_edge_vertices(range(num_edges)) 170 | 171 | because it avoids unravelling. In the current implementation 172 | this method is is not used in favor of `get_all_source_vertices`, 173 | however I leave it here for reference. 174 | 175 | Params: 176 | ravel: Whether to return voxel as flat indices or nd indices. 177 | 178 | Returns: 179 | s: (N,) or (N,3) array of source voxel indices for each edge 180 | t: (N,) or (N,3) array of target voxel indices for each edge 181 | """ 182 | I, J, K = self.edge_shape[:3] 183 | sijk = ( 184 | np.stack( 185 | np.meshgrid( 186 | np.arange(I, dtype=np.int32), 187 | np.arange(J, dtype=np.int32), 188 | np.arange(K, dtype=np.int32), 189 | indexing="ij", 190 | ), 191 | -1, 192 | ) 193 | .reshape(-1, 3) 194 | .repeat(3, 0) 195 | .reshape(-1, 3, 3) 196 | ) 197 | tijk = sijk + np.eye(3, dtype=np.int32).reshape(1, 3, 3) 198 | tijk = tijk.reshape(-1, 3) 199 | sijk = sijk.reshape(-1, 3) 200 | if ravel: 201 | sijk = self.ravel_nd(sijk, self.padded_shape) 202 | tijk = self.ravel_nd(tijk, self.padded_shape) 203 | return sijk, tijk 204 | 205 | def get_all_source_vertices(self): 206 | """Find all edge start voxel indices 207 | 208 | Similar to `get_all_edge_vertices` but does not compute 209 | target voxel indices also does not repeat (x3) the source 210 | voxel indices for each possible edge direction. 211 | 212 | Returns: 213 | s: (N,3) array of source voxel indices for each possible edge 214 | """ 215 | I, J, K = self.edge_shape[:3] 216 | sijk = np.stack( 217 | np.meshgrid( 218 | np.arange(I, dtype=np.int32), 219 | np.arange(J, dtype=np.int32), 220 | np.arange(K, dtype=np.int32), 221 | indexing="ij", 222 | ), 223 | -1, 224 | ).reshape(-1, 3) 225 | return sijk 226 | 227 | def find_voxels_sharing_edge( 228 | self, edges: np.ndarray, ravel: bool = True 229 | ) -> np.ndarray: 230 | """Returns all voxel neighbors sharing the given edge in ccw order. 231 | 232 | Params: 233 | edges: (N,) or (N,4) array of edge indices 234 | ravel: Whether to return voxel as flat indices or nd indices 235 | 236 | Returns: 237 | v: (N,4) or (N,4,3) of voxel indices in ccw order when viewed from 238 | position edge direction. 239 | """ 240 | edges = np.asarray(edges, dtype=np.int32) 241 | if edges.ndim == 1: 242 | edges = self.unravel_nd(edges, self.edge_shape) 243 | voxels = edges[..., :3] 244 | elabels = edges[..., -1] 245 | 246 | neighbors = ( 247 | np.expand_dims(voxels, -2) + Grid.EDGE_VOXEL_OFFSETS[elabels] 248 | ) # (N,4,3) 249 | 250 | edge_mask = (neighbors >= 0) & (neighbors < np.array(self.shape) - 1) 251 | edge_mask = edge_mask.all(-1).all(-1) # All edges that have 4 valid neighbors 252 | neighbors[~edge_mask] = 0 253 | 254 | if ravel: 255 | neighbors = self.ravel_nd( 256 | neighbors.reshape(-1, 3), self.padded_shape 257 | ).reshape(-1, 4) 258 | return neighbors, edge_mask 259 | 260 | def find_voxel_edges(self, voxels: np.ndarray, ravel: bool = True) -> np.ndarray: 261 | """Finds all edges for the given voxels. 262 | 263 | Params: 264 | voxels: (N,) or (N,3) voxel indices. 265 | ravel: Whether to return voxel as flat indices or nd indices 266 | 267 | Returns: 268 | edges: (N,12) or (N,12,4) edge indices. 269 | """ 270 | voxels = np.asarray(voxels, dtype=np.int32) 271 | if voxels.ndim == 1: 272 | voxels = self.unravel_nd(voxels, self.padded_shape) 273 | N = voxels.shape[0] 274 | 275 | voxels = np.expand_dims( 276 | np.concatenate((voxels, np.zeros((N, 1), dtype=np.int32)), -1), -2 277 | ) 278 | edges = voxels + Grid.VOXEL_EDGE_OFFSETS 279 | if ravel: 280 | edges = self.ravel_nd(edges.reshape(-1, 4), self.edge_shape).reshape(-1, 12) 281 | return edges 282 | 283 | def grid_to_data(self, x: np.ndarray) -> np.ndarray: 284 | """Convert coordinates in grid space to data space.""" 285 | return x * self.spacing + self.min_corner 286 | 287 | def data_to_grid(self, x: np.ndarray) -> np.ndarray: 288 | """Convert coordinates in data space to grid space.""" 289 | return (x - self.min_corner) / self.spacing 290 | -------------------------------------------------------------------------------- /sdftoolbox/dual_strategies.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import abc 3 | from typing import Literal, Optional, TYPE_CHECKING 4 | 5 | from .types import float_dtype 6 | from .roots import directional_newton_roots, bisect_roots 7 | 8 | if TYPE_CHECKING: 9 | from .grid import Grid 10 | from .sdfs import SDF 11 | 12 | 13 | class DualVertexStrategy(abc.ABC): 14 | """Base class for methods that compute vertex locations. 15 | 16 | In dual methods one vertex per active voxel is required. 17 | A voxel is active if at least one of 12 edges belonging 18 | to the voxel is intersected by the surface boundary. 19 | """ 20 | 21 | @abc.abstractmethod 22 | def find_vertex_locations( 23 | self, 24 | active_voxels: np.ndarray, 25 | edge_coords: np.ndarray, 26 | node: "SDF", 27 | grid: "Grid", 28 | ) -> np.ndarray: 29 | pass 30 | 31 | 32 | class MidpointVertexStrategy(DualVertexStrategy): 33 | """Computes vertex locations based on voxel centers. 34 | This results in Minecraft-like reconstructions. 35 | """ 36 | 37 | def find_vertex_locations( 38 | self, 39 | active_voxels: np.ndarray, 40 | edge_coords: np.ndarray, 41 | node: "SDF", 42 | grid: "Grid", 43 | ) -> np.ndarray: 44 | del node, edge_coords 45 | sijk = grid.unravel_nd(active_voxels, grid.padded_shape) 46 | return sijk + np.array([[0.5, 0.5, 0.5]], dtype=float_dtype) 47 | 48 | 49 | class NaiveSurfaceNetVertexStrategy(DualVertexStrategy): 50 | """Computes vertex locations based on averaging edge intersection points. 51 | 52 | Each vertex location is chosen to be the average of intersection points 53 | of all active edges that belong to a voxel. 54 | 55 | References: 56 | - Gibson, Sarah FF. "Constrained elastic surface nets: 57 | Generating smooth surfaces from binary segmented data." 58 | Springer, Berlin, Heidelberg, 1998. 59 | - https://0fps.net/2012/07/12/smooth-voxel-terrain-part-2/ 60 | """ 61 | 62 | def find_vertex_locations( 63 | self, 64 | active_voxels: np.ndarray, 65 | edge_coords: np.ndarray, 66 | node: "SDF", 67 | grid: "Grid", 68 | ) -> np.ndarray: 69 | del node 70 | active_voxel_edges = grid.find_voxel_edges(active_voxels) # (M,12) 71 | e = edge_coords[active_voxel_edges] # (M,12,3) 72 | return np.nanmean(e, 1) # (M,3) 73 | 74 | 75 | class DualContouringVertexStrategy(DualVertexStrategy): 76 | """Computes vertex locations based on dual-contouring strategy. 77 | 78 | This method additionally requires intersection normals. The idea 79 | is to find (for each voxel) the location that agrees 'best' with 80 | all intersection points/normals from from surrounding active edges. 81 | 82 | Each interseciton point/normal consitutes a plane in 3d. A location 83 | agrees with it when it lies on (close-to) the plane. A location 84 | that agrees with all planes is considered the best. 85 | 86 | In practice, this can be solved by linear least squares. Consider 87 | an isect location p and isect normal n. Then, we'd like to find 88 | x, such that 89 | 90 | n^T(x-p) = 0 91 | 92 | which we can rearrange as follows 93 | 94 | n^Tx -n^Tp = 0 95 | n^Tx = n^Tp 96 | 97 | and concatenate (for multiple p, n pairs as) into 98 | 99 | Ax = b 100 | 101 | which we solve using least squares. However, one need to ensures that 102 | resulting locations lie within voxels. We do this by biasing the 103 | linear system to the naive SurfaceNets solution using additional 104 | equations of the form 105 | 106 | ei^Tx = bias[0] 107 | ej^Tx = bias[1] 108 | ek^Tx = bias[2] 109 | 110 | wher e(i,j,k) are the canonical unit vectors. 111 | 112 | References: 113 | - Ju, Tao, et al. "Dual contouring of hermite data." 114 | Proceedings of the 29th annual conference on Computer 115 | graphics and interactive techniques. 2002. 116 | """ 117 | 118 | def __init__( 119 | self, 120 | bias_mode: Literal["always", "failed", "disabled"] = "always", 121 | bias_strength: float = 1e-5, 122 | ): 123 | """Initialize the strategy. 124 | 125 | Params: 126 | bias_mode: Determines how bias is applied. `always` applies 127 | biasing towards naive surface net solution unconditionally. `failed` 128 | applies biasing only after solving without bias results in an invalid 129 | point and `disable` never performs biasing. Defaults to always. 130 | bias_strength: Strength of bias compared to compatible normal terms. 131 | clip: Wether to clip the result to voxel or not. Note, when False relaxes 132 | the assumption that only one vertex per cell should be created. In 133 | particular useful when dealing with non-smooth SDFs. 134 | """ 135 | assert bias_mode in ["always", "failed", "disabled"] 136 | self.bias_strength = bias_strength 137 | self.sqrt_bias_strength = np.sqrt(self.bias_strength) 138 | self.bias_mode = bias_mode 139 | 140 | def find_vertex_locations( 141 | self, 142 | active_voxels: np.ndarray, 143 | edge_coords: np.ndarray, 144 | node: "SDF", 145 | grid: "Grid", 146 | ) -> np.ndarray: 147 | sijk = grid.unravel_nd(active_voxels, grid.padded_shape) # (M,3) 148 | active_voxel_edges = grid.find_voxel_edges(active_voxels) # (M,12) 149 | points = edge_coords[active_voxel_edges] # (M,12,3) 150 | normals = node.gradient(grid.grid_to_data(points)) # (M,12,3) 151 | if self.bias_mode != "disabled": 152 | bias_verts = NaiveSurfaceNetVertexStrategy().find_vertex_locations( 153 | active_voxels, edge_coords, node, grid 154 | ) 155 | else: 156 | bias_verts = [None] * len(points) 157 | 158 | # Consider a batched variant using block-diagonal matrices 159 | verts = [] 160 | bias_always = self.bias_mode == "always" 161 | bias_failed = self.bias_mode == "failed" 162 | for off, p, n, bias in zip(sijk, points, normals, bias_verts): 163 | # off: (3,), p: (12,3), n: (12,3), bias: (3,) 164 | q = p - off[None, :] # [0,1) range in each dim 165 | mask = np.isfinite(q).all(-1) # Skip non-active voxel edges 166 | 167 | # Try to solve unbiased 168 | x = self._solve_lst( 169 | q[mask], n[mask], bias=(bias - off) if bias_always else None 170 | ) 171 | if bias_failed and ((x < 0.0).any() or (x > 1.0).any()): 172 | x = self._solve_lst(q[mask], n[mask], bias=(bias - off)) 173 | verts.append(x + off) 174 | return np.array(verts, dtype=float_dtype) 175 | 176 | def _solve_lst( 177 | self, q: np.ndarray, n: np.ndarray, bias: Optional[np.ndarray] 178 | ) -> np.ndarray: 179 | A = n 180 | b = (q[:, None, :] @ n[..., None]).reshape(-1) 181 | if bias is not None and self.bias_strength > 0.0: 182 | C = np.eye(3, dtype=A.dtype) * np.sqrt(self.bias_strength) 183 | d = bias * np.sqrt(self.bias_strength) 184 | A = np.concatenate((A, C), 0) 185 | b = np.concatenate((b, d), 0) 186 | x, res, rank, _ = np.linalg.lstsq(A.astype(float), b.astype(float), rcond=None) 187 | return x.astype(q.dtype) 188 | 189 | 190 | class DualEdgeStrategy(abc.ABC): 191 | """Base class for methods that edge/surface intersections. 192 | 193 | Similar to primal methods, dual methods rely on determining 194 | voxel edges that intersect the boundary of the surface. We 195 | call an edge active if intersects the surface boundary 196 | between the two edge endpoints. 197 | 198 | Each strategy is supposed to compute a scalar `t` for each 199 | edge in question that marks the point of intersection along 200 | the edge. The method will be called only for active edges. 201 | The returned value `t` is supposed to be in [0,1), however 202 | value outside the range might be excepted as well when relaxed 203 | edge intersections are enabled at the contouring algorithm. 204 | """ 205 | 206 | @abc.abstractmethod 207 | def find_edge_intersections( 208 | self, 209 | src_ijk: np.ndarray, 210 | src_sdf: np.ndarray, 211 | dst_ijk: np.ndarray, 212 | dst_sdf: np.ndarray, 213 | edge_dir_index: int, 214 | edge_dir: np.ndarray, 215 | node: "SDF", 216 | grid: "Grid", 217 | ) -> np.ndarray: 218 | pass 219 | 220 | 221 | class LinearEdgeStrategy(DualEdgeStrategy): 222 | """Determine edge intersections by a linear equation. 223 | 224 | This is the most commonly found method for determining the intersection 225 | between edges and surface boundaries. It assumes that a) the surface is 226 | smooth and b) edges are 'infinitely' small compared to surface shape. If 227 | both promises hold, the surface will be linear close to the edge, 228 | and hence an intersection is given by the root of straight line through 229 | (0,sdf_src) and (1, sdf_dst). 230 | 231 | Note, for the line to have a root within edge bounds, the sign of 232 | sdf_src and sdf_dst must differ. We don't check this explicitly, because 233 | the method is allowed to return t values outside of [0,1). 234 | """ 235 | 236 | def find_edge_intersections( 237 | self, 238 | src_ijk: np.ndarray, 239 | src_sdf: np.ndarray, 240 | dst_ijk: np.ndarray, 241 | dst_sdf: np.ndarray, 242 | edge_dir_index: int, 243 | edge_dir: np.ndarray, 244 | node: "SDF", 245 | grid: "Grid", 246 | ) -> np.ndarray: 247 | del src_ijk, dst_ijk, edge_dir, node, grid, edge_dir_index 248 | return LinearEdgeStrategy.compute_linear_roots(src_sdf, dst_sdf) 249 | 250 | @staticmethod 251 | def compute_linear_roots(src_sdf: np.ndarray, dst_sdf: np.ndarray) -> np.ndarray: 252 | return -src_sdf / (dst_sdf - src_sdf) 253 | 254 | 255 | class NewtonEdgeStrategy(DualEdgeStrategy): 256 | """Determine edge intersections by Newton root finding. 257 | 258 | If the assumptions of LinearEdgeStrategy do not hold, but 259 | the gradient of the SDF still holds information along the 260 | edge direction one can find the root by performing iterative 261 | directional Newton root finding. 262 | """ 263 | 264 | def __init__(self, max_steps: int = 10, eps=1e-8) -> None: 265 | super().__init__() 266 | self.max_steps = max_steps 267 | self.eps = eps 268 | 269 | def find_edge_intersections( 270 | self, 271 | src_ijk: np.ndarray, 272 | src_sdf: np.ndarray, 273 | dst_ijk: np.ndarray, 274 | dst_sdf: np.ndarray, 275 | edge_dir_index: int, 276 | edge_dir: int, 277 | node: "SDF", 278 | grid: "Grid", 279 | ) -> np.ndarray: 280 | del dst_ijk 281 | 282 | # We use linearly interpolated start points 283 | tlinear = LinearEdgeStrategy.compute_linear_roots(src_sdf, dst_sdf) 284 | x_grid = src_ijk + edge_dir[None, :] * tlinear[:, None] 285 | x_data = grid.grid_to_data(x_grid) 286 | 287 | # Perform the optimization 288 | x_data = directional_newton_roots( 289 | node, x_data, edge_dir, max_steps=self.max_steps, eps=self.eps 290 | ) 291 | x_grid = grid.data_to_grid(x_data) 292 | 293 | # Compute updated results. 294 | t = (x_grid - src_ijk)[:, edge_dir_index] 295 | return t 296 | 297 | 298 | class BisectionEdgeStrategy(DualEdgeStrategy): 299 | """Determine edge intersections by bisection root finding. 300 | 301 | If the assumptions of LinearEdgeStrategy do not hold and 302 | the gradient in the direction of the edge does not contain 303 | information, this method can be used to determine a more 304 | accurate intersection point. 305 | """ 306 | 307 | def __init__( 308 | self, 309 | max_steps: int = 10, 310 | eps=1e-8, 311 | linear_interp: bool = False, 312 | ) -> None: 313 | self.max_steps = max_steps 314 | self.eps = eps 315 | self.linear_interp = linear_interp 316 | 317 | def find_edge_intersections( 318 | self, 319 | src_ijk: np.ndarray, 320 | src_sdf: np.ndarray, 321 | dst_ijk: np.ndarray, 322 | dst_sdf: np.ndarray, 323 | edge_dir_index: int, 324 | edge_dir: int, 325 | node: "SDF", 326 | grid: "Grid", 327 | ) -> np.ndarray: 328 | 329 | # We use linearly interpolated start points 330 | tlinear = LinearEdgeStrategy.compute_linear_roots(src_sdf, dst_sdf) 331 | x_grid = src_ijk + edge_dir[None, :] * tlinear[:, None] 332 | x_data0 = grid.grid_to_data(x_grid) 333 | 334 | # Perform the optimization 335 | x_data = bisect_roots( 336 | node, 337 | grid.grid_to_data(src_ijk), 338 | grid.grid_to_data(dst_ijk), 339 | x_data0, 340 | max_steps=self.max_steps, 341 | eps=self.eps, 342 | linear_interp=self.linear_interp, 343 | ) 344 | x_grid = grid.data_to_grid(x_data) 345 | 346 | # Compute updated results. 347 | t = (x_grid - src_ijk)[:, edge_dir_index] 348 | 349 | return t 350 | -------------------------------------------------------------------------------- /doc/doc_plots.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import matplotlib.colors 3 | import numpy as np 4 | 5 | import sdftoolbox 6 | from sdftoolbox.roots import directional_newton_roots, bisect_roots 7 | 8 | 9 | def plot_frames(): 10 | fig, ax = sdftoolbox.plotting.create_figure("ortho") 11 | 12 | ijk = np.stack(np.meshgrid(range(3), range(3), range(3), indexing="ij"), -1) 13 | colors = np.ones((3, 3, 3, 4)) 14 | colors[:] = matplotlib.colors.to_rgba(next(ax._get_lines.prop_cycler)["color"]) 15 | colors[:-1, :-1, :-1] = matplotlib.colors.to_rgba( 16 | next(ax._get_lines.prop_cycler)["color"] 17 | ) 18 | 19 | ax.scatter( 20 | ijk[..., 0], 21 | ijk[..., 1], 22 | ijk[..., 2], 23 | color=colors.reshape(-1, 4), 24 | ) 25 | ax.plot( 26 | [ijk[0, 0, 0, 0], ijk[1, 0, 0, 0]], 27 | [ijk[0, 0, 0, 1], ijk[1, 0, 0, 1]], 28 | [ijk[0, 0, 0, 2], ijk[1, 0, 0, 2]], 29 | ) 30 | 31 | ax.plot([0, 2], [0, 0], [0, 0], c="k", lw=0.5, linestyle="-") 32 | ax.plot([0, 0], [0, 2], [0, 0], c="k", lw=0.5, linestyle="-") 33 | ax.plot([0, 0], [0, 0], [0, 2], c="k", lw=0.5, linestyle="-") 34 | ax.plot([0, 2], [0, 0], [2, 2], c="k", lw=0.5, linestyle="-") 35 | ax.plot([0, 0], [0, 2], [2, 2], c="k", lw=0.5, linestyle="-") 36 | ax.plot([2, 2], [0, 0], [0, 2], c="k", lw=0.5, linestyle="-") 37 | ax.plot([2, 2], [0, 2], [2, 2], c="k", lw=0.5, linestyle="-") 38 | ax.plot([0, 0], [2, 2], [0, 2], c="k", lw=0.5, linestyle="-") 39 | ax.plot([0, 2], [2, 2], [2, 2], c="k", lw=0.5, linestyle="-") 40 | ax.plot([0, 2], [2, 2], [0, 0], c="k", lw=0.5, linestyle="--") 41 | ax.plot([2, 2], [0, 2], [0, 0], c="k", lw=0.5, linestyle="--") 42 | ax.plot([2, 2], [2, 2], [0, 2], c="k", lw=0.5, linestyle="--") 43 | 44 | ax.quiver(0, 0, 0, 1, 0, 0, length=1.0, arrow_length_ratio=0.1, color="red") 45 | ax.text(0.7, 0.1, 0.0, "i", color="k") 46 | 47 | ax.quiver(0, 0, 0, 0, 1, 0, length=1.0, arrow_length_ratio=0.1, color="green") 48 | ax.text(0.1, 0.7, 0.0, "j", color="k") 49 | 50 | ax.quiver(0, 0, 0, 0, 0, 1, length=1.0, arrow_length_ratio=0.1, color="blue") 51 | ax.text(0.05, 0.0, 0.7, "k", color="k") 52 | sdftoolbox.plotting.setup_axes( 53 | ax, (-0.5, -0.5, -0.5), (2.5, 2.5, 2.5), azimuth=-121, elevation=32 54 | ) 55 | plt.tight_layout() 56 | fig.savefig("doc/frames.svg") 57 | plt.close(fig) 58 | 59 | 60 | def plot_edges(): 61 | fig, ax = sdftoolbox.plotting.create_figure("ortho") 62 | 63 | ijk = np.stack(np.meshgrid(range(3), range(3), range(3), indexing="ij"), -1) 64 | 65 | ax.scatter(ijk[..., 0], ijk[..., 1], ijk[..., 2], label="sample coords") 66 | 67 | ax.quiver(1, 0, 0, 1, 0, 0, length=1.0, arrow_length_ratio=0.1, color="purple") 68 | ax.text(1.5, -0.1, -0.1, "(1,0,0,0)", color="k") 69 | 70 | ax.quiver(0, 1, 1, 0, 0, 1, length=1.0, arrow_length_ratio=0.1, color="purple") 71 | ax.text(0, 2.1, 1.5, "(0,1,1,2)", color="k") 72 | 73 | ax.quiver(2, 0, 2, 0, 1, 0, length=1.0, arrow_length_ratio=0.1, color="purple") 74 | ax.text(2, 0.5, 2.1, "(2,0,2,1)", color="k") 75 | 76 | sdftoolbox.plotting.setup_axes( 77 | ax, (-0.5, -0.5, -0.5), (2.5, 2.5, 2.5), azimuth=-110, elevation=38 78 | ) 79 | plt.legend() 80 | plt.tight_layout() 81 | fig.savefig("doc/edges.svg") 82 | plt.close(fig) 83 | 84 | 85 | def plot_edge_strategies(): 86 | def compute_linear_isect(node, edge): 87 | edge_sdf = node.sample(edge) 88 | tlin = sdftoolbox.LinearEdgeStrategy.compute_linear_roots( 89 | edge_sdf[0:1], edge_sdf[1:2] 90 | ).squeeze() 91 | xlin = (1 - tlin) * edge[0] + tlin * edge[1] 92 | return xlin 93 | 94 | def compute_newton_isect(node, edge): 95 | dir = edge[1] - edge[0] 96 | dir = dir / np.linalg.norm(dir) 97 | x = compute_linear_isect(node, edge) 98 | x = directional_newton_roots(node, x[None, :], dir[None, :]) 99 | return x.squeeze() 100 | 101 | def compute_bisect_isect(node, edge): 102 | x = compute_linear_isect(node, edge) 103 | x = bisect_roots(node, edge[0:1], edge[1:2], x[None, :], max_steps=20) 104 | return x.squeeze() 105 | 106 | def plot_edge(ax, edge, isect): 107 | ax.plot(edge[:, 0], edge[:, 1], color="k", linewidth=0.5) 108 | ax.scatter(edge[:, 0], edge[:, 1], color="k", s=20, zorder=3) 109 | ax.scatter( 110 | isect[0], 111 | isect[1], 112 | s=20, 113 | marker="o", 114 | zorder=3, 115 | facecolors="none", 116 | edgecolors="r", 117 | ) 118 | 119 | # Sphere 120 | fig, axs = plt.subplots(1, 3, figsize=plt.figaspect(0.3333)) 121 | sphere = sdftoolbox.sdfs.Sphere.create(radius=1.0) 122 | sphere_grid = sdftoolbox.sdfs.Grid( 123 | (100, 100, 1), min_corner=(0.0, 0.0, 0.0), max_corner=(1.1, 1.1, 0.0) 124 | ) 125 | xyz = sphere_grid.xyz 126 | sdf = sphere.sample(xyz) 127 | 128 | edge1 = np.array([[0.622, 0.674, 0], [0.810, 0.753, 0]]) 129 | edge2 = np.array([[0.5, 0.2, 0], [0.5, 0.995, 0]]) 130 | 131 | cs = axs[0].contour(xyz[..., 0, 0], xyz[..., 0, 1], sdf[..., 0]) 132 | axs[0].clabel(cs, inline=True, fontsize=10) 133 | cs = axs[1].contour(xyz[..., 0, 0], xyz[..., 0, 1], sdf[..., 0]) 134 | axs[1].clabel(cs, inline=True, fontsize=10) 135 | cs = axs[2].contour(xyz[..., 0, 0], xyz[..., 0, 1], sdf[..., 0]) 136 | axs[2].clabel(cs, inline=True, fontsize=10) 137 | axs[0].set_title("Linear") 138 | axs[1].set_title("Directional Newton") 139 | axs[2].set_title("Bisection") 140 | plot_edge(axs[0], edge1, compute_linear_isect(sphere, edge1)) 141 | plot_edge(axs[0], edge2, compute_linear_isect(sphere, edge2)) 142 | plot_edge(axs[1], edge1, compute_newton_isect(sphere, edge1)) 143 | plot_edge(axs[1], edge2, compute_newton_isect(sphere, edge2)) 144 | plot_edge(axs[2], edge1, compute_bisect_isect(sphere, edge1)) 145 | plot_edge(axs[2], edge2, compute_bisect_isect(sphere, edge2)) 146 | plt.tight_layout() 147 | fig.savefig("doc/edge_strategies_sphere.svg") 148 | plt.close(fig) 149 | 150 | # Box 151 | fig, axs = plt.subplots(1, 3, figsize=plt.figaspect(0.3333)) 152 | box = sdftoolbox.sdfs.Box() 153 | box_grid = sdftoolbox.sdfs.Grid( 154 | (100, 100, 1), min_corner=(0.0, 0.0, 0.0), max_corner=(1.1, 1.1, 0.0) 155 | ) 156 | xyz = box_grid.xyz 157 | sdf = box.sample(xyz) 158 | 159 | edge1 = np.array([[0.448, 0.039, 0], [0.448, 0.649, 0]]) 160 | edge2 = np.array([[0.283, 0.259, 0], [0.866, 0.757, 0]]) 161 | 162 | cs = axs[0].contour(xyz[..., 0, 0], xyz[..., 0, 1], sdf[..., 0]) 163 | axs[0].clabel(cs, inline=True, fontsize=10) 164 | cs = axs[1].contour(xyz[..., 0, 0], xyz[..., 0, 1], sdf[..., 0]) 165 | axs[1].clabel(cs, inline=True, fontsize=10) 166 | cs = axs[2].contour(xyz[..., 0, 0], xyz[..., 0, 1], sdf[..., 0]) 167 | axs[2].clabel(cs, inline=True, fontsize=10) 168 | axs[0].set_title("Linear") 169 | axs[1].set_title("Directional Newton") 170 | axs[2].set_title("Bisection") 171 | plot_edge(axs[0], edge1, compute_linear_isect(box, edge1)) 172 | plot_edge(axs[0], edge2, compute_linear_isect(box, edge2)) 173 | plot_edge(axs[1], edge1, compute_newton_isect(box, edge1)) 174 | plot_edge(axs[1], edge2, compute_newton_isect(box, edge2)) 175 | plot_edge(axs[2], edge1, compute_bisect_isect(box, edge1)) 176 | plot_edge(axs[2], edge2, compute_bisect_isect(box, edge2)) 177 | plt.tight_layout() 178 | fig.savefig("doc/edge_strategies_box.svg") 179 | plt.close(fig) 180 | 181 | 182 | def plot_vertex_strategies(): 183 | # Box in canonical orientation 184 | 185 | def plot_boxes(boxes, grid): 186 | fig = plt.figure(figsize=plt.figaspect(0.3333)) 187 | ax0 = fig.add_subplot( 188 | 1, 3, 1, projection="3d", proj_type="persp", computed_zorder=False 189 | ) 190 | ax1 = fig.add_subplot( 191 | 1, 3, 2, projection="3d", proj_type="persp", computed_zorder=False 192 | ) 193 | ax2 = fig.add_subplot( 194 | 1, 3, 3, projection="3d", proj_type="persp", computed_zorder=False 195 | ) 196 | 197 | # for the contour plot 198 | minc = grid.min_corner.copy() 199 | minc[2] = 0 200 | maxc = grid.max_corner.copy() 201 | maxc[2] = 0 202 | xyz = sdftoolbox.sdfs.Grid((100, 100, 1), min_corner=minc, max_corner=maxc).xyz 203 | sdf = boxes.sample(xyz) 204 | _ = ax0.contour( 205 | xyz[..., 0, 0], 206 | xyz[..., 0, 1], 207 | sdf[..., 0], 208 | zdir="z", 209 | offset=0, 210 | levels=[0], 211 | colors="purple", 212 | ) 213 | _ = ax1.contour( 214 | xyz[..., 0, 0], 215 | xyz[..., 0, 1], 216 | sdf[..., 0], 217 | zdir="z", 218 | offset=0, 219 | levels=[0], 220 | colors="purple", 221 | ) 222 | _ = ax2.contour( 223 | xyz[..., 0, 0], 224 | xyz[..., 0, 1], 225 | sdf[..., 0], 226 | zdir="z", 227 | offset=0, 228 | levels=[0], 229 | colors="purple", 230 | ) 231 | 232 | verts0, faces0 = sdftoolbox.dual_isosurface( 233 | boxes, grid, vertex_strategy=sdftoolbox.MidpointVertexStrategy() 234 | ) 235 | verts1, faces1 = sdftoolbox.dual_isosurface( 236 | boxes, grid, vertex_strategy=sdftoolbox.NaiveSurfaceNetVertexStrategy() 237 | ) 238 | verts2, faces2 = sdftoolbox.dual_isosurface( 239 | boxes, 240 | grid, 241 | vertex_strategy=sdftoolbox.DualContouringVertexStrategy(), 242 | edge_strategy=sdftoolbox.BisectionEdgeStrategy(), 243 | ) 244 | sdftoolbox.plotting.setup_axes(ax0, grid.min_corner, grid.max_corner) 245 | sdftoolbox.plotting.setup_axes(ax1, grid.min_corner, grid.max_corner) 246 | sdftoolbox.plotting.setup_axes(ax2, grid.min_corner, grid.max_corner) 247 | ax0.set_title("sdftoolbox.MidpointVertexStrategy") 248 | ax1.set_title("sdftoolbox.NaiveSurfaceNetVertexStrategy") 249 | ax2.set_title("sdftoolbox.DualContouringVertexStrategy") 250 | sdftoolbox.plotting.plot_mesh(ax0, verts0, faces0) 251 | sdftoolbox.plotting.plot_mesh(ax1, verts1, faces1) 252 | sdftoolbox.plotting.plot_mesh(ax2, verts2, faces2) 253 | return fig, (ax0, ax1, ax2) 254 | 255 | grid = sdftoolbox.sdfs.Grid( 256 | (10, 10, 10), min_corner=(-1.1, -1.1, -1.1), max_corner=(1.1, 1.1, 1.1) 257 | ) 258 | # Canonical aligned boxes 259 | boxes = sdftoolbox.sdfs.Union( 260 | [ 261 | sdftoolbox.sdfs.Box().transform(trans=(0.5, 0.5, 0.5)), 262 | sdftoolbox.sdfs.Box(), 263 | ] 264 | ).transform(trans=(-0.25, -0.25, -0.25)) 265 | fig, axs = plot_boxes(boxes, grid) 266 | sdftoolbox.plotting.generate_rotation_gif( 267 | "doc/vertex_strategies_aligned_box.gif", fig, axs, total_time=10 268 | ) 269 | plt.close(fig) 270 | 271 | # Rotate boxes 272 | boxes = sdftoolbox.sdfs.Union( 273 | [ 274 | sdftoolbox.sdfs.Box().transform(trans=(0.5, 0.5, 0.5)), 275 | sdftoolbox.sdfs.Box(), 276 | ] 277 | ).transform(trans=(-0.25, -0.25, -0.25), rot=(1, 1, 1, np.pi / 4)) 278 | fig, axs = plot_boxes(boxes, grid) 279 | sdftoolbox.plotting.generate_rotation_gif( 280 | "doc/vertex_strategies_rot_box.gif", fig, axs, total_time=10 281 | ) 282 | plt.close(fig) 283 | 284 | 285 | def plot_edge_strategies_dual_contouring_rot_cube(): 286 | fig = plt.figure(figsize=plt.figaspect(0.3333)) 287 | ax0 = fig.add_subplot( 288 | 1, 3, 1, projection="3d", proj_type="persp", computed_zorder=False 289 | ) 290 | ax1 = fig.add_subplot( 291 | 1, 3, 2, projection="3d", proj_type="persp", computed_zorder=False 292 | ) 293 | ax2 = fig.add_subplot( 294 | 1, 3, 3, projection="3d", proj_type="persp", computed_zorder=False 295 | ) 296 | 297 | grid = sdftoolbox.sdfs.Grid( 298 | (5, 5, 5), min_corner=(-1.0, -1.0, -1.0), max_corner=(1.0, 1.0, 1.0) 299 | ) 300 | # Canonical aligned boxes 301 | box = sdftoolbox.sdfs.Box((1.2, 1.2, 1.2)).transform(rot=(1, 0, 0, np.pi / 4)) 302 | 303 | verts0, faces0, debug0 = sdftoolbox.dual_isosurface( 304 | box, 305 | grid, 306 | vertex_strategy=sdftoolbox.DualContouringVertexStrategy(), 307 | edge_strategy=sdftoolbox.LinearEdgeStrategy(), 308 | return_debug_info=True, 309 | ) 310 | verts1, faces1, debug1 = sdftoolbox.dual_isosurface( 311 | box, 312 | grid, 313 | vertex_strategy=sdftoolbox.DualContouringVertexStrategy(), 314 | edge_strategy=sdftoolbox.NewtonEdgeStrategy(), 315 | return_debug_info=True, 316 | ) 317 | verts2, faces2, debug2 = sdftoolbox.dual_isosurface( 318 | box, 319 | grid, 320 | vertex_strategy=sdftoolbox.DualContouringVertexStrategy(), 321 | edge_strategy=sdftoolbox.BisectionEdgeStrategy(), 322 | return_debug_info=True, 323 | ) 324 | 325 | def plot_debug(ax, debug): 326 | isect = grid.grid_to_data(debug.edges_isect_coords[debug.edges_active_mask]) 327 | isect_n = box.gradient(isect, normalize=True) 328 | 329 | active_src, active_dst = grid.find_edge_vertices( 330 | np.where(debug.edges_active_mask)[0], ravel=False 331 | ) 332 | active_src = grid.grid_to_data(active_src) 333 | active_dst = grid.grid_to_data(active_dst) 334 | sdftoolbox.plotting.plot_edges( 335 | ax, active_src, active_dst, color="k", linewidth=0.5 336 | ) 337 | sdftoolbox.plotting.plot_normals(ax, isect, isect_n, color="k") 338 | 339 | sdftoolbox.plotting.setup_axes(ax0, grid.min_corner, grid.max_corner) 340 | sdftoolbox.plotting.setup_axes(ax1, grid.min_corner, grid.max_corner) 341 | sdftoolbox.plotting.setup_axes(ax2, grid.min_corner, grid.max_corner) 342 | ax0.set_title("DC + sdftoolbox.LinearEdgeStrategy") 343 | ax1.set_title("DC + sdftoolbox.NewtonEdgeStrategy") 344 | ax2.set_title("DC + sdftoolbox.BisectionEdgeStrategy") 345 | sdftoolbox.plotting.plot_mesh(ax0, verts0, faces0, alpha=0.5) 346 | sdftoolbox.plotting.plot_mesh(ax1, verts1, faces1, alpha=0.5) 347 | sdftoolbox.plotting.plot_mesh(ax2, verts2, faces2, alpha=0.5) 348 | plot_debug(ax0, debug0) 349 | plot_debug(ax1, debug1) 350 | plot_debug(ax2, debug2) 351 | 352 | plt.show() 353 | # sdftoolbox.plotting.generate_rotation_gif( 354 | # "doc/vertex_strategies_aligned_box.gif", fig, axs, total_time=10 355 | # ) 356 | # plt.close(fig) 357 | 358 | 359 | if __name__ == "__main__": 360 | 361 | plot_frames() 362 | plot_edges() 363 | plot_edge_strategies() 364 | plot_vertex_strategies() 365 | # plot_edge_strategies_dual_contouring_rot_cube() 366 | -------------------------------------------------------------------------------- /doc/SDF.md: -------------------------------------------------------------------------------- 1 | # SDF Documentation 2 | 3 | The aim of this document is to provide a documentation of the methods implemented by this library. We limit the discussion to $\mathbb{R}^3$ Euclidean space. 4 | 5 | _Note: In case math rendering looks odd, you might want to read this page in raw mode._ 6 | 7 | ## Signed distance fields 8 | 9 | Volumentric data is commonly found in many scientific, engineering, and medical applications. Such volumentric data can be efficiently encoded using [signed distance fields](https://en.wikipedia.org/wiki/Signed_distance_function) (SDFs). A SDF is a vector valued scalar function, $f(x)$, that determines the _signed_ distance from any location to the boundary of the surface encoded by the SDF. By the properties of signed distance, one may classify a location by looking at its SDF value 10 | 11 | $$ 12 | \begin{cases} 13 | f(x)>0 & \textrm{outside} \\ 14 | f(x)=0 & \textrm{on the boundary} \\ 15 | f(x)<0 & \textrm{inside} 16 | \end{cases} 17 | $$ 18 | 19 | Its useful to note that the tuple $(x,|f(x)|)$ describes a sphere centered at $x$ that touches the closest surface boundary. Frankly, it does not tell you the contact location, only the distance. 20 | 21 | Still, this property gives raise to efficient ray maching schemes for visualizing SDF volumes. Another useful property of SDFs: the gradient $\nabla_x f(x)$ points into the direction of fastest increase of signed distance. 22 | 23 | For many primitive shapes in $\mathbb{R}^3$ analytic SDF representations are known. Additionally, one can derive modifiers, $g(x, f)$, that transform SDF in useful ways. Modifiers include rigid transformation, uniform scaling, boolean operations, repetition, displacements and many more. See [[2]](#2) for detailed information. 24 | 25 | ## Isosurface extraction 26 | 27 | Isosurface extraction is the task of finding a suitable tesselation of the boundary (or any constant offset value) of a SDF. The methods considered in this library rely a regular SDF sampling volume from which the resulting mesh is generated. The two schemes that dominate the isosurface extraction field differ in the way they generate the tesselated topology. The following table lists the differences: 28 | 29 | 30 | 31 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
32 | Intersecting grid element 33 |
MethodEdgeFaceVoxel
PrimalVertexEdgeFace(s)
DualFaceEdgeVertex
53 | 54 | The table above lists, for each type of grid element that intersects the boundary of the SDF surface, which topological element is generated using either primary or dual approaches. For example, first row, second column means that in primal methods a vertex is created for each sampling edge that crosses the SDF boundary. See [[1]](#1) for a more elaborate discussion. 55 | 56 | ## Coordinate systems 57 | 58 | For detailed discussions we need to define the set of coordinate systems involved. These coordinate systems match the implementation of the library. 59 | 60 | The following image shows a sampling grid of `(3,3,3)` (blue) points. The sampling coordinates are with respect to the data coordinate system (x,y,z). The sampling points are indexed by a grid coordinate system (i,j,k) that has its origin at the mininium sampling point. Each voxel in the grid is indexed by the integer grid coordinates of its minimum (i,j,k) corner. Note that the next voxel in the i-direction of voxel (i,j,k) is (i+1,j,k). 61 | 62 | 63 | 64 | We index edges by its source voxel index plus a label `e` $\in \{0,1,2\}$ that defines the positive edge direction. The plot below highlights a few edges. 65 | 66 | 67 | 68 | Having three (forward) edges per voxel index allows us to easily enumerate all edges without duplicates and without missing any edges. Note, at the outer positive border faces we get a set of invalid edges (for example `(2,0,2,0)` is invalid, while `(2,0,2,1)` is valid). 69 | 70 | ## Dual isosurface extraction 71 | 72 | This library implements a generic dual isosurface extraction method based. This blueprint allows the user to set different behavioral aspects to implement varios approaches (or hybrids thereof) proposed in literature. 73 | 74 | Given a SDF and grid defining the sampling locations, the basic dual isosurface algorithm works as follows 75 | 76 | 1. Active edges: For each edge in the sampling grid, determine if it intersects the boundary of the SDF. We call those edges with intersections _active_ edges. 77 | 1. Edge intersection: For each active edge find the intersection point with the boundary of the surface along the edge. 78 | 1. Vertex placement: For each grid (active) voxel with at least one active edge, determine a single vertex location. 79 | 1. Face generation: For each active edge create a quadliteral connecting the vertices of the four active voxels sharing this active edge. 80 | 81 | See [[3]](#3),[[1]](#1) for more information. 82 | 83 | The library implements this recipe in vectorized form. That is, all steps of the algorithms are capable to work with multiple elements at once. This allows for a better resource usage and generally speeds up algorithmic runtime dramatically. It is also the reason that you will hardly find for-loops sprinkled all over the code. 84 | 85 | The recipe above gives raise to different behavioral aspects that are implemented as exchangable modules: 86 | 87 | - _edge strategies_: determines the specific location of the surface intersection along an active edge. 88 | - _vertex strategies_: determines the way the vertex position is calculated for all active voxels. 89 | 90 | ### Edge strategies 91 | 92 | Edge strategies implement different methods to determine the edge/surface crossing. The following strategies are implemented 93 | 94 | #### Linear (single-step) 95 | 96 | This method determines the intersection by finding the root of a linear equation guided by the SDF values at the two edge endpoints. This is most commonly found method in literature. It makes the following two assumptions 97 | 98 | 1. Surface Smoothness: the SDF is assumed to be smooth 99 | 1. Small edges: the edge lengths are supposed to be small compared to size of the shape of the SDF 100 | 101 | Together, this two assumptions lead to linearity of the surface close to edges. Hence, modelling the surface boundary using a linear equation leads to accurate estimations. Also, in case you do not have access to analytic SDFs (e.g discretized volume of SDF values) it is the best you can do. 102 | 103 | #### Newton (iterative) 104 | 105 | If the assumptions of the linear strategy are wrong, this leads to misplaced intersections that affect the quality of the final mesh. If you happen to have access to an analytic SDF, you might do better: We drop the assumption of surface linearity and instead find the root of the SDF along the edge iteratively. One algorithm with quadric convergence is Newton's method (it requires access to the gradient of the SDF). For our usecase (vector valued scalar function) we need a variant of it, the so called directional Newton method. 106 | 107 | #### Bisection (iterative) 108 | 109 | The bisection method is useful when a) linearity is not given, b) you have access to an analytic SDF and c) the gradient does not convey information along the edge direction (e.g. for some points in the SDF of a box). 110 | 111 | #### Edge strategies evaluation 112 | 113 | Here is a diagram comparing the edge strategies on a spherical cross-section. 114 | 115 |
116 | 117 |
Comparison of different edge intersection strategies on the cross section of an analytic sphere SDF. Each plot shows the same two edges and marks the intersection point (red circle) as determined by the respective method.
118 | 119 |
120 |
121 | 122 | One notices, that for the linear estimator only the smaller edge seems to yield an accurate fit. For the larger edge, the main assumptions of the linear method break and hence the estimated root is off. Newtons method as well as the bisection method do not expose this issue at the cost of additional computational steps. 123 | 124 | Shown below, is a similar plot for the cross section of a box. 125 | 126 |
127 | 128 |
Comparison of different edge intersection strategies on the cross section of an analytic box SDF. Each plot shows the same two edges and marks the intersection point (red circle) as determined by the respective method.
129 |
130 |
131 | 132 | The linear method fails for both edges because its main assumptions are violated. For Newton's method, the intersection for only one of the edges is computed correctly. No intersection is found for the other edge, since the gradient is orthogonal to the edge direction (no information along the edge dir). Only the bisection method is capable for producing an accurate result for both cases. 133 | 134 | ### Vertex strategies 135 | 136 | These strategies determine the final vertex locations active voxels. 137 | 138 | #### Midpoint 139 | 140 | The simplest strategy that places the vertex in the center the voxel. The resulting meshes are Mincraft-like box worlds. 141 | 142 | #### (Naive) SurfaceNets 143 | 144 | Naive SurfaceNets [[1]](#1) determine the the voxel's vertex location as the centroid of all 12 active edge intersection points. This strategy works well in practice but has the following downsides: a) it cannot reproduce sharp features like corners (like primal Marching Cubes [[4]](#4)) and b) its prone to shrink the surface in regions of sharp 145 | features. 146 | 147 | #### Dual Contouring 148 | 149 | Dual Contouring [[5]](#5) attempts to restore sharp features by determining the vertex position as the point that has the maximum support from all active edges of the voxel. Consider the plane formed by an active edge intersection point $q_i$ and its associated normal $n_i$. Any vertex location that is on that plane is compatible with it. In general, more than one plane is active for each voxel and point of maximum support is the one that lies on all the planes. This gives raise to a system of linear equations (each of the form $n_i^Tx=n_i^Tq_i$ for the unknown point $x$), for which we find a least squares solution in practice. 150 | 151 | Additionally, many edge configurations convey too little information to uniquely determine the location (as an example consider a xy-plane intersecting the four k-directed voxel edges). These configurations lead to an underdetermined system of linear equations. From a geometrical standpoint only the intersection of at least three planes (a corner) will uniquely determine the position of the vertex. In terms of the null-space, we can see that in the case of a single (independent) plane we end up with two dimensional null-space (all points in the plane). For two independent equations, we end up with one dimensional null-space (all points along the edge). For more than three-planes we resort to a least squares solution that minimizes $\sum_i (n_i^Tx - n_i^Tq_i)^2$. 152 | 153 | To deal null-spaces of dimension > 0, we add additional equations (multi objective linear least squares) that directly encode a preferred vertex location. These take the following $e_k^Tx =b_k$ for $k \in \{0,1,2\}$ where $e_k$ is the k-th canonical unit direction and $b$ is the location we bias towards. We give these additional equations little weight, so that they only take over when the system is otherwise truly underdetermined. 154 | 155 | #### Vertex strategies evaluation 156 | 157 | The following plot compares the three vertex placement strategies using the SDF of a union of two (offsetted) axis aligned boxes given by 158 | 159 | ```python 160 | boxes = sdftoolbox.sdfs.Union([ 161 | sdftoolbox.sdfs.Box().transform(trans=(0.5, 0.5, 0.5)), 162 | sdftoolbox.sdfs.Box(), 163 | ]).transform(trans=(-0.25, -0.25, -0.25)) 164 | ``` 165 | 166 | We use a low resolution grid of resolution `10x10x10`. 167 | 168 |
169 | 170 |
Comparison of different vertex placement strategies using an analytic SDF formed by two offsetted but axis aligned boxes. Each plot shows the isosurface extraction result of the corresponding method labelled on top of the plot. For orientation, the true isocontour of the cross section at z=0 is shown in purple. 171 |
172 |
173 |
174 | 175 | Since the boxes are aligned with the world coordinate system, the midpoint strategy generates visually pleasing reconstruction. However, due to the placement of the vertices in the voxel centers, the resulting surface model has too little volume. Similarily, the naive SurfaceNets variant deforms the shape of the model due to a contraction of the average. This causes a loss of sharp features and a shape volume that is too small. Only the Dual Contouring strategy is capable of reconstructing sharp features and placing the vertices at locations that give rise to a volumetric matching reconstruction. 176 | 177 | Shown below are the reconstructions of the same boxes, but this time rotated around the axis `(1,1,1)` by 45°. 178 | 179 |
180 | 181 |
Comparison of different vertex placement strategies using an analytic SDF formed by two offsetted and rotated boxes. Each plot shows the isosurface extraction result of the corresponding method labelled on top of the plot. For orientation, the true isocontour of the cross section at z=0 is shown in purple. 182 |
183 |
184 |
185 | 186 | This time the midpoint placement strategy fails to capture the shape of the object (like aliasing effects in rendering). The naive SurfaceNets method has the same issues as in the aligned case (non sharp features, volume contraction). The Dual Contouring method manages to capture the shape correctly but induces a few non-manifold vertices (hard to see from the plot). 187 | 188 | ## Plots 189 | 190 | All plots are generated by [doc_plots.py](doc_plots.py). 191 | 192 | ## References 193 | 194 | - [1] 195 | mikolalysenko. https://0fps.net/2012/07/12/smooth-voxel-terrain-part-2/ 196 | - [2] 197 | Inigio Quilez's. 198 | https://iquilezles.org/articles/distfunctions/ 199 | - [3] Gibson, Sarah FF. "Constrained elastic surface nets: Generating smooth surfaces from binary segmented data." International Conference on Medical Image Computing and Computer-Assisted Intervention. Springer, Berlin, Heidelberg, 1998. 200 | - [4]Lorensen, William E., and Harvey E. Cline. "Marching cubes: A high resolution 3D surface construction algorithm." ACM siggraph computer graphics 21.4 (1987): 163-169. 201 | - [5] Ju, Tao, et al. "Dual contouring of hermite data." Proceedings of the 29th annual conference on Computer graphics and interactive techniques. 2002. 202 | -------------------------------------------------------------------------------- /sdftoolbox/sdfs.py: -------------------------------------------------------------------------------- 1 | """Signed distance function helpers. 2 | 3 | Tools to create, manipulate and sample continuous signed distance functions in 3D. 4 | """ 5 | from argparse import ArgumentError 6 | from typing import Callable, Literal 7 | import numpy as np 8 | import abc 9 | 10 | from . import maths 11 | from .types import float_dtype 12 | from .grid import Grid 13 | 14 | 15 | class SDF(abc.ABC): 16 | """Abstract base for a node in the signed distance function graph.""" 17 | 18 | def __call__(self, x: np.ndarray) -> np.ndarray: 19 | return self.sample(x) 20 | 21 | @abc.abstractmethod 22 | def sample(self, x: np.ndarray) -> np.ndarray: 23 | """Samples the SDF at locations `x`. 24 | 25 | Params 26 | x: (...,3) array of sampling locations 27 | 28 | Returns 29 | v: (...) array of SDF values. 30 | """ 31 | ... 32 | 33 | def gradient( 34 | self, 35 | x: np.ndarray, 36 | h: float = 1e-8, 37 | normalize: bool = False, 38 | mode: Literal["central"] = "central", 39 | ) -> np.ndarray: 40 | """Returns derivatives of the SDF wrt. the input locations. 41 | 42 | Params: 43 | x: (...,3) array of sample locations 44 | h: step size for numeric approximation 45 | normalize: whether to return normalized gradients 46 | 47 | Returns: 48 | n: (...,3) array of gradients/normals 49 | """ 50 | 51 | if mode == "central": 52 | offsets = ( 53 | np.expand_dims( 54 | np.eye(3, dtype=x.dtype), 55 | np.arange(x.ndim - 1).tolist(), 56 | ) 57 | * h 58 | * 0.5 59 | ) 60 | x = np.expand_dims(x, -2) 61 | fwd = self.sample(x + offsets) 62 | bwd = self.sample(x - offsets) 63 | 64 | g = (fwd - bwd) / h 65 | else: 66 | raise ArgumentError("Unknown mode") 67 | 68 | if normalize: 69 | length = np.linalg.norm(g, axis=-1, keepdims=True) 70 | g = g / length 71 | g[~np.isfinite(g)] = 0.0 72 | 73 | return g 74 | 75 | def merge(self, *others: list["SDF"], alpha: float = np.inf) -> "Union": 76 | return Union([self] + list(others), alpha=alpha) 77 | 78 | def intersect(self, *others: list["SDF"], alpha: float = np.inf) -> "Intersection": 79 | return Intersection([self] + list(others), alpha=alpha) 80 | 81 | def subtract(self, *others: list["SDF"], alpha: float = np.inf) -> "Difference": 82 | return Difference([self] + list(others), alpha=alpha) 83 | 84 | def transform( 85 | self, 86 | trans: tuple[float, float, float] = (0.0, 0.0, 0.0), 87 | rot: tuple[float, float, float, float] = (1.0, 0.0, 0.0, 0.0), 88 | scale: float = 1.0, 89 | ) -> "Transform": 90 | return Transform(self, Transform.create_transform(trans, rot, scale)) 91 | 92 | 93 | class Transform(SDF): 94 | """Base for nodes with transforms. 95 | 96 | Most of the primitives nodes are defined in terms of a canonical shape 97 | (unit sphere, xy-plane). The transform allows you to shift, 98 | rotate and isotropically scale them to your needs. 99 | """ 100 | 101 | def __init__(self, node: SDF, t_world_local: np.ndarray = None) -> None: 102 | if t_world_local is None: 103 | t_world_local = np.eye(4, dtype=float_dtype) 104 | 105 | self._t_scale: float = 1.0 106 | self.node = node 107 | self.t_world_local = t_world_local 108 | 109 | @property 110 | def t_world_local(self) -> np.ndarray: 111 | return self._t_world_local 112 | 113 | @t_world_local.setter 114 | def t_world_local(self, m: np.ndarray): 115 | self._t_world_local = np.asfarray(m, dtype=float_dtype) 116 | self._update_scale() 117 | self._update_inv() 118 | 119 | @property 120 | def t_local_world(self) -> np.ndarray: 121 | return self._t_local_world 122 | 123 | def sample(self, x: np.ndarray) -> np.ndarray: 124 | return self.node.sample(self._to_local(x)) * self._t_scale 125 | 126 | def _update_inv(self): 127 | # Decompose into as M=TRS with uniform scale as 128 | # M^-1=(S^-1)(R^T)(T^-1) 129 | s = np.linalg.norm(self.t_world_local[:3, 0]) 130 | Sinv = np.diag([1 / s, 1 / s, 1 / s, 1.0]) 131 | R = self.t_world_local[:3, :3] / s 132 | Rinv = np.eye(4, dtype=self.t_world_local.dtype) 133 | Rinv[:3, :3] = R.T 134 | t = self.t_world_local[:3, 3] 135 | Tinv = np.eye(4, dtype=self.t_world_local.dtype) 136 | Tinv[:3, 3] = -t 137 | self._t_local_world = Sinv @ Rinv @ Tinv 138 | 139 | # print(self._t_local_world, np.linalg.inv(self.t_world_local)) 140 | 141 | # self._t_local_world = np.linalg.inv(self.t_world_local) 142 | 143 | def _update_scale(self): 144 | scales = np.linalg.norm(self._t_world_local[:3, :3], axis=0) 145 | if not np.allclose(scales, scales[0]): 146 | raise ValueError( 147 | "Only uniform scaling is supported. Anisotropic scaling" 148 | " destroys distance fields." 149 | ) 150 | self._t_scale = scales[0] 151 | 152 | def _to_local(self, x: np.ndarray) -> np.ndarray: 153 | return maths.dehom(maths.hom(x) @ self.t_local_world.T) 154 | 155 | def _decompose_matrix( 156 | self, m: np.ndarray 157 | ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: 158 | """Decomposes the 4x4 matrix into scale, rotation and translation parts""" 159 | # M=TRS (uniform scale) 160 | # 161 | 162 | def transform( 163 | self, 164 | trans: tuple[float, float, float] = (0, 0, 0), 165 | rot: tuple[float, float, float, float] = (1, 0, 0, 0), 166 | scale: float = 1, 167 | ) -> "Transform": 168 | t = Transform.create_transform(trans, rot, scale) 169 | self.t_world_local = t @ self.t_world_local 170 | return self 171 | 172 | @staticmethod 173 | def create_transform( 174 | trans=(0.0, 0.0, 0.0), rot=(1.0, 0.0, 0.0, 0.0), scale: float = 1.0 175 | ) -> np.ndarray: 176 | t = ( 177 | maths.translate(trans) 178 | @ maths.rotate(rot[:3], rot[-1]) 179 | @ maths.scale((scale, scale, scale)) 180 | ) 181 | return t 182 | 183 | 184 | class Union(SDF): 185 | """(Smooth) Boolean union operation.""" 186 | 187 | def __init__(self, sdfs: list[SDF], alpha: float = np.inf) -> None: 188 | if len(sdfs) == 0: 189 | raise ValueError("Need at least one SDF") 190 | self.children = sdfs 191 | self.alpha = alpha 192 | 193 | def sample(self, x: np.ndarray) -> np.ndarray: 194 | values = np.stack([c.sample(x) for c in self.children], 0) 195 | # min = -max(-values) 196 | return -maths.generalized_max(-values, 0, alpha=self.alpha) 197 | 198 | 199 | class Intersection(SDF): 200 | """(Smooth) Boolean intersection operation.""" 201 | 202 | def __init__(self, sdfs: list[SDF], alpha: float = np.inf) -> None: 203 | if len(sdfs) == 0: 204 | raise ValueError("Need at least one SDF") 205 | self.children = sdfs 206 | self.alpha = alpha 207 | 208 | def sample(self, x: np.ndarray) -> np.ndarray: 209 | values = np.stack([c.sample(x) for c in self.children], 0) 210 | return maths.generalized_max(values, 0, alpha=self.alpha) 211 | 212 | 213 | class Difference(SDF): 214 | """(Smooth) Boolean difference operation.""" 215 | 216 | def __init__(self, sdfs: list[SDF], alpha: float = np.inf) -> None: 217 | if len(sdfs) == 0: 218 | raise ValueError("Need at least one SDF") 219 | self.children = sdfs 220 | self.alpha = alpha 221 | 222 | def sample(self, x: np.ndarray) -> np.ndarray: 223 | values = np.stack([c.sample(x) for c in self.children], 0) 224 | if values.shape[0] > 1: 225 | values[1:] *= -1 226 | return maths.generalized_max(values, 0, alpha=self.alpha) 227 | 228 | 229 | class Displacement(SDF): 230 | """Displaces a SDF node by function modifier.""" 231 | 232 | def __init__(self, node: SDF, dispfn: Callable[[np.ndarray], np.ndarray]) -> None: 233 | self.dispfn = dispfn 234 | self.node = node 235 | 236 | def sample(self, x: np.ndarray) -> np.ndarray: 237 | node_values = self.node.sample(x) 238 | disp_values = self.dispfn(x) 239 | return node_values + disp_values 240 | 241 | 242 | class Repetition(SDF): 243 | """Repeats a SDF node (in)finitely.""" 244 | 245 | def __init__( 246 | self, 247 | node: SDF, 248 | periods: tuple[float, float, float] = (1, 1, 1), 249 | reps: tuple[int, int, int] = None, 250 | ) -> None: 251 | self.periods = np.asfarray(periods, dtype=float_dtype).reshape(1, 1, 1, 3) 252 | self.node = node 253 | if reps is not None: 254 | self.reps = np.array(reps).reshape(1, 1, 1, 3) 255 | self.sample = self._repeat_finite 256 | else: 257 | self.sample = self._repeat_infinite 258 | 259 | def sample(self, x: np.ndarray) -> np.ndarray: 260 | pass # set via __init__ 261 | 262 | def _repeat_infinite(self, x: np.ndarray) -> np.ndarray: 263 | x = np.mod(x + 0.5 * self.periods, self.periods) - 0.5 * self.periods 264 | return self.node.sample(x) 265 | 266 | def _repeat_finite(self, x: np.ndarray) -> np.ndarray: 267 | x = x - self.periods * np.clip(np.round(x / self.periods), 0, self.reps - 1) 268 | return self.node.sample(x) 269 | 270 | 271 | class Discretized(SDF): 272 | """Stores a discretized SDF. 273 | 274 | For any query location, the SDF is then reconstructed 275 | by trilinear interpolation over the voxel grid. This class inherits 276 | from Transform, so you can adjust the position, orientation and 277 | uniform scale wrt. the SDF node to be sampled. 278 | 279 | Attributes: 280 | grid: Grid (I,J,K) holds grid sampling locations 281 | sdf_values: (I,J,K) SDF values at sampling locations 282 | """ 283 | 284 | def __init__( 285 | self, 286 | grid: Grid, 287 | sdf_values: np.ndarray, 288 | ) -> None: 289 | """ 290 | Params: 291 | grid: local sampling coordinates 292 | sdf_valus: (I,J,K) signed distance values 293 | """ 294 | self.grid = grid 295 | self.sdf_values = sdf_values 296 | 297 | def sample(self, x: np.ndarray) -> np.ndarray: 298 | """Samples the discretized volume using trilinear interpolation. 299 | 300 | Params: 301 | x: (...,3) array of local coordinates 302 | 303 | Returns: 304 | sdf: (...) sdf values at given locations. 305 | """ 306 | 307 | m = np.isfinite(x).all(-1) 308 | 309 | sdf = np.zeros(x.shape[:-1]) 310 | values = self._interp(self.sdf_values, x[m]).squeeze(-1) 311 | sdf[m] = values 312 | return sdf 313 | 314 | def _interp(self, vol: np.ndarray, x: np.ndarray) -> np.ndarray: 315 | P = x.shape[:-1] 316 | x = x.reshape(-1, 3) 317 | 318 | if vol.ndim == 3: 319 | vol = np.expand_dims(vol, -1) 320 | 321 | minc = np.expand_dims(self.grid.min_corner, 0) 322 | maxc = np.expand_dims(self.grid.max_corner, 0) 323 | x = np.maximum( 324 | minc, np.minimum(maxc - 1e-8, x) 325 | ) # 1e-8 to always have sample point > x 326 | 327 | spacing = np.expand_dims(self.grid.spacing, 0) 328 | xn = (x - minc) / spacing 329 | sijk = np.floor(xn).astype(np.int32) 330 | w = xn - sijk 331 | 332 | # See https://en.wikipedia.org/wiki/Trilinear_interpolation 333 | # i-diretion 334 | si, sj, sk = sijk.T 335 | c00 = vol[si, sj, sk] * (1 - w[..., 0:1]) + vol[si + 1, sj, sk] * w[..., 0:1] 336 | c01 = ( 337 | vol[si, sj, sk + 1] * (1 - w[..., 0:1]) 338 | + vol[si + 1, sj, sk + 1] * w[..., 0:1] 339 | ) 340 | c10 = ( 341 | vol[si, sj + 1, sk] * (1 - w[..., 0:1]) 342 | + vol[si + 1, sj + 1, sk] * w[..., 0:1] 343 | ) 344 | c11 = ( 345 | vol[si, sj + 1, sk + 1] * (1 - w[..., 0:1]) 346 | + vol[si + 1, sj + 1, sk + 1] * w[..., 0:1] 347 | ) 348 | # j-diretion 349 | c0 = c00 * (1 - w[..., 1:2]) + c10 * w[..., 1:2] 350 | c1 = c01 * (1 - w[..., 1:2]) + c11 * w[..., 1:2] 351 | # k-direction 352 | c = c0 * (1 - w[..., 2:3]) + c1 * w[..., 2:3] 353 | return c.reshape(P + (c.shape[-1],)) 354 | 355 | 356 | class Sphere(SDF): 357 | """The SDF of a unit sphere.""" 358 | 359 | def sample(self, x: np.ndarray) -> np.ndarray: 360 | d = np.linalg.norm(x, axis=-1) 361 | return d - 1.0 362 | 363 | @staticmethod 364 | def create( 365 | center: np.ndarray = (0.0, 0.0, 0.0), 366 | radius: float = 1.0, 367 | ) -> Transform: 368 | """Creates a sphere from center and radius.""" 369 | t = maths.translate(center) @ maths.scale(radius) 370 | return Transform(Sphere(), t_world_local=t) 371 | 372 | 373 | class Plane(SDF): 374 | """A plane parallel to xy-plane through origin.""" 375 | 376 | def sample(self, x: np.ndarray) -> np.ndarray: 377 | return x[..., -1] 378 | 379 | @staticmethod 380 | def create( 381 | origin: tuple[float, float, float] = (0, 0, 0), 382 | normal: tuple[float, float, float] = (0, 0, 1), 383 | ) -> Transform: 384 | """Creates a plane from a point and normal direction.""" 385 | normal = np.asfarray(normal, dtype=float_dtype) 386 | origin = np.asfarray(origin, dtype=float_dtype) 387 | normal /= np.linalg.norm(normal) 388 | # Need to find a rotation that alignes canonical frame's z-axis 389 | # with normal. 390 | z = np.array([0.0, 0.0, 1.0], dtype=normal.dtype) 391 | d = np.dot(z, normal) 392 | if d == 1.0: 393 | t = np.eye(4, dtype=normal.dtype) 394 | elif d == -1.0: 395 | t = maths.rotate([1.0, 0.0, 0.0], np.pi) 396 | else: 397 | p = np.cross(normal, z) 398 | a = np.arccos(normal[-1]) 399 | t = maths.rotate(p, -a) 400 | t[:3, 3] = origin 401 | return Transform(Plane(), t_world_local=t) 402 | 403 | 404 | class Box(SDF): 405 | """An axis aligned bounding box centered at origin.""" 406 | 407 | def __init__( 408 | self, 409 | lengths: tuple[float, float, float] = (1.0, 1.0, 1.0), 410 | ) -> None: 411 | self.half_lengths = np.asfarray(lengths, dtype=float_dtype) * 0.5 412 | 413 | @staticmethod 414 | def create(lengths: tuple[float, float, float] = (1.0, 1.0, 1.0)) -> "Box": 415 | """Creates a box from given lengths.""" 416 | return Box(lengths) 417 | 418 | def sample(self, x: np.ndarray) -> np.ndarray: 419 | a = np.abs(x) - self.half_lengths 420 | return np.linalg.norm(np.maximum(a, 0), axis=-1) + np.minimum( 421 | np.max(a, axis=-1), 0 422 | ) 423 | -------------------------------------------------------------------------------- /doc/frames.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 2022-06-13T19:13:59.972571 10 | image/svg+xml 11 | 12 | 13 | Matplotlib v3.5.2, https://matplotlib.org/ 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 30 | 31 | 32 | 38 | 39 | 40 | 41 | 46 | 47 | 48 | 49 | 50 | 55 | 56 | 57 | 58 | 59 | 64 | 65 | 66 | 67 | 68 | 71 | 72 | 73 | 74 | 75 | 76 | 91 | 92 | 93 | 94 | 95 | 96 | 100 | 104 | 108 | 109 | 110 | 111 | 114 | 115 | 116 | 117 | 118 | 119 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 150 | 151 | 152 | 153 | 154 | 155 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 179 | 180 | 181 | 182 | 183 | 184 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 219 | 220 | 221 | 222 | 223 | 224 | 241 | 242 | 243 | 244 | 245 | 246 | 250 | 254 | 258 | 259 | 260 | 261 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 304 | 305 | 306 | 307 | 308 | 309 | 322 | 323 | 324 | 325 | 326 | 327 | 331 | 335 | 339 | 340 | 341 | 342 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 481 | 482 | 483 | 486 | 487 | 488 | 491 | 492 | 493 | 496 | 497 | 498 | 501 | 502 | 503 | 506 | 507 | 508 | 511 | 512 | 513 | 516 | 517 | 518 | 521 | 522 | 523 | 526 | 527 | 528 | 531 | 532 | 533 | 536 | 537 | 538 | 541 | 542 | 543 | 546 | 549 | 552 | 553 | 554 | 557 | 560 | 563 | 564 | 565 | 568 | 571 | 574 | 575 | 576 | 577 | 578 | 579 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | -------------------------------------------------------------------------------- /doc/edges.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 2022-06-13T19:14:00.149098 10 | image/svg+xml 11 | 12 | 13 | Matplotlib v3.5.2, https://matplotlib.org/ 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 30 | 31 | 32 | 38 | 39 | 40 | 41 | 46 | 47 | 48 | 49 | 50 | 55 | 56 | 57 | 58 | 59 | 64 | 65 | 66 | 67 | 68 | 71 | 72 | 73 | 74 | 75 | 76 | 91 | 92 | 93 | 94 | 95 | 96 | 100 | 104 | 108 | 109 | 110 | 111 | 114 | 115 | 116 | 117 | 118 | 119 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 150 | 151 | 152 | 153 | 154 | 155 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 179 | 180 | 181 | 182 | 183 | 184 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 219 | 220 | 221 | 222 | 223 | 224 | 241 | 242 | 243 | 244 | 245 | 246 | 250 | 254 | 258 | 259 | 260 | 261 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 304 | 305 | 306 | 307 | 308 | 309 | 322 | 323 | 324 | 325 | 326 | 327 | 331 | 335 | 339 | 340 | 341 | 342 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 481 | 484 | 487 | 488 | 489 | 492 | 495 | 498 | 499 | 500 | 503 | 506 | 509 | 510 | 511 | 512 | 513 | 514 | 527 | 536 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 602 | 603 | 604 | 605 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 656 | 689 | 719 | 745 | 752 | 777 | 778 | 799 | 820 | 837 | 863 | 864 | 865 | 866 | 867 | 868 | 869 | 870 | 871 | 872 | 873 | 874 | 875 | 876 | 877 | 878 | 879 | 880 | 881 | 882 | 883 | 884 | 885 | 886 | 887 | 888 | --------------------------------------------------------------------------------