Against other SurfaceNets implementations
serra implements the method in Frisken (2022).
Two other implementations of that paper exist: the author's own
C++ library, and
VTK's vtkSurfaceNets3D, reachable from Python as
ImageData.contour_labels.
Both were built and run against the same 512³ connectomics volume serra is
benchmarked on. Because all three implement the same algorithm, the comparison
is direct — mesh against mesh, face for face.
zmesh is in both tables below as well, it is expected to disagree on content, but is included as a comparison on cost.
Results
The three SurfaceNets agree; marching cubes does not
512³ neuropil volume, no smoothing, boundary padding on in every row:
| objects | vertices | faces | |
|---|---|---|---|
| serra | 2523 | 46,547,344 | 93,061,024 |
VTK vtkSurfaceNets3D |
2523 | 46,486,892 | 93,061,024 |
| Frisken C++ | 2524 | 50.5M | 100.4M |
| zmesh (marching cubes) | 2523 | 46,530,512 | 93,031,372 |
serra and VTK agree on the face count exactly, and again at 128³ (156 objects, 1,831,136 faces each). Their vertex counts differ by 0.13%: VTK returns one polydata with each shared wall stored once, and splitting that into one mesh per label re-derives each label's vertex set, which counts a junction vertex slightly differently from how serra emits it. Frisken's counts are higher only because it meshes the background as an ordinary material; on a single sphere all three give 3038 vertices and 6072 triangles, watertight, enclosing exactly 8255.0 units of volume for 8255 voxels.
Speed and memory
512³, 2524 labels, Apple M4 Pro, -O3, one process each. VTK, Frisken and
zmesh are all single-threaded, so serra (1 thread) is the like-for-like
column.
| serra (1 thr) | serra (14 thr) | zmesh | VTK | Frisken (as published) | Frisken (patched) | |
|---|---|---|---|---|---|---|
| traverse the volume | 2.10 s | 0.63 s | 2.15 s | 3.65 s | 16.0 s | 1.94 s |
| smoothing, per sweep | 0.55 s | 0.06 s | — | 0.20 s | 5.64 s | 0.43 s |
| one mesh per object | 1.12 s | 1.11 s | 23.8 s | 17.3 s | 92.7 s | 18.7 s |
| peak RSS | 2.97 GB | 4.04 GB | 5.13 GB | 8.0–9.7 GB | 14.0 GB | 5.5 GB |
The two Frisken columns are carried over from the run that built the reference; every other column was measured in one sitting on an idle machine. Rebuilding Frisken's stock library to re-measure it costs 15 GB of resident memory, and its algorithm has not changed.
- serra is fastest at traversal, 1.7× over VTK on one thread and 5.8× using
all 14. VTK cannot be threaded from Python: its PyPI wheel ships the
SequentialSMP backend. - zmesh traverses at serra's single-threaded speed and is 21× slower at delivery. Marching cubes emits a triangle soup — every triangle carries its own three vertices, with no sharing — so turning that into indexed meshes means deduplicating 268.6M soup vertices down to 45.0M unique, per object, through a hash map: 83 ns each, which is simply what a hash-map insertion costs. serra never creates the duplicates. One vertex per cell per connected component, with quads referencing those indices directly, so extraction is a coordinate conversion and a triangulation at 23 ns per vertex. The third row is an architectural difference, not a tuning one.
- zmesh has no smoothing, at any setting; its dash means not applicable, not zero. serra's fairing, VTK's constrained smoothing and Frisken's relaxation are all sweeps over the extracted surface.
- VTK smooths 2.8× faster than serra on one thread, and serra overtakes it by 3× on 14. serra runs Jacobi, which needs a second buffer, so its output is byte-identical however the volume is split across threads. VTK and Frisken update in place and cannot be.
- serra uses 2.7–3.3× less memory than VTK, and far more predictably — VTK's peak moved between 8.0 and 9.7 GB across three runs of the same command.
- Frisken's published library is ~8× slower than serra, but its algorithm is not. Three lines of layout fix — see below — bring it level.
Where serra differs
| serra | VTK and Frisken | |
|---|---|---|
| unsmoothed vertex position | centroid of the edge crossings | centre of the cell |
| shrink compensation | Taubin λ/μ (fairing_taubin) |
none (VTK: constrained) |
| output | one indexed mesh per object | one polydata for the whole volume |
The placement difference is the one that shows up in accuracy: serra's unsmoothed mesh scores 0.109 voxels of mean surface error against an analytic tube where a cell-centre mesh scores 0.240. See accuracy.
Frisken's pure Laplacian loses 15.7% of a radius-4 tube's volume at 20 sweeps against serra's 0.42% with Taubin. serra's own Laplacian, run as a control, loses 14.97% — the same operator, so the difference is Taubin, not the domain.
Methods and details
Building the reference implementations
The Frisken repo ships a Visual Studio solution, but only Source/Application
needs Qt. Source/SNLib is 1,613 lines of ISO C++ with only standard-library
includes, and clang++ -std=c++17 compiles all five files with zero errors:
make -C bench/frisken stock # or: make FRISKEN=/path/to/clone stock
make -C bench/frisken patched
bench/frisken/sn_bench.cpp is the missing main() — it prints the same JSON
keys as bench/compare_zmesh.py so it drops into that harness. The clone is
never modified; patched copies the sources out and applies
bench/frisken/patches/*.patch to the copy.
VTK needs no build; it comes from the pyvista bench dependency group.
Why the published Frisken library is slow
MMCellFlag stores 19 bit masks as non-static const unsigned int data
members and declares a virtual destructor. Neither carries information, and
together they make sizeof(MMCellFlag) 88 bytes, 84 of them the same
constants in every cell. sizeof(Cell) is then 112 B, and MMCellMap
allocates new Cell[(nx+2)(ny+2)(nz+2)] — dense over the padded volume,
boundary or not. At 512³ that is 135.8M cells and 15.2 GB.
Patch 01 makes the masks static constexpr and drops virtual, taking Cell
to 24 bytes with byte-identical output. Patch 02 indexes quads by label so
objData() stops rescanning the global quad list once per object. Relaxation
gains 13.1× from a 4.7× smaller working set, because it is pure pointer-chasing
over the cell array and at 112 B/cell nearly every neighbour lookup misses cache.
Settings that make the comparison fair
Four defaults would each have invalidated the result:
- pyvista's
boundary_styledefaults to"external", returning only the outside of the foreground and skipping every wall between adjacent objects — a third of the faces here. Set to"all". - pyvista's
smoothingdefaults on at 16 iterations, which would compare a smoothed VTK mesh against an unsmoothed serra one. Off unless asked for. - serra's
closedefaults off while VTK'spad_backgroundand Frisken's padding are always on. Withclose=Truethe face counts agree exactly; without it serra emits fewer (89,182,314 against 93,061,024). Every serra figure above is--close. - zmesh's
closealso defaults off, and costs it far more than it costs serra. serra's is a virtual border and is free-ish: 1.77 s → 2.10 s on one thread. zmesh's wrapper allocates a padded C-order copy of the volume — 0.54 GB at 512³uint32, plus a transposing copy because this volume is Fortran-ordered, which also moves the traversal off zmesh's Fortran-order loop onto its C-order one. That nearly doubles it: 1.12 s unpadded against 2.15 s padded. The table uses the padded run so that every column is padded, but the difference is the wrapper's, not the algorithm's. Peak RSS does not show the copy — 5.77 GB unpadded against 5.13 GB padded — because zmesh's peak is set by the soup dedup in extraction, which swamps a half-gigabyte volume and varies by more than that between runs. zmesh also never subtracts the pad again, so its vertices sit +1 voxel from everyone else's; that moves no count in the first table, and is corrected in accuracy, where coordinates do matter.
The Frisken .vcxproj configuration inspected carries Optimization=Disabled;
all figures here are -O3 -DNDEBUG.
Two conversions the reference needs
MMCellMap walks i fastest, so its arraySize[0] is numpy's axis 2 — the
mirror of serra's convention. It also pads by a voxel on every face and reports
positions in the padded index space, so raw output sits +1 voxel from the
segmentation on every axis. sn_bench corrects both and reverses triangle
winding to undo the reflection. On a cubic volume the axis swap is invisible,
which is what makes it dangerous.
On the per-object numbers
vtkSurfaceNets3D returns one polydata with a two-component boundary_labels
cell array, each shared wall stored once. A connectomics pipeline needs the
opposite — one indexed mesh per segment, each wall in both neighbours — and VTK
has no filter for it. The 17.3 s above is bench/compare_zmesh.py doing it in
numpy, bucketed in one pass rather than filtered per label; a C++ implementation
would beat it comfortably. The point is that the step exists at all, and that
serra's 1.12 s already includes it.
Frisken's 92.7 s is the library's own objData(), which makes two full passes
over every quad in the volume per label.
zmesh's 23.8 s is the other shape this problem takes. It already returns one mesh per object, so nothing needs splitting — the cost is deduplicating the triangle soup that marching cubes produced in the first place, which is a step neither SurfaceNets implementation has to do at all.
Sweeps are not iterations
VTK and Frisken update in place, propagating information across the array within one sweep, so they converge in roughly half as many sweeps as serra's Jacobi. Equal sweep counts are equal work, not equal smoothing. On the analytic tube the two land within 3–6% of each other at matched sweep counts, because the movement clamp binds before the difference in convergence rate matters.
Reproducing
python bench/compare_zmesh.py serra --threads 1 --close # matches VTK exactly
python bench/compare_zmesh.py serra --threads 14 --close
python bench/compare_zmesh.py serra --threads 1 --close --fairing 20
python bench/compare_zmesh.py zmesh --close --zmesh ../zmesh
python bench/compare_zmesh.py zmesh --zmesh ../zmesh # unpadded; the pad's cost
python bench/compare_zmesh.py vtk
python bench/compare_zmesh.py vtk --smoothing-iterations 20
python bench/compare_zmesh.py frisken --relax 20
python bench/compare_zmesh.py frisken --relax 20 \
--frisken-bin bench/frisken/build/patched/sn_bench
python bench/analytic_tube.py --radius 4 # accuracy
--zmesh points at a local zmesh checkout, because zmesh is deliberately not a
declared dependency and the comparison should run against the build you have
rather than whatever is on PyPI.