Skip to content

Commit b1b5728

Browse files
Implement marching cubes
1 parent f1e0ae9 commit b1b5728

18 files changed

Lines changed: 2667 additions & 0 deletions

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ nanobind_add_module(_core
2020
src/bindings/graph.cxx
2121
src/bindings/ground_truth.cxx
2222
src/bindings/label_multiset.cxx
23+
src/bindings/mesh.cxx
2324
src/bindings/segmentation.cxx
2425
src/bindings/transformation.cxx
2526
src/bindings/util.cxx

LICENSE

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,33 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121
SOFTWARE.
22+
23+
Third-party notice — scikit-image Marching Cubes tables
24+
--------------------------------------------------------
25+
26+
The source and binary distributions also include lookup-table data derived
27+
from scikit-image 0.26.0. Copyright (c) 2009-2022 the scikit-image team.
28+
All rights reserved.
29+
30+
Redistribution and use in source and binary forms, with or without
31+
modification, are permitted provided that the following conditions are met:
32+
33+
1. Redistributions of source code must retain the above copyright notice,
34+
this list of conditions and the following disclaimer.
35+
2. Redistributions in binary form must reproduce the above copyright notice,
36+
this list of conditions and the following disclaimer in the documentation
37+
and/or other materials provided with the distribution.
38+
3. Neither the name of the copyright holder nor the names of its contributors
39+
may be used to endorse or promote products derived from this software
40+
without specific prior written permission.
41+
42+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
43+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
44+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
45+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
46+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
47+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
48+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
49+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
50+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
51+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

MIGRATION_GUIDE.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1872,6 +1872,54 @@ Notes:
18721872

18731873
## Skimage
18741874

1875+
### Marching Cubes
1876+
1877+
`bioimage-cpp` provides dependency-free isosurface extraction under
1878+
`bic.mesh`, including both the topology-resolving Lewiner/MC33 method used by
1879+
default in scikit-image and the classic Lorensen lookup-table variant.
1880+
1881+
```python
1882+
import bioimage_cpp as bic
1883+
1884+
# Extract one object from a label image. `pad=True` closes objects that touch
1885+
# the volume boundary by adding a temporary zero-valued halo.
1886+
vertices, faces, normals, values = bic.mesh.marching_cubes(
1887+
labels == label_id,
1888+
level=0.5,
1889+
spacing=(z_spacing, y_spacing, x_spacing),
1890+
method="lewiner",
1891+
pad=True,
1892+
)
1893+
```
1894+
1895+
The signature follows `skimage.measure.marching_cubes`: `level`, `spacing`,
1896+
`gradient_direction`, `step_size`, `allow_degenerate`, `method`, and an
1897+
optional boolean `mask` have the same purpose. Coordinates use NumPy
1898+
`(z, y, x)` order. Vertices are `float32` at unit spacing and `float64` after
1899+
non-unit spacing; faces are consistently `int32`, and normals/values are
1900+
`float32`.
1901+
1902+
Important details:
1903+
1904+
- Inputs are converted to contiguous `float32` before extraction. Any real
1905+
numeric or boolean input dtype is accepted; complex inputs are rejected.
1906+
- `method="lewiner"` resolves ambiguous cases and is the default;
1907+
`method="lorensen"` selects the original 256-case algorithm.
1908+
- Normals and local-range values follow scikit-image semantics. As in
1909+
scikit-image, `gradient_direction` reverses face winding without changing
1910+
normals, and anisotropic spacing scales vertices without transforming
1911+
normals.
1912+
- `pad=False` matches scikit-image's open-boundary behavior. The additional
1913+
`pad=True` option uses a zero-valued halo and is intended for
1914+
foreground-positive segmentation masks. The iso-level is determined from
1915+
the original unpadded volume.
1916+
- Spacing entries must be positive and finite, and faces remain `int32` when
1917+
degenerate faces are removed. These validations/dtype choices are
1918+
intentional differences from scikit-image edge cases.
1919+
1920+
See `development/mesh/check_marching_cubes.py` for reference comparisons and
1921+
`development/mesh/benchmark_marching_cubes.py` for reproducible timings.
1922+
18751923
### Anti-Aliased Resampling
18761924

18771925
`affine_transform` itself never pre-smooths the input; downsampling without

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
Image processing and segmentation functionality in C++ with light-weight python bindings through nanobind and minimal dependencies to enable distribution via pip.
99

10+
The package includes dependency-free triangle-mesh extraction from 3D volumes
11+
and segmentation masks under `bioimage_cpp.mesh`.
12+
1013
The `bioimage_cpp` python library can be installed via pip:
1114
```bash
1215
pip install bioimage-cpp

THIRD_PARTY_NOTICES.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Third-party notices
2+
3+
## scikit-image Marching Cubes tables
4+
5+
`include/bioimage_cpp/mesh/detail/mc33_luts.hxx` contains lookup-table data
6+
derived from scikit-image 0.26.0's Marching Cubes implementation. The
7+
corresponding MC33 control flow in `marching_cubes.hxx` follows that reference
8+
port of the algorithm by Lewiner et al. The relevant scikit-image material is
9+
licensed under the BSD 3-Clause License:
10+
11+
Copyright (c) 2009-2022 the scikit-image team. All rights reserved.
12+
13+
Redistribution and use in source and binary forms, with or without
14+
modification, are permitted provided that the following conditions are met:
15+
16+
1. Redistributions of source code must retain the above copyright notice,
17+
this list of conditions and the following disclaimer.
18+
2. Redistributions in binary form must reproduce the above copyright notice,
19+
this list of conditions and the following disclaimer in the documentation
20+
and/or other materials provided with the distribution.
21+
3. Neither the name of the copyright holder nor the names of its contributors
22+
may be used to endorse or promote products derived from this software
23+
without specific prior written permission.
24+
25+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
26+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
27+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
28+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
29+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
30+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
31+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
32+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
33+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
34+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Marching cubes performance
2+
3+
`bic.mesh.marching_cubes` remains deterministic and single-threaded. The
4+
optimization pass replaced the volume-growing edge hash map with Lewiner's
5+
two-slice face cache. Several smaller candidates were measured first and
6+
rejected when their gains did not survive repeated benchmarks.
7+
8+
## Measurement setup
9+
10+
- CPU: Intel Core i7-1185G7, 4 cores / 8 threads; the kernel uses one thread.
11+
- OS: Linux 5.15 x86_64.
12+
- Compiler/build: conda-forge GCC 14.3, editable release build (`-O3`).
13+
- Python 3.13.13, NumPy 2.4.6, scikit-image 0.26.0.
14+
- Workloads: a binary sphere, a reproducible 10%-foreground random binary
15+
mask, and a deterministic smooth scalar field.
16+
- Medium timings are medians of three seven-call batch medians. Small timings
17+
use three batches of three calls; large timings use one batch of three calls.
18+
- Every timed case passes the geometry/topology reference comparison first.
19+
20+
Reproduce:
21+
22+
```bash
23+
python development/mesh/benchmark_marching_cubes.py --size small --repeats 3 --batches 3
24+
python development/mesh/benchmark_marching_cubes.py --size medium --repeats 7 --batches 3
25+
python development/mesh/benchmark_marching_cubes.py --size large --repeats 3 --batches 1
26+
```
27+
28+
## Candidate evaluation
29+
30+
Each candidate was rebuilt, checked against the mesh tests and scikit-image
31+
oracle, then timed twice at 96³. A candidate needed a repeatable improvement
32+
of at least 3% without a regression above 3%.
33+
34+
| candidate | observed result | decision |
35+
|---|---|---|
36+
| Slice-sized output reserves and scalar normal appends | First run helped spheres ~3%, repeat regressed spheres 3–4% and dense Lewiner 11% | Reverted |
37+
| Hoisted flat indexing plus one-pass hash insertion | Dense masks improved ~2.5%, but spheres repeatedly regressed 3–4% | Reverted |
38+
| Cached corner strengths and per-cell edge indices | Mostly 1–3% changes; dense Lorensen regressed ~2% | Reverted |
39+
| Two-slice face cache | Repeatable 17–22% sphere, 55% scalar, and 80–81% dense-mask reductions at 96³ | Retained |
40+
| Flat cube loads after the face cache | Helped sparse Lewiner, but repeat regressed scalar Lewiner 4% and left dense masks unchanged | Reverted |
41+
42+
The retained cache uses two `int32` arrays with four slots per `(y, x)`
43+
position. The upper edge layer becomes the next z-slice's lower layer; the new
44+
upper layer is cleared. MC33 center vertices remain cell-local. Deduplication
45+
memory is therefore `O(nx * ny)` instead of growing with the total surface.
46+
47+
## Profiling
48+
49+
Profiling used the repository's `BIOIMAGE_PROFILE` build on a 160³ Lewiner
50+
call. The baseline's global map destruction happened after the original core
51+
report, so it appears as the gap between core traversal/finalization and the
52+
binding's `core_call`.
53+
54+
| dense-mask phase | hash-map baseline | two-slice cache |
55+
|---|---:|---:|
56+
| cell traversal | 2.208 s | 0.449 s |
57+
| normal finalization | 0.009 s | 0.009 s |
58+
| cache/map cleanup | ~0.537 s | <0.001 s |
59+
| output orientation | 0.009 s | 0.016 s |
60+
| measured public core/orientation total | 2.762 s | 0.475 s |
61+
62+
After the cache change, traversal is still 98% of the measured core work;
63+
normalization, output orientation, cleanup, and NumPy handoff are individually
64+
too small to justify further single-threaded complexity in this pass.
65+
66+
Peak RSS for a single 160³ dense-mask Lewiner call fell from 323,048 KiB to
67+
235,760 KiB, a 27% reduction.
68+
69+
## Final results
70+
71+
`baseline / final` reports the speedup from this optimization pass.
72+
`skimage / final` above one means bioimage-cpp is faster.
73+
74+
| shape | workload | method | baseline ms | final ms | baseline / final | skimage / final |
75+
|---|---|---|---:|---:|---:|---:|
76+
| 48³ | sphere | lewiner | 2.56 | 1.85 | 1.38× | 1.34× |
77+
| 48³ | sphere | lorensen | 2.53 | 2.00 | 1.27× | 1.24× |
78+
| 48³ | dense mask | lewiner | 26.76 | 10.45 | 2.56× | 2.17× |
79+
| 48³ | dense mask | lorensen | 23.34 | 9.06 | 2.58× | 2.08× |
80+
| 48³ | scalar field | lewiner | 10.51 | 4.45 | 2.36× | 2.13× |
81+
| 48³ | scalar field | lorensen | 9.91 | 4.34 | 2.28× | 2.20× |
82+
| 96³ | sphere | lewiner | 15.15 | 12.15 | 1.25× | 1.35× |
83+
| 96³ | sphere | lorensen | 15.49 | 12.36 | 1.25× | 1.35× |
84+
| 96³ | dense mask | lewiner | 427.71 | 85.60 | 5.00× | 2.16× |
85+
| 96³ | dense mask | lorensen | 389.43 | 74.49 | 5.23× | 2.12× |
86+
| 96³ | scalar field | lewiner | 52.73 | 23.71 | 2.22× | 1.85× |
87+
| 96³ | scalar field | lorensen | 52.51 | 23.41 | 2.24× | 1.90× |
88+
| 160³ | sphere | lewiner | 63.65 | 56.15 | 1.13× | 1.14× |
89+
| 160³ | sphere | lorensen | 66.41 | 55.20 | 1.20× | 1.21× |
90+
| 160³ | dense mask | lewiner | 2,824.26 | 456.11 | 6.19× | 2.10× |
91+
| 160³ | dense mask | lorensen | 2,507.33 | 378.46 | 6.63× | 2.17× |
92+
| 160³ | scalar field | lewiner | 247.95 | 87.80 | 2.82× | 1.64× |
93+
| 160³ | scalar field | lorensen | 227.09 | 86.28 | 2.63× | 1.63× |
94+
95+
The size-dependent hash-map regression is gone. The implementation is faster
96+
than scikit-image in every measured case without threading, SIMD, or API
97+
changes.
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""scikit-image reference helpers for marching-cubes development checks.
2+
3+
This module is intentionally kept outside the package and test dependency set.
4+
It imports scikit-image lazily, mirrors the public ``pad`` extension, and is
5+
used by the parity and benchmark scripts in this directory.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from collections.abc import Sequence
11+
12+
import numpy as np
13+
14+
15+
def _skimage_marching_cubes():
16+
try:
17+
from skimage.measure import marching_cubes
18+
except ImportError as error: # pragma: no cover - development only
19+
raise ImportError(
20+
"scikit-image is required for the marching-cubes reference "
21+
"(`pip install scikit-image`)"
22+
) from error
23+
return marching_cubes
24+
25+
26+
def reference_marching_cubes(
27+
volume: np.ndarray,
28+
level: float | None = None,
29+
*,
30+
spacing: Sequence[float] = (1.0, 1.0, 1.0),
31+
gradient_direction: str = "descent",
32+
step_size: int = 1,
33+
allow_degenerate: bool = True,
34+
method: str = "lewiner",
35+
mask: np.ndarray | None = None,
36+
pad: bool = False,
37+
):
38+
"""Call scikit-image with the same public contract as ``bic.mesh``."""
39+
image = np.ascontiguousarray(volume, dtype=np.float32)
40+
if level is None:
41+
level = 0.5 * (float(image.min()) + float(image.max()))
42+
mask_array = None if mask is None else np.ascontiguousarray(np.asarray(mask) != 0)
43+
spacing_array = np.asarray(spacing, dtype=np.float64)
44+
if pad:
45+
image = np.pad(image, 1, mode="constant", constant_values=0)
46+
if mask_array is not None:
47+
mask_array = np.pad(mask_array, 1, mode="constant", constant_values=True)
48+
result = _skimage_marching_cubes()(
49+
image,
50+
level,
51+
spacing=spacing_array,
52+
gradient_direction=gradient_direction,
53+
step_size=step_size,
54+
allow_degenerate=allow_degenerate,
55+
method=method,
56+
mask=mask_array,
57+
)
58+
if pad:
59+
vertices = result[0] - spacing_array.astype(result[0].dtype, copy=False)
60+
result = (vertices, *result[1:])
61+
return result
62+
63+
64+
def _sorted_rows(array: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
65+
if len(array) == 0:
66+
return array, np.empty(0, dtype=np.int64)
67+
order = np.lexsort(tuple(array[:, axis] for axis in range(array.shape[1] - 1, -1, -1)))
68+
return array[order], order
69+
70+
71+
def _canonical_faces(vertices: np.ndarray, faces: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
72+
unique_vertices, inverse = np.unique(vertices, axis=0, return_inverse=True)
73+
canonical = np.sort(inverse[faces], axis=1)
74+
canonical, _ = _sorted_rows(canonical)
75+
return unique_vertices, canonical
76+
77+
78+
def _surface_area(vertices: np.ndarray, faces: np.ndarray) -> float:
79+
total = 0.0
80+
for begin in range(0, len(faces), 100_000):
81+
triangles = vertices[faces[begin : begin + 100_000]].astype(np.float64, copy=False)
82+
cross = np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])
83+
total += float(np.linalg.norm(cross, axis=1).sum()) * 0.5
84+
return total
85+
86+
87+
def assert_mesh_matches(actual, reference, *, normal_atol: float = 1e-5) -> None:
88+
"""Assert geometry/topology parity independent of output ordering.
89+
90+
Normals and local-range values are compared after sorting complete vertex
91+
records by coordinates. Face winding is tested separately by the package
92+
tests because canonical triangle comparison intentionally ignores it.
93+
"""
94+
actual_vertices, actual_faces, actual_normals, actual_values = actual
95+
reference_vertices, reference_faces, reference_normals, reference_values = reference
96+
97+
assert actual_vertices.shape == reference_vertices.shape
98+
assert actual_faces.shape == reference_faces.shape
99+
assert actual_normals.shape == reference_normals.shape
100+
assert actual_values.shape == reference_values.shape
101+
102+
actual_unique, actual_triangles = _canonical_faces(actual_vertices, actual_faces)
103+
reference_unique, reference_triangles = _canonical_faces(reference_vertices, reference_faces)
104+
np.testing.assert_allclose(actual_unique, reference_unique, rtol=0.0, atol=1e-6)
105+
np.testing.assert_array_equal(actual_triangles, reference_triangles)
106+
np.testing.assert_allclose(
107+
_surface_area(actual_vertices, actual_faces),
108+
_surface_area(reference_vertices, reference_faces),
109+
rtol=1e-6,
110+
atol=1e-8,
111+
)
112+
113+
actual_records = np.column_stack(
114+
(actual_vertices, actual_normals, actual_values)
115+
).astype(np.float64, copy=False)
116+
reference_records = np.column_stack(
117+
(reference_vertices, reference_normals, reference_values)
118+
).astype(np.float64, copy=False)
119+
actual_records, _ = _sorted_rows(actual_records)
120+
reference_records, _ = _sorted_rows(reference_records)
121+
np.testing.assert_allclose(
122+
actual_records[:, :3], reference_records[:, :3], rtol=0.0, atol=1e-6
123+
)
124+
np.testing.assert_allclose(
125+
actual_records[:, 3:6], reference_records[:, 3:6], rtol=0.0, atol=normal_atol
126+
)
127+
np.testing.assert_allclose(
128+
actual_records[:, 6], reference_records[:, 6], rtol=0.0, atol=1e-6
129+
)

0 commit comments

Comments
 (0)