synapse_net.morphology
1import multiprocessing as mp 2import warnings 3from concurrent import futures 4from typing import Dict, List, Optional, Tuple 5 6import trimesh 7 8import numpy as np 9import pandas as pd 10 11from scipy.ndimage import distance_transform_edt, convolve 12from skimage.graph import MCP 13from skimage.measure import regionprops, marching_cubes, find_contours 14from skimage.morphology import skeletonize, medial_axis, label 15from skimage.segmentation import find_boundaries 16 17 18def _size_filter_ids(ids, props, min_size): 19 if ids is None: 20 ids = [prop.label for prop in props if prop.area > min_size] 21 else: 22 sizes = {prop.label: prop.area for prop in props} 23 ids = [vid for vid in ids if sizes[vid] > min_size] 24 return ids 25 26 27def _compute_radii_naive(vesicles, resolution, ids, min_size): 28 props = regionprops(vesicles) 29 30 ids = _size_filter_ids(ids, props, min_size) 31 radii = {prop.label: resolution[0] * (prop.axis_minor_length + prop.axis_major_length) / 4 32 for prop in props if prop.label in ids} 33 assert len(radii) == len(ids) 34 return ids, radii 35 36 37def _compute_radii_distances(vesicles, resolution, ids, min_size, derive_distances_2d): 38 vesicle_boundaries = find_boundaries(vesicles, mode="thick") 39 40 if derive_distances_2d: 41 42 def dist(input_): 43 return distance_transform_edt(input_ == 0, sampling=resolution[1:]) 44 45 with futures.ThreadPoolExecutor(mp.cpu_count()) as tp: 46 distances = list(tp.map(dist, vesicle_boundaries)) 47 distances = np.stack(distances) 48 49 else: 50 distances = distance_transform_edt(vesicle_boundaries == 0, sampling=resolution) 51 52 props = regionprops(vesicles, intensity_image=distances) 53 ids = _size_filter_ids(ids, props, min_size) 54 radii = {prop.label: prop.intensity_max for prop in props if prop.label in ids} 55 assert len(radii) == len(ids) 56 57 return ids, radii 58 59 60def compute_radii( 61 vesicles: np.ndarray, 62 resolution: Tuple[float, float, float], 63 ids: Optional[List[int]] = None, 64 derive_radius_from_distances: bool = True, 65 derive_distances_2d: bool = True, 66 min_size: int = 500, 67) -> Tuple[List[int], Dict[int, float]]: 68 """Compute the radii for a vesicle segmentation. 69 70 Args: 71 vesicles: The vesicle segmentation. 72 resolution: The pixel / voxel size of the data. 73 ids: Vesicle ids to restrict the radius computation to. 74 derive_radius_from_distances: Whether to derive the radii 75 from the distance to the vesicle boundaries, or from the 76 axis fitted to the vesicle by scikit-image regionprops. 77 derive_distances_2d: Whether to derive the radii individually in 2d 78 or in 3d. Deriving the radii in 3d is beneficial for anisotropic 79 data or data that suffers from the missing wedge effect. 80 min_size: The minimal size for extracting the radii. 81 82 Returns: 83 The ids of the extracted radii. 84 The radii that were computed. 85 """ 86 if derive_radius_from_distances: 87 ids, radii = _compute_radii_distances( 88 vesicles, resolution, 89 ids=ids, min_size=min_size, derive_distances_2d=derive_distances_2d 90 ) 91 else: 92 assert not derive_distances_2d 93 ids, radii = _compute_radii_naive(vesicles, resolution, ids=ids, min_size=min_size) 94 return ids, radii 95 96 97def compute_object_morphology( 98 object_: np.ndarray, 99 structure_name: str, 100 resolution: Tuple[float, float, float] = None 101) -> pd.DataFrame: 102 """Compute the volume and surface area of a 2D or 3D object. 103 104 Args: 105 object_: 2D or 3D binary object array. 106 structure_name: Name of the structure being analyzed. 107 resolution: The pixel / voxel size of the data. 108 109 Returns: 110 Morphology information containing volume and surface area. 111 """ 112 if object_.ndim == 2: 113 # Use find_contours for 2D data 114 contours = find_contours(object_, level=0.5) 115 116 # Compute perimeter (total length of all contours) 117 perimeter = sum( 118 np.sqrt(np.sum(np.diff(contour, axis=0)**2, axis=1)).sum() 119 for contour in contours 120 ) 121 122 # Compute area (number of positive pixels) 123 area = np.sum(object_ > 0) 124 125 # Adjust for resolution if provided 126 if resolution is not None: 127 area *= resolution[0] * resolution[1] 128 perimeter *= resolution[0] 129 130 morphology = pd.DataFrame({ 131 "structure": [structure_name], 132 "area [pixel^2]" if resolution is None else "area [nm^2]": [area], 133 "perimeter [pixel]" if resolution is None else "perimeter [nm]": [perimeter], 134 }) 135 136 elif object_.ndim == 3: 137 # Use marching_cubes for 3D data 138 verts, faces, normals, _ = marching_cubes( 139 object_, 140 spacing=(1.0, 1.0, 1.0) if resolution is None else resolution, 141 ) 142 143 mesh = trimesh.Trimesh(vertices=verts, faces=faces, vertex_normals=normals) 144 surface = mesh.area 145 if mesh.is_watertight: 146 volume = np.abs(mesh.volume) 147 else: 148 warnings.warn("Could not compute mesh surface for the volume; setting it to NaN.") 149 volume = np.nan 150 151 morphology = pd.DataFrame({ 152 "structure": [structure_name], 153 "volume [pixel^3]" if resolution is None else "volume [nm^3]": [volume], 154 "surface [pixel^2]" if resolution is None else "surface [nm^2]": [surface], 155 }) 156 157 else: 158 raise ValueError("Input object must be a 2D or 3D numpy array.") 159 160 return morphology 161 162 163def _find_endpoints(component): 164 # Define a 3x3 kernel to count neighbors 165 kernel = np.ones((3, 3), dtype=int) 166 neighbor_count = convolve(component.astype(int), kernel, mode="constant", cval=0) 167 endpoints = np.argwhere((component == 1) & (neighbor_count == 2)) # Degree = 1 168 return endpoints 169 170 171def _compute_longest_path(component, endpoints): 172 # Use the first endpoint as the source 173 src = tuple(endpoints[0]) 174 cost = np.where(component, 1, np.inf) # Cost map: 1 for skeleton, inf for background 175 mcp = MCP(cost) 176 _, traceback = mcp.find_costs([src]) 177 178 # Use the second endpoint as the destination 179 dst = tuple(endpoints[-1]) 180 181 # Trace back the path 182 path = np.zeros_like(component, dtype=bool) 183 current = dst 184 185 # Extract offsets from the MCP object 186 offsets = np.array(mcp.offsets) 187 nrows, ncols = component.shape 188 189 while current != src: 190 path[current] = True 191 current_offset_index = traceback[current] 192 if current_offset_index < 0: 193 # No valid path found 194 break 195 offset = offsets[current_offset_index] 196 # Move to the predecessor 197 current = (current[0] - offset[0], current[1] - offset[1]) 198 # Ensure indices are within bounds 199 if not (0 <= current[0] < nrows and 0 <= current[1] < ncols): 200 break 201 202 path[src] = True # Include the source 203 return path 204 205 206def _prune_skeleton_longest_path(skeleton): 207 pruned_skeleton = np.zeros_like(skeleton, dtype=bool) 208 209 # Label connected components in the skeleton 210 labeled_skeleton, num_labels = label(skeleton, return_num=True) 211 212 for label_id in range(1, num_labels + 1): 213 # Isolate the current connected component 214 component = (labeled_skeleton == label_id) 215 216 # Find the endpoints of the component 217 endpoints = _find_endpoints(component) 218 if len(endpoints) < 2: 219 continue # Skip if there are no valid endpoints 220 elif len(endpoints) == 2: # Nothing to prune 221 pruned_skeleton |= component 222 continue 223 224 # Compute the longest path using MCP 225 longest_path = _compute_longest_path(component, endpoints) 226 pruned_skeleton |= longest_path 227 228 return pruned_skeleton.astype(skeleton.dtype) 229 230 231def skeletonize_object( 232 segmentation: np.ndarray, 233 method: str = "skeletonize", 234 prune: bool = True, 235 min_prune_size: int = 10, 236) -> np.ndarray: 237 """Skeletonize a 3D object by inidividually skeletonizing each slice. 238 239 Args: 240 segmentation: The segmented object. 241 method: The method to use for skeletonization. Either 'skeletonize' or 'medial_axis'. 242 prune: Whether to prune the skeleton. 243 min_prune_size: The minimal size of components after pruning. 244 245 Returns: 246 The skeletonized object. 247 """ 248 assert method in ("skeletonize", "medial_axis") 249 seg_thin = np.zeros_like(segmentation) 250 skeletor = skeletonize if method == "skeletonize" else medial_axis 251 # Parallelize? 252 for z in range(segmentation.shape[0]): 253 skeleton = skeletor(segmentation[z]) 254 255 if prune: 256 skeleton = _prune_skeleton_longest_path(skeleton) 257 if min_prune_size > 0: 258 skeleton = label(skeleton) 259 ids, sizes = np.unique(skeleton, return_counts=True) 260 ids, sizes = ids[1:], sizes[1:] 261 skeleton = np.isin(skeleton, ids[sizes >= min_prune_size]) 262 263 seg_thin[z] = skeleton 264 return seg_thin
def
compute_radii( vesicles: numpy.ndarray, resolution: Tuple[float, float, float], ids: Optional[List[int]] = None, derive_radius_from_distances: bool = True, derive_distances_2d: bool = True, min_size: int = 500) -> Tuple[List[int], Dict[int, float]]:
61def compute_radii( 62 vesicles: np.ndarray, 63 resolution: Tuple[float, float, float], 64 ids: Optional[List[int]] = None, 65 derive_radius_from_distances: bool = True, 66 derive_distances_2d: bool = True, 67 min_size: int = 500, 68) -> Tuple[List[int], Dict[int, float]]: 69 """Compute the radii for a vesicle segmentation. 70 71 Args: 72 vesicles: The vesicle segmentation. 73 resolution: The pixel / voxel size of the data. 74 ids: Vesicle ids to restrict the radius computation to. 75 derive_radius_from_distances: Whether to derive the radii 76 from the distance to the vesicle boundaries, or from the 77 axis fitted to the vesicle by scikit-image regionprops. 78 derive_distances_2d: Whether to derive the radii individually in 2d 79 or in 3d. Deriving the radii in 3d is beneficial for anisotropic 80 data or data that suffers from the missing wedge effect. 81 min_size: The minimal size for extracting the radii. 82 83 Returns: 84 The ids of the extracted radii. 85 The radii that were computed. 86 """ 87 if derive_radius_from_distances: 88 ids, radii = _compute_radii_distances( 89 vesicles, resolution, 90 ids=ids, min_size=min_size, derive_distances_2d=derive_distances_2d 91 ) 92 else: 93 assert not derive_distances_2d 94 ids, radii = _compute_radii_naive(vesicles, resolution, ids=ids, min_size=min_size) 95 return ids, radii
Compute the radii for a vesicle segmentation.
Arguments:
- vesicles: The vesicle segmentation.
- resolution: The pixel / voxel size of the data.
- ids: Vesicle ids to restrict the radius computation to.
- derive_radius_from_distances: Whether to derive the radii from the distance to the vesicle boundaries, or from the axis fitted to the vesicle by scikit-image regionprops.
- derive_distances_2d: Whether to derive the radii individually in 2d or in 3d. Deriving the radii in 3d is beneficial for anisotropic data or data that suffers from the missing wedge effect.
- min_size: The minimal size for extracting the radii.
Returns:
The ids of the extracted radii. The radii that were computed.
def
compute_object_morphology( object_: numpy.ndarray, structure_name: str, resolution: Tuple[float, float, float] = None) -> pandas.core.frame.DataFrame:
98def compute_object_morphology( 99 object_: np.ndarray, 100 structure_name: str, 101 resolution: Tuple[float, float, float] = None 102) -> pd.DataFrame: 103 """Compute the volume and surface area of a 2D or 3D object. 104 105 Args: 106 object_: 2D or 3D binary object array. 107 structure_name: Name of the structure being analyzed. 108 resolution: The pixel / voxel size of the data. 109 110 Returns: 111 Morphology information containing volume and surface area. 112 """ 113 if object_.ndim == 2: 114 # Use find_contours for 2D data 115 contours = find_contours(object_, level=0.5) 116 117 # Compute perimeter (total length of all contours) 118 perimeter = sum( 119 np.sqrt(np.sum(np.diff(contour, axis=0)**2, axis=1)).sum() 120 for contour in contours 121 ) 122 123 # Compute area (number of positive pixels) 124 area = np.sum(object_ > 0) 125 126 # Adjust for resolution if provided 127 if resolution is not None: 128 area *= resolution[0] * resolution[1] 129 perimeter *= resolution[0] 130 131 morphology = pd.DataFrame({ 132 "structure": [structure_name], 133 "area [pixel^2]" if resolution is None else "area [nm^2]": [area], 134 "perimeter [pixel]" if resolution is None else "perimeter [nm]": [perimeter], 135 }) 136 137 elif object_.ndim == 3: 138 # Use marching_cubes for 3D data 139 verts, faces, normals, _ = marching_cubes( 140 object_, 141 spacing=(1.0, 1.0, 1.0) if resolution is None else resolution, 142 ) 143 144 mesh = trimesh.Trimesh(vertices=verts, faces=faces, vertex_normals=normals) 145 surface = mesh.area 146 if mesh.is_watertight: 147 volume = np.abs(mesh.volume) 148 else: 149 warnings.warn("Could not compute mesh surface for the volume; setting it to NaN.") 150 volume = np.nan 151 152 morphology = pd.DataFrame({ 153 "structure": [structure_name], 154 "volume [pixel^3]" if resolution is None else "volume [nm^3]": [volume], 155 "surface [pixel^2]" if resolution is None else "surface [nm^2]": [surface], 156 }) 157 158 else: 159 raise ValueError("Input object must be a 2D or 3D numpy array.") 160 161 return morphology
Compute the volume and surface area of a 2D or 3D object.
Arguments:
- object_: 2D or 3D binary object array.
- structure_name: Name of the structure being analyzed.
- resolution: The pixel / voxel size of the data.
Returns:
Morphology information containing volume and surface area.
def
skeletonize_object( segmentation: numpy.ndarray, method: str = 'skeletonize', prune: bool = True, min_prune_size: int = 10) -> numpy.ndarray:
232def skeletonize_object( 233 segmentation: np.ndarray, 234 method: str = "skeletonize", 235 prune: bool = True, 236 min_prune_size: int = 10, 237) -> np.ndarray: 238 """Skeletonize a 3D object by inidividually skeletonizing each slice. 239 240 Args: 241 segmentation: The segmented object. 242 method: The method to use for skeletonization. Either 'skeletonize' or 'medial_axis'. 243 prune: Whether to prune the skeleton. 244 min_prune_size: The minimal size of components after pruning. 245 246 Returns: 247 The skeletonized object. 248 """ 249 assert method in ("skeletonize", "medial_axis") 250 seg_thin = np.zeros_like(segmentation) 251 skeletor = skeletonize if method == "skeletonize" else medial_axis 252 # Parallelize? 253 for z in range(segmentation.shape[0]): 254 skeleton = skeletor(segmentation[z]) 255 256 if prune: 257 skeleton = _prune_skeleton_longest_path(skeleton) 258 if min_prune_size > 0: 259 skeleton = label(skeleton) 260 ids, sizes = np.unique(skeleton, return_counts=True) 261 ids, sizes = ids[1:], sizes[1:] 262 skeleton = np.isin(skeleton, ids[sizes >= min_prune_size]) 263 264 seg_thin[z] = skeleton 265 return seg_thin
Skeletonize a 3D object by inidividually skeletonizing each slice.
Arguments:
- segmentation: The segmented object.
- method: The method to use for skeletonization. Either 'skeletonize' or 'medial_axis'.
- prune: Whether to prune the skeleton.
- min_prune_size: The minimal size of components after pruning.
Returns:
The skeletonized object.