├── 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 | 
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 | 
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 | |  |  |
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 |
32 |
Intersecting grid element
33 |
34 |
35 |
Method
36 |
Edge
37 |
Face
38 |
Voxel
39 |
40 |
41 |
Primal
42 |
Vertex
43 |
Edge
44 |
Face(s)
45 |
46 |
47 |
Dual
48 |
Face
49 |
Edge
50 |
Vertex
51 |
52 |
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 |
653 |
--------------------------------------------------------------------------------
/doc/edges.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
888 |
--------------------------------------------------------------------------------