Source code for brainglobe_atlasapi.atlas_generation.mesh_utils

"""Utility functions for working with meshes."""

import json
import shutil
from typing import List

import DracoPy
import meshio
from brainglobe_utils.general.system import get_num_processes
from cloudvolume.datasource.precomputed.mesh.multilod import (
    MultiLevelPrecomputedMeshManifest,
)
from rich.progress import track
from scipy import sparse
from scipy.ndimage import (
    binary_closing,
    binary_fill_holes,
    distance_transform_edt,
    find_objects,
    gaussian_filter,
)
from skimage.measure import marching_cubes

from brainglobe_atlasapi.structure_tree_util import (
    get_structures_tree,
    preorder_depth_first_search,
)

try:
    from vedo import Mesh, Volume, write
except ModuleNotFoundError:
    raise ModuleNotFoundError(
        "Mesh generation with these utils requires vedo\n"
        + '   please install with "pip install vedo -U"'
    )

import multiprocessing as mp
from pathlib import Path

import numpy as np
import zarr
from loguru import logger
from treelib import Node, Tree

from brainglobe_atlasapi.atlas_generation.volume_utils import (
    create_masked_array,
)

# ----------------- #
#   MESH CREATION   #
# ----------------- #


def _smooth_constrained(mask, band_radius=4, max_iters=500, rel_tol=1e-6):
    """Smooth the 0.5 level-set of a 3D binary mask.

    Implements "Surface Extraction from Binary Volumes with Higher-Order
    Smoothness", Victor Lempitsky, CVPR 2010. Adapted from PyMCubes'
    `smooth_constrained` (BSD-3-Clause, Copyright (c) 2012-2015 P. M.
    Neila), with the matrix assembly vectorised.

    Returns a float array whose 0 level-set is the smoothed surface. The
    surface is constrained to stay within half a voxel of the mask boundary.
    """
    mask = mask > 0
    dist = np.where(
        mask,
        distance_transform_edt(mask) - 0.5,
        0.5 - distance_transform_edt(~mask),
    )
    band = np.abs(dist) <= band_radius

    # Second differences along each axis over band voxels; neighbours outside
    # the band are treated as equal to the voxel itself.
    n = np.count_nonzero(band)
    idx = np.pad(np.full(band.shape, -1), 1, constant_values=-1)
    idx[1:-1, 1:-1, 1:-1][band] = np.arange(n)
    coords = np.argwhere(band) + 1
    rows, cols, vals = [], [], []
    for axis in range(3):
        row = axis * n + np.arange(n)
        diag = np.full(n, -2.0)
        for step in (-1, 1):
            neighbour_coords = coords.copy()
            neighbour_coords[:, axis] += step
            neighbour = idx[tuple(neighbour_coords.T)]
            inside = neighbour >= 0
            rows.append(row[inside])
            cols.append(neighbour[inside])
            vals.append(np.ones(inside.sum()))
            diag += ~inside
        rows.append(row)
        cols.append(np.arange(n))
        vals.append(diag)
    q = sparse.csr_matrix(
        (np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))),
        shape=(3 * n, n),
    )
    q = (q.T @ q).tocsr()

    # Weighted Jacobi iterations, clipped so voxels keep their inside/outside
    # sign, stopping when energy improves by less than rel_tol per iteration
    x = dist[band]
    upper = np.where(x < 0, x, np.inf)
    lower = np.where(x > 0, x, -np.inf)
    upper[np.abs(upper) < 1] = 0
    lower[np.abs(lower) < 1] = 0
    inv_diag = 1.0 / q.diagonal()
    off_diag = q - sparse.diags(q.diagonal())
    tol = 1 - (1 - rel_tol) ** 10
    energy = x @ (q @ x) / 2
    for i in range(max_iters):
        x = 0.5 * (-inv_diag * (off_diag @ x)) + 0.5 * x
        x = np.clip(x, lower, upper)
        if (i + 1) % 10 == 0:
            energy, previous_energy = x @ (q @ x) / 2, energy
            if (previous_energy - energy) / previous_energy < tol:
                break

    dist[band] = x
    return dist


[docs] def extract_mesh_from_mask( volume, obj_filepath=None, threshold=0.5, smooth: bool = False, mcubes_smooth=False, closing_n_iters=8, decimate_fraction: float = 0.6, # keep 60% of original fertices use_marching_cubes=False, extract_largest=False, origin=None, ): """ Return a vedo mesh actor with just the outer surface of a binary mask volume. It's faster though less accurate than extract_mesh_from_mask. Parameters ---------- obj_filepath: str or Path object path to where the .obj mesh file will be saved volume: 3d np.ndarray threshold: float min value to threshold the volume for isosurface extraction. Ignored when `mcubes_smooth` is True. smooth: bool if True the surface mesh is smoothed use_marching_cubes: bool: if True scikit-image's marching cubes is used to extract the volume's surface instead of vedo. The mesh is not capped where the mask touches the volume border. mcubes_smooth: bool, if True the mask's surface is smoothed before applying marching cubes, with constrained smoothing (Lempitsky, 2010), or a Gaussian filter for volumes larger than 500**3 voxels. Only used when `use_marching_cubes` is True. closing_n_iters: int number of iterations of closing morphological operation. set to None to avoid applying morphological operations decimate_fraction: float in range [0, 1]. What fraction of the original number of vertices is to be kept. EG .5 means that 50% of the vertices are kept, the others are removed. extract_largest: bool If True only the largest region are extracted. It can cause issues for bilateral regions as only one will remain origin: sequence of int or None Voxel index of `volume[0, 0, 0]` within a larger volume, so a cropped mask yields the same vertex coordinates as the uncropped one. Ignored when `use_marching_cubes` is True. """ # check savepath argument if obj_filepath is not None: if isinstance(obj_filepath, str): obj_filepath = Path(obj_filepath) if not obj_filepath.parents[0].exists(): raise FileExistsError( "The folder where the .obj file is to be saved doesn't exist" + f"\n {str(obj_filepath)}" ) # Check volume argument if not ( (np.issubdtype(volume.dtype, np.integer) or volume.dtype == bool) and (np.max(volume) <= 1 and np.min(volume) >= 0) ): raise ValueError( "Argument volume should be a binary mask with only " "0s and 1s when passing a np.ndarray" ) # Apply morphological transformations if closing_n_iters is not None: volume = binary_fill_holes(volume).astype(np.uint8) volume = binary_closing(volume, iterations=closing_n_iters).astype( np.uint8 ) if not use_marching_cubes: # Use faster algorithm volume = Volume(volume, origin=origin) mesh = volume.isosurface(value=threshold).cap() else: # Smoothed volumes have their surface at the 0 level-set, # matching PyMCubes' `smooth` if mcubes_smooth: threshold = 0 if volume.size > 500**3: volume = gaussian_filter(volume.astype(float) - 0.5, sigma=3) else: volume = _smooth_constrained(volume) # Avoid creating meshes from empty, or full volumes if volume.min() < threshold < volume.max(): vertices, faces, _, _ = marching_cubes( volume, threshold, method="lorensen" ) # skimage winds inward; flip to outward faces = faces[:, ::-1] else: # Return empty mesh if the volume is empty or full vertices = np.empty((0, 3)) faces = np.empty((0, 3), dtype=np.int64) mesh = Mesh((vertices, faces)) # Cleanup and save if extract_largest: mesh = mesh.extract_largest_region() # decimate mesh.decimate_pro(decimate_fraction) if smooth: mesh.smooth() if obj_filepath is not None: write(mesh, str(obj_filepath)) return mesh
def _create_region_mesh( meshes_dir_path: Path, node: Node, tree: Tree, labels: list[int], annotated_volume: np.ndarray | str | Path, ROOT_ID: int, closing_n_iters: int, decimate_fraction: float, smooth: bool, verbosity: int = 0, bbox: tuple[slice, ...] | None = None, ) -> None: """ Create and save an `.obj` mesh for a region and its descendants. The mesh is generated from a binary mask built from `node.identifier` and the identifiers of all child nodes in `tree`. Only labels present in `annotated_volume` are considered. If no matching labels are found, or the resulting mask is empty, no mesh is written. `annotated_volume` may be provided as an in-memory NumPy array or as a path to a zarr store, which will be opened in read mode. For the root region (`node.identifier == ROOT_ID`), mesh extraction skips the `closing_n_iters` argument. For all other regions, that parameter is passed through to `extract_mesh_from_mask`. Parameters ---------- meshes_dir_path : Path Directory where mesh `.obj` files are written. node Tree node corresponding to the region whose mesh should be created. tree Structure hierarchy containing `node` and its descendants. labels Unique annotation labels present in `annotated_volume`, typically `list(np.unique(annotated_volume))`. annotated_volume : numpy.ndarray or str or Path Annotation volume as a 3D array, or a path to a zarr store containing the annotations. ROOT_ID : int Identifier of the root structure. closing_n_iters : int Number of morphological closing iterations to apply for non-root regions during mesh extraction. decimate_fraction : float Fraction used to decimate the extracted mesh. smooth : bool Whether to smooth the extracted mesh. verbosity : int, optional Verbosity level used for debug output. bbox : tuple of slice, optional Region of `annotated_volume` containing the region's labels, padded so morphological closing can't reach its edge. Only this crop is read and meshed; the output is the same as meshing the full volume. Raises ------ TypeError If `annotated_volume` is neither a NumPy array nor a path to a zarr store. Returns ------- None Mesh data is written to disk when extraction succeeds. """ if verbosity > 0: logger.debug(f"Creating mesh for region {node.identifier}") # Avoid overwriting existing mesh savepath = meshes_dir_path / f"{node.identifier}.obj" # if savepath.exists(): # logger.debug(f"Mesh file save path exists already, skipping.") # return if not isinstance(annotated_volume, np.ndarray): # If annotated_volume is a path to a zarr store, open it if isinstance(annotated_volume, (str, Path)): annotated_volume = zarr.open(annotated_volume, mode="r") else: raise TypeError( "Argument annotated_volume should be a np.ndarray" " or a path to a zarr store" ) # Get labels for region and it's children stree = tree.subtree(node.identifier) ids = list(stree.nodes.keys()) # Keep only labels that are in the annotation volume matched_labels = [i for i in ids if i in labels] if ( not matched_labels ): # it fails if the region and all of its children are not in annotation if verbosity > 0: print(f"No labels found for {node.tag}") return else: # Create mask and extract mesh origin = None if bbox is not None: annotated_volume = annotated_volume[bbox] origin = [s.start for s in bbox] mask = create_masked_array(annotated_volume, ids) if np.sum(mask) == 0: print(f"Empty mask for {node.tag}") else: if node.identifier == ROOT_ID: extract_mesh_from_mask( mask, obj_filepath=savepath, smooth=smooth, decimate_fraction=decimate_fraction, origin=origin, ) else: extract_mesh_from_mask( mask, obj_filepath=savepath, smooth=smooth, closing_n_iters=closing_n_iters, decimate_fraction=decimate_fraction, origin=origin, ) def _label_bounding_boxes(volume, labels, slab_depth=64): """ Return per-label inclusive (lo, hi) voxel bounds, each (len(labels), ndim). `labels` must be sorted. Labels absent from `volume` keep lo > hi, and voxel values not in `labels` are ignored. The volume is scanned in slabs along axis 0 to avoid a full-size index array. """ lo = np.full((len(labels), volume.ndim), np.iinfo(np.int64).max) hi = np.full((len(labels), volume.ndim), -1) for z0 in range(0, volume.shape[0], slab_depth): slab = volume[z0 : z0 + slab_depth] # 1-based label index per voxel, 0 for values not in labels idx = np.minimum(np.searchsorted(labels, slab), len(labels) - 1) found = labels[idx] == slab idx += 1 idx[~found] = 0 objects = find_objects(idx, max_label=len(labels)) for i, sl in enumerate(objects): if sl is None: continue start = [s.start for s in sl] stop = [s.stop - 1 for s in sl] start[0] += z0 stop[0] += z0 lo[i] = np.minimum(lo[i], start) hi[i] = np.maximum(hi[i], stop) return lo, hi
[docs] def create_region_mesh(args): """ Wrap _create_region_mesh which facilitates multiprocessing. """ if not isinstance(args, (tuple, list)): raise TypeError("args must be a tuple or list") return _create_region_mesh(*args)
[docs] def construct_meshes_from_annotation( save_path: Path, volume: np.ndarray, structures_list, closing_n_iters=2, decimate_fraction=0, smooth=False, parallel: bool = True, num_threads: int = -1, verbosity: int = 0, skip_structure_ids=None, ): """ Retrieve or construct atlas region meshes for a given annotation volume. If an atlas is packaged with mesh files, reuse those. Otherwise, construct the meshes using the existing volume and structure tree. Returns a dictionary mapping structure IDs to their corresponding .obj mesh files. Parameters ---------- save_path : Path Path to the directory where new mesh files will be saved. volume : np.ndarray 3D annotation volume. structures_list : list List of structure dictionaries containing id information. smooth: bool if True the surface mesh is smoothed closing_n_iters: int number of iterations of closing morphological operation. set to None to avoid applying morphological operations decimate_fraction: float in range [0, 1]. What fraction of the original number of vertices is to be kept. EG .5 means that 50% of the vertices are kept, the others are removed. parallel: bool If True, uses multiprocessing to speed up mesh creation num_threads: int Number of threads to use for parallel processing. If -1, threads are set to the maximum number based on available memory. If > 0, uses that many threads. verbosity: int Level of verbosity for logging. 0 for no output, 1 for basic info. skip_structure_ids: iterable of int or None If provided, mesh generation for these structure IDs is skipped. Returns ------- dict Dictionary of structure IDs and paths to their .obj mesh files. """ if num_threads == 0: raise ValueError("Number of threads cannot be 0") meshes_dir_path = save_path / "meshes" meshes_dir_path.mkdir(exist_ok=True) tree = get_structures_tree(structures_list) labels = np.unique(volume).astype(np.uint32) # Only used for parallel processing ann_path = save_path / "temp_annotations.zarr" for key, node in tree.nodes.items(): node.data = Region(key in labels) # Mesh each region from its padded bounding box instead of the full # volume. Padding by closing_n_iters + 1 keeps hole filling and closing # identical to running them on the full volume. label_lo, label_hi = _label_bounding_boxes(volume, labels) label_index = {int(label): i for i, label in enumerate(labels)} pad = (closing_n_iters or 0) + 1 volume_shape = volume.shape def region_bbox(node): # Root is closed with extract_mesh_from_mask's default iterations, # not closing_n_iters, and spans almost the whole volume anyway. if node.identifier == tree.root: return None idx = [ label_index[i] for i in tree.subtree(node.identifier).nodes if i in label_index ] if not idx: return None lo = label_lo[idx].min(axis=0) - pad hi = label_hi[idx].max(axis=0) + pad + 1 return tuple( slice(max(int(a), 0), min(int(b), size)) for a, b, size in zip(lo, hi, volume_shape) ) volume_size = volume.size if parallel: compressor = zarr.codecs.BloscCodec( cname="zstd", clevel=6, shuffle="bitshuffle", ) if ann_path.exists(): shutil.rmtree(ann_path) ann_store = zarr.storage.LocalStore(ann_path) zarr.create_array( ann_store, data=volume, compressors=compressor, ) ann_store.close() volume = ann_path root_id = tree.root # Normalise skip set so filtering is a simple membership check if skip_structure_ids is None: skip_structure_ids = set() elif not isinstance(skip_structure_ids, set): skip_structure_ids = set(skip_structure_ids) # Create a list of arguments for each region's mesh creation, # filtering out structures that should be skipped upstream to # avoid unnecessary inter-process communication. args_list = [ ( meshes_dir_path, node, tree, labels, volume, root_id, closing_n_iters, decimate_fraction, smooth, verbosity, region_bbox(node), ) for node in preorder_depth_first_search(tree) if node.identifier not in skip_structure_ids ] if parallel: if num_threads == -1: # Each thread uses ~ 7 times the number of voxels in the volume. mem_per_thread = 7 * volume_size num_threads = get_num_processes( ram_needed_per_process=mem_per_thread, n_max_processes=mp.cpu_count() - 1, fraction_free_ram=0.05, ) logger.info(f"Using {num_threads} threads for mesh creation") with mp.Pool(num_threads) as pool: for _ in track( pool.imap(create_region_mesh, args_list), total=len(args_list), description="Creating meshes", ): pass shutil.rmtree(ann_path) # Clean up temporary annotations zarr store else: for args in track( args_list, total=len(args_list), description="Creating meshes" ): _create_region_mesh(*args) meshes_dict = {} structures_with_mesh = [] for s in structures_list: mesh_path = meshes_dir_path / f'{s["id"]}.obj' if not mesh_path.exists(): print(f"No mesh file exists for: {s}, ignoring it") continue if mesh_path.stat().st_size < 512: print(f"obj file for {s} is too small, ignoring it.") continue structures_with_mesh.append(s) meshes_dict[s["id"]] = mesh_path print( f"In the end, {len(structures_with_mesh)}" " structures with mesh are kept", ) return meshes_dict
[docs] def write_mesh_info( mesh_dir: Path, vertex_quantization_bits: int = 16, transform: List[int] = None, lod_scale_multiplier: float = 1.0, ) -> dict: """ Write the mesh-directory metadata file. Parameters ---------- mesh_dir : Path Path to the mesh directory where the `info` file will be written. vertex_quantization_bits : int, optional Number of bits used for vertex quantization (default is 16). transform : List[int], optional A 4x3 transformation matrix in row-major order (default is identity). lod_scale_multiplier : float, optional Scale multiplier for level-of-detail (LOD) (default is 1.0). Returns ------- dict The metadata dictionary that was written to the `info` file. """ if transform is None: transform = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0] info = { "@type": "neuroglancer_multilod_draco", "vertex_quantization_bits": int(vertex_quantization_bits), "transform": [float(x) for x in transform], "lod_scale_multiplier": float(lod_scale_multiplier), } (mesh_dir / "info").write_text(json.dumps(info, indent=2)) return info
[docs] def write_mesh( mesh: meshio.Mesh, mesh_dir: Path, segment_id: int, vertex_quantization_bits: int = 16, compression_level: int = 0, ): """Write one object as a single-LOD, single-fragment multi-res mesh. Parameters ---------- mesh_dir : path to the mesh directory (gets `info`, `<id>`, `<id>.index`) segment_id : int label of the object vertices : (N, 3) float array of xyz coordinates ("points") faces : (M, 3) int array of triangle vertex indices vertex_quantization_bits : 10 or 16 (Neuroglancer only allows these two) compression_level : 0-10 (higher = more compression) """ vertices = np.ascontiguousarray(mesh.points, dtype=np.float32) faces = np.ascontiguousarray(mesh.cells[0].data, dtype=np.uint32) # Define the single octree cell that holds the whole mesh. # Draco quantizes ALL axes against one uniform range (a bounding *cube*). bbox_min = vertices.min(axis=0) bbox_max = vertices.max(axis=0) qrange = float((bbox_max - bbox_min).max()) if qrange <= 0.0: # degenerate (single point / planar in all axes) qrange = 1.0 grid_origin = bbox_min.astype(np.float32) # Encode the one fragment fragment = DracoPy.encode( vertices, faces, quantization_bits=vertex_quantization_bits, compression_level=compression_level, quantization_range=qrange, quantization_origin=grid_origin.tolist(), ) manifest = MultiLevelPrecomputedMeshManifest( segment_id=int(segment_id), chunk_shape=np.array([qrange, qrange, qrange], dtype=np.float32), grid_origin=grid_origin, num_lods=1, lod_scales=np.array([qrange], dtype=np.float32), vertex_offsets=np.zeros((1, 3), dtype=np.float32), num_fragments_per_lod=np.array([1], dtype=np.uint32), fragment_positions=[np.zeros((1, 3), dtype=np.uint32)], fragment_offsets=[np.array([len(fragment)], dtype=np.uint32)], ) (mesh_dir / str(segment_id)).write_bytes(fragment) (mesh_dir / f"{int(segment_id)}.index").write_bytes(manifest.to_binary())
[docs] class Region(object): """ Class used to add metadata to treelib.Tree during atlas creation. Using this means that you can then filter tree nodes depending on whether they have a mesh/label. """ def __init__(self, has_label): self.has_label = has_label