Unified tool for mesh generation, multiresolution mesh creation, skeletonization, and analysis — all parallelized with Dask.
Produces meshes in the neuroglancer precomputed format for viewing in neuroglancer.
The example notebook runs the full pipeline end-to-end (build a zarr volume, generate meshes, view in neuroglancer) without any local setup.
git clone https://github.com/janelia-cellmap/mesh-n-bone.git
cd mesh-n-bone
pixi install
# Create a small example zarr volume
pixi run python examples/create_example_volume.py
# Generate meshes and multiresolution output
pixi run mesh-n-bone meshify examples/meshify-config -n 1
# View volume and meshes in neuroglancer
pixi run mesh-n-bone serve examples --zarr data/example.zarr/seg --meshes output/multiresSee examples/ for the full walkthrough.
- Meshify — Generate meshes from
.zarr,.n5, or neuroglancer precomputed segmentation volumes (auto-detected; local,http(s)://,gs://, ors3://) via marching cubes, with blockwise processing, chunk assembly, simplification, and optional on-the-fly downsampling - To-Neuroglancer — Convert single-scale meshes into neuroglancer multiresolution Draco-compressed meshes with automatic LOD decimation, optionally sharded so thousands of meshes load with a handful of HTTP range requests instead of per-segment file fetches
- Skeletonize — Extract skeletons from meshes using CGAL mean curvature flow, with pruning, simplification, and metrics
- Analyze — Compute mesh metrics: volume, surface area, curvature, thickness, principal inertia, oriented bounds
git clone https://github.com/janelia-cellmap/mesh-n-bone.git
cd mesh-n-bone
pixi installpip install mesh-n-boneThe skeletonization module requires a compiled C++ binary. Build it with:
pixi run -e build-cgal build-cgalThis uses a separate pixi environment with CGAL, Boost, and Eigen dependencies.
All commands are available through the mesh-n-bone CLI:
mesh-n-bone <command> [options]
mesh-n-bone meshify CONFIG_PATH -n NUM_WORKERS [--roi begin_z,begin_y,begin_x,end_z,end_y,end_x]Reads a local or HTTP(S) .zarr / .n5 segmentation volume, runs marching cubes per chunk, assembles across chunk boundaries (with boundary deduplication), optionally simplifies and smooths, and writes output as PLY or neuroglancer format.
Example meshify run-config.yaml:
# ── Required ──
input_path: /path/to/segmentation.zarr/s0 # Local path or HTTP(S) URL to zarr/n5 dataset
output_directory: /path/to/output # Where to write output meshes
# ── All remaining fields are optional ──
# ── Mesh generation ──
downsample_factor: 2 # Downsample volume by this factor before meshing (default: none)
downsample_method: mode # Downsampling method: mode, mode_suppress_zero, or binary (default: mode)
# ── Simplification & smoothing ──
do_simplification: true # Simplify meshes after assembly (default: true)
target_reduction: 0.933 # Total fraction of faces to remove (default: 0.933, hemibrain-matched)
n_smoothing_iter: 2 # Taubin smoothing iterations (default: 2)
check_mesh_validity: false # Validate watertightness + winding after simplify+smooth (default: false; turn on for strict validation)
# When smooth_before_simplify is true (default), Taubin smoothing runs on the
# DENSE assembled mesh first, then quadric decimation collapses the smooth
# surface to target_reduction. Empirically ~2x lower RMS deviation from the
# underlying continuous surface at the same final face count compared to
# the reverse order, because Taubin can fully average out the voxel staircase
# only when it has every nearby vertex available as a neighbor. Set false for
# the legacy decimate-then-smooth ordering.
smooth_before_simplify: true # (default: true)
# When use_fixed_edge_simplification is true, simplification runs in TWO STAGES:
# 1. per-chunk pass, with block-boundary vertices pinned so they survive assembly
# 2. global pass on the assembled mesh, finishing off the remaining reduction
# stage_1_reduction_fraction sets how much of target_reduction happens in stage 1.
# When false, a single standard simplification pass runs after assembly.
use_fixed_edge_simplification: true # (default: false)
stage_1_reduction_fraction: 0.5 # Share of target_reduction in the per-chunk stage (default: 0.5)
do_analysis: false # Compute mesh metrics CSV after meshing (default: false)
# ── Multiresolution output ──
do_multires: true # Also generate neuroglancer multilod_draco output (default: true)
num_lods: 4 # Number of levels of detail (default: 4)
multires_strategy: downsample # LOD strategy: decimate (face-decimate s0 mesh for higher LODs) or
# downsample (re-mesh from progressively downsampled volumes,
# better-looking coarse LODs) (default: downsample)
# In the downsample strategy, each LOD prefers a pre-existing OME-NGFF
# multiscale level when one matches the required factor. For example, if
# input_path is `.../seg/s0` and the parent group exposes `s0`, `s1`, `s2`,
# then LOD 0 reads s0, LOD 1 reads s1 directly, LOD 2 reads s2 directly,
# and only LODs without a matching pre-built scale fall back to
# downsample_method-based in-worker downsampling. This is automatic — no
# extra config needed beyond having OME-NGFF multiscales metadata on the
# input zarr. For precomputed sources or zarrs without multiscales,
# every LOD downsamples in-worker via `downsample_method`.
use_existing_scales: true # Set false to FORCE in-worker downsampling at every LOD,
# ignoring any pre-built s_k scales on the input zarr. Use
# when you don't trust the source's downsampling and want
# consistent `downsample_method` behavior at every LOD.
# (default: true)
# When the input zarr exposes only s0 (no pre-built coarser levels),
# the downsample multires strategy auto-builds the missing s_k arrays
# into `{output_directory}/_intermediate_scales.zarr`. This is automatic
# and transparent — whenever a scale a LOD needs isn't already present
# on the input, it gets built on the fly; no separate pre-processing
# step is required. The pyramid is written in the SAME zarr format
# (v2 or v3) as the input, and s0 is symlinked in (not copied) when the
# formats match — so `_intermediate_scales.zarr` is directly browsable
# as one consistent OME-NGFF group (e.g. in neuroglancer), not just
# usable internally by mesh-n-bone.
delete_intermediate_scales: true # Delete the auto-built `_intermediate_scales.zarr` once the
# multires pipeline finishes. Set false to keep the pyramid
# around for re-running with different mesh params on the same
# data. (default: true)
# How the auto-built s_k arrays above are computed:
# false (default): every missing scale is downsampled directly from s0 by
# its cumulative factor — one parallel super-chunk pass, but per-task
# memory and total work both grow with num_lods.
# true: each missing scale is downsampled from the immediately coarser
# one (s_{k-1} -> s_k), chaining forward. ~O(source_size) total work
# instead of O(num_lods * source_size), and per-task memory stays
# bounded by a small step factor (~2x/axis) instead of growing with
# num_lods — prefer this for num_lods roughly 5-10+, or when direct
# downsampling runs out of memory. Manual choice only — it does not
# auto-switch based on memory pressure mid-run. Caveat: exact for
# associative reducers, an approximation (small mismatch at label
# boundaries) for the majority-vote reducers (mode, mode_suppress_zero,
# binary) — the same class of approximation most OME-NGFF pyramid
# generators already accept for label data.
downsample_cascade: false # (default: false)
pyramid_alignment_mode: halo # "snap" rounds an unaligned ROI INWARD to multiples of the
# max per-axis factor (drops up to max_factor-1 voxels per edge);
# "halo" rounds OUTWARD and reads beyond the ROI to complete the
# boundary cubes (no data loss). (default: halo)
decimation_factor: 6 # Per-LOD face reduction factor. In decimate strategy: literal ratio
# between LODs. In downsample strategy: target ratio used to derive
# per-LOD target_reduction so the LOD-to-LOD face count drops by this
# factor. (default: 6, hemibrain-matched)
delete_decimated_meshes: true # Remove intermediate LOD mesh files after Draco output (default: true)
center_octree: true # Position the mesh in the middle of the multires octree. NG uses the
# top-LOD chunk center for double-click-to-focus, so centering keeps
# click-to-center accurate. Cost: meshes that would fit in a single
# LOD-0 chunk straddle the octree's center boundary instead,
# producing visible chunk seams at high zoom. Set false to align the
# octree to the mesh bbox corner — sub-chunk meshes then fit in 1
# fragment with no internal seams, but click-to-focus lands near the
# mesh's near corner instead of its center. (default: true)
# ── Sharded output (recommended for thousands of meshes) ──
sharded: true # Pack all meshes into a few <n>.shard files
# instead of two files per segment (default: true)
# shard_bits: 4 # Optional overrides — defaults are auto-sized
# minishard_bits: 6 # from the segment count via choose_shard_params.
# preshift_bits: 0
delete_unsharded_files: true # Remove per-segment files after packing (default: true)
# ── Coordinate system ──
# Voxel size is read automatically from the dataset metadata (OME-NGFF or
# zarr attributes). Use voxel_size_nm only to override when the metadata is
# missing or incorrect. It affects mesh vertex scaling, not ROI coordinates.
voxel_size_nm: [1000, 1000, 1000] # Override voxel size (ZYX)
# ── Segment properties ──
segment_properties_csv: /path/to/properties.csv # CSV with per-segment metadata
segment_properties_columns: [col1, col2] # Which columns to include (default: all)
segment_properties_id_column: "Object ID" # CSV column with segment IDs (default: "Object ID")
# ── Region of interest ──
roi: # Restrict processing to this subregion
begin: [100, 200, 300] # Start coordinates in dataset world units (ZYX)
end: [500, 600, 700] # End coordinates in dataset world units (ZYX)
# Boundary edges are preserved during simplification.
# Can also be passed via CLI: --roi z0,y0,x0,z1,y1,x1
# ── Specific segment ids only (optional) ──
# When set, only the listed ids are meshified — every other voxel is
# zeroed before marching cubes via fastremap.mask_except, so zmesh does
# no work on unwanted objects. Blocks containing none of the targets
# are skipped entirely.
target_ids: [12345, 67890] # YAML list of ids, OR
# target_ids: /path/to/ids.csv # CSV file (first col, or "id"/"Object ID" col)
# target_ids: 12345 # Single id
# CLI: --ids 12345,67890 OR --ids /path.csvinput_path accepts any of .zarr, .n5, or neuroglancer precomputed sources, from any of these locations:
- A local filesystem path (e.g.
/data/seg.zarr/s0) http(s)://...gs://bucket/paths3://bucket/path
The format is auto-detected by probing for the relevant marker file (info → precomputed, zarr.json → zarr v3, .zarray → zarr v2, attributes.json → N5), so a path like gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation opens correctly with no extra prefix.
If no specific scale is provided, the highest-resolution scale is used by default — for OME-Zarr multiscales groups (root or subgroup) that's the first dataset listed in the metadata; for precomputed, scale index 0 from the info file. To use a different scale, either name the array directly (.../seg.zarr/s2) or, for precomputed, append the scale key from the info file (e.g. gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation/16.0x16.0x16.0).
Example using the public hemibrain segmentation at native res:
input_path: gs://neuroglancer-janelia-flyem-hemibrain/v1.0/segmentation/8.0x8.0x8.0
output_directory: /path/to/output
roi:
begin: [155744, 155744, 155744] # ZYX, nm
end: [163744, 163744, 163744] # 1000 voxels × 8 nm per side
check_mesh_validity: false # ROIs cut at the boundary
use_fixed_edge_simplification: true
do_multires: true
num_lods: 4When a phase has fewer tasks than the requested -n workers (e.g. one block in a tiny ROI), meshify caps the cluster size for that phase to the task count so idle LSF/SLURM slots aren't requested. You'll see a one-line Capping workers ... log message.
If a worker dies from out-of-memory (typically a single huge segment hitting the per-worker memory cap), meshify automatically halves processes-per-slot in the in-memory dask config (so each remaining worker gets 2× memory while LSF slot count and CPU budget stay fixed) and retries the phase. Defaults: 3 retries, opt out with retry_on_oom: false, change retry count with memory_retry_max: N. Each retry logs a WARNING with the dead worker's address; the per-task mesh_id is also printed to worker stderr so you can grep job-logs/LSFCluster-*.err to identify the segment that caused the OOM.
mesh-n-bone to-neuroglancer CONFIG_PATH -n NUM_WORKERS [--roi begin_x,begin_y,begin_z,end_x,end_y,end_z]Takes existing meshes (e.g. PLY files), decimates them at multiple LODs using pyfqmr, decomposes into spatial fragments, Draco-compresses, and writes the neuroglancer multilod_draco format. Use this when you already have single-scale meshes and just need the neuroglancer format.
Config directory must contain run-config.yaml and dask-config.yaml. Example run-config.yaml:
required_settings:
input_path: /path/to/meshes # Directory containing LOD 0 mesh files (e.g. PLY)
output_path: /path/to/output # Where to write neuroglancer output
num_lods: 6 # Number of levels of detail to generate
optional_decimation_settings:
box_size: 4 # LOD 0 fragment size in world units (scalar or [x, y, z])
skip_decimation: false # Set true to reuse previously decimated meshes
decimation_factor: 4 # Face reduction factor per LOD (default: 2)
aggressiveness: 10 # pyfqmr decimation aggressiveness (default: 7)
delete_decimated_meshes: true # Remove intermediate LOD mesh files when done
roi: # Only process meshes intersecting this region (XYZ)
begin: [0, 0, 0]
end: [1000, 1000, 1000]
optional_properties_settings:
segment_properties_csv: /path/to/properties.csv # CSV with per-segment metadata
segment_properties_columns: [col1, col2] # Which columns to include (default: all)
segment_properties_id_column: "Object ID" # CSV column with segment IDs
sharding_settings:
sharded: true # Pack output into <n>.shard files (default: true)
# shard_bits: 4 # Optional overrides — auto-sized by default
# minishard_bits: 6
# preshift_bits: 0
delete_unsharded_files: true # Remove per-segment files after packing (default: true)box_size can be a scalar (applied to all axes) or a 3-element list for per-axis control, which prevents degenerate triangles on elongated meshes.
mesh-n-bone skeletonize CONFIG_PATH -n NUM_WORKERSRuns CGAL mean curvature flow skeletonization on all meshes in a directory. Produces skeleton files, metrics (longest shortest path, radius statistics, branch counts), and neuroglancer skeleton format output.
mesh-n-bone skeletonize-single INPUT_FILE OUTPUT_FILE [--subdivisions N] [--neuroglancer]mesh-n-bone analyze CONFIG_PATH -n NUM_WORKERSComputes per-mesh metrics using trimesh and pymeshlab: volume, surface area, curvature (mean, Gaussian, RMS, absolute), thickness (shape diameter function), principal inertia components, and oriented bounding box dimensions. Outputs a CSV.
mesh-n-bone serve PATH [--zarr ZARR_PATH] [--meshes MESHES_PATH] [--port PORT]Starts a local HTTP server with CORS headers and prints a neuroglancer URL. Use --zarr and --meshes to specify relative paths within PATH to a zarr/n5 volume and precomputed meshes, respectively. Default port is 9015.
All pipeline commands use Dask for parallelism. The config directory must contain a dask-config.yaml specifying the cluster type. Supported: local, lsf, slurm, sge.
When running with -n 1, no cluster is created and no config file is needed — work runs synchronously in the calling process.
See dask-jobqueue configuration for all cluster options.
To run on an LSF cluster, submit the driver process via bsub. The driver launches Dask workers as separate LSF jobs:
bsub -n 2 -P your_project_name mesh-n-bone meshify lsf-config -n 40This submits a 2-slot driver job that creates a 40-worker Dask cluster. Each worker is launched as its own LSF job using the settings in lsf-config/dask-config.yaml.
With pixi:
bsub -n 2 -P your_project_name pixi run mesh-n-bone meshify lsf-config -n 40An example LSF dask config is provided in lsf-config/dask-config.yaml:
jobqueue:
lsf:
ncpus: 48
processes: 40
cores: 40
memory: 720GB
walltime: 01:00
mem: 720000000000
use-stdin: true
log-directory: job-logs
name: mesh-n-bone
project: your_project_nameUpdate project to your LSF project/queue allocation and adjust ncpus, memory, and walltime for your cluster.
The same pattern applies — submit the driver via your scheduler and set the cluster type in dask-config.yaml:
# SLURM
jobqueue:
slurm:
cores: 40
memory: 720GB
walltime: "01:00:00"
# ... other SLURM-specific options
# SGE
jobqueue:
sge:
cores: 40
memory: 720GB
# ... other SGE-specific optionspixi run -e test testThe test suite includes unit tests and integration tests covering:
- Full meshify pipeline from zarr volumes (cross-chunk assembly, watertightness, volume accuracy)
- Multiresolution decomposition and Draco compression
- Mesh decimation across multiple LODs
- Downsampling methods (mode, mode-suppress-zero, binary mode)
- Mesh analysis metrics (volume, area, curvature, thickness)
- Skeleton processing (pruning, simplification, longest shortest path)
- Watertightness preservation after simplification and repair
- Fixed-edge boundary-preserving simplification
- Neuroglancer format output (ngmesh, multilod_draco, annotations)
src/mesh_n_bone/
cli.py # Unified CLI
config.py # YAML config parsing
meshify/ # Volume → mesh generation
meshify.py # Main pipeline (zmesh, chunk assembly)
downsample.py # Numba JIT blockwise downsampling
fixed_edge.py # Boundary-preserving simplification
multires/ # Multiresolution mesh creation
multires.py # Pipeline orchestrator
decomposition.py # Spatial fragment decomposition + Draco
decimation.py # pyfqmr LOD decimation
skeletonize/ # Mesh skeletonization
skeletonize.py # CGAL skeletonization pipeline
skeleton.py # Skeleton data structure + operations
analyze/ # Mesh analysis
analyze.py # Volume, curvature, thickness metrics
util/ # Shared utilities
dask_util.py # Dask cluster management
mesh_io.py # Mesh I/O, fragments, z-order
neuroglancer.py # Neuroglancer format writers
logging.py # Timing, logging, stream capture
cgal_skeletonize_mesh/ # C++ CGAL skeletonizer source + binary
tests/ # Unit and integration tests
Thanks to Luca Marconato for the pixi configuration that informed macOS support and the neuroglancer serving approach (#6).
mesh-n-bone is distributed under the GNU General Public License v3.0 or later (GPL-3.0-or-later). See LICENSE for the full text.
