skeletor.post
The skeletor.post module contains functions to post-process skeletons after
skeletonization.
Fixing issues with skeletons
Depending on your mesh, pre-processing and the parameters you chose for skeletonization, chances are that your skeleton will not come out perfectly.
skeletor.post.clean_up can help you solve some potential issues:
- skeleton nodes (vertices) that outside or right on the surface instead of centered inside the mesh
- superfluous "hairs" on otherwise straight bits
skeletor.post.smooth will smooth out the skeleton.
skeletor.post.despike can help you remove spikes in the skeleton where
single nodes are out of aligment.
skeletor.post.remove_bristles will remove bristles from the skeleton.
Computing radius information
Only skeletor.skeletonize.by_wavefront() provides radii off the bat. For all
other methods, you might want to run skeletor.post.radii can help you
(re-)generate radius information for the skeletons.
1# This script is part of skeletor (http://www.github.com/navis-org/skeletor). 2# Copyright (C) 2018 Philipp Schlegel 3# 4# This program is free software: you can redistribute it and/or modify 5# it under the terms of the GNU General Public License as published by 6# the Free Software Foundation, either version 3 of the License, or 7# (at your option) any later version. 8# 9# This program is distributed in the hope that it will be useful, 10# but WITHOUT ANY WARRANTY; without even the implied warranty of 11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12# GNU General Public License for more details. 13# 14# You should have received a copy of the GNU General Public License 15# along with this program. 16 17r""" 18The `skeletor.post` module contains functions to post-process skeletons after 19skeletonization. 20 21### Fixing issues with skeletons 22 23Depending on your mesh, pre-processing and the parameters you chose for 24skeletonization, chances are that your skeleton will not come out perfectly. 25 26`skeletor.post.clean_up` can help you solve some potential issues: 27 28- skeleton nodes (vertices) that outside or right on the surface instead of 29 centered inside the mesh 30- superfluous "hairs" on otherwise straight bits 31 32`skeletor.post.smooth` will smooth out the skeleton. 33 34`skeletor.post.despike` can help you remove spikes in the skeleton where 35single nodes are out of aligment. 36 37`skeletor.post.remove_bristles` will remove bristles from the skeleton. 38 39### Computing radius information 40 41Only `skeletor.skeletonize.by_wavefront()` provides radii off the bat. For all 42other methods, you might want to run `skeletor.post.radii` can help you 43(re-)generate radius information for the skeletons. 44 45""" 46 47from .radiusextraction import radii 48from .postprocessing import clean_up, smooth, despike, remove_bristles, recenter_vertices, fix_outside_edges 49 50__docformat__ = "numpy" 51__all__ = ["radii", "clean_up", "smooth", "despike", "remove_bristles", "recenter_vertices", "fix_outside_edges"]
29def radii(s, mesh=None, method='knn', aggregate='mean', validate=False, **kwargs): 30 """Extract radii for given skeleton table. 31 32 Important 33 --------- 34 This function really only produces useful radii if the skeleton is centered 35 inside the mesh. `by_wavefront` does that by default whereas all other 36 skeletonization methods don't. Your best bet to get centered skeletons is 37 to contract the mesh first (`sk.pre.contract`). 38 39 Parameters 40 ---------- 41 s : skeletor.Skeleton 42 Skeleton to clean up. 43 mesh : trimesh.Trimesh, optional 44 Original mesh (e.g. before contraction). If not provided will 45 use the mesh associated with ``s``. 46 method : "knn" | "ray" 47 Whether and how to add radius information to each node:: 48 49 - "knn" uses k-nearest-neighbors to get radii: fast but 50 potential for being very wrong 51 - "ray" uses ray-casting to get radii: slower but sometimes 52 less wrong 53 54 aggregate : "mean" | "median" | "max" | "min" | "percentile75" 55 Function used to aggregate radii over sample (i.e. across 56 k nearest-neighbors or ray intersections) 57 validate : bool 58 If True, will try to fix potential issues with the mesh 59 (e.g. infinite values, duplicate vertices, degenerate faces) 60 before skeletonization. Note that this might make changes to 61 your mesh inplace! 62 **kwargs 63 Keyword arguments are passed to the respective method: 64 65 For method "knn":: 66 67 n : int (default 5) 68 Radius will be the mean over n nearest-neighbors. 69 70 For method "ray":: 71 72 n_rays : int (default 20) 73 Number of rays to cast for each node. 74 projection : "sphere" (default) | "tangents" 75 Whether to cast rays in a sphere around each node or in a 76 circle orthogonally to the node's tangent vector. 77 fallback : "knn" (default) | None | number 78 If a point is outside or right on the surface of the mesh 79 the raycasting will return nonesense results. We can either 80 ignore those cases (``None``), assign a arbitrary number or 81 we can fall back to radii from k-nearest-neighbors (``knn``). 82 83 Returns 84 ------- 85 None 86 But attaches `radius` to the skeleton's SWC table. Existing 87 values are replaced! 88 89 """ 90 if isinstance(mesh, type(None)): 91 mesh = s.mesh 92 93 mesh = make_trimesh(mesh, validate=True) 94 95 if method == 'knn': 96 radius = get_radius_knn(s.swc[['x', 'y', 'z']].values, 97 aggregate=aggregate, 98 mesh=mesh, **kwargs) 99 elif method == 'ray': 100 radius = get_radius_ray(s.swc, 101 mesh=mesh, 102 aggregate=aggregate, 103 **kwargs) 104 else: 105 raise ValueError(f'Unknown method "{method}"') 106 107 s.swc['radius'] = radius 108 109 return
Extract radii for given skeleton table.
Important
This function really only produces useful radii if the skeleton is centered
inside the mesh. by_wavefront does that by default whereas all other
skeletonization methods don't. Your best bet to get centered skeletons is
to contract the mesh first (sk.pre.contract).
Parameters
- s (skeletor.Skeleton): Skeleton to clean up.
- mesh (trimesh.Trimesh, optional):
Original mesh (e.g. before contraction). If not provided will
use the mesh associated with
s. method ("knn" | "ray"): Whether and how to add radius information to each node::
- "knn" uses k-nearest-neighbors to get radii: fast but potential for being very wrong - "ray" uses ray-casting to get radii: slower but sometimes less wrong- aggregate ("mean" | "median" | "max" | "min" | "percentile75"): Function used to aggregate radii over sample (i.e. across k nearest-neighbors or ray intersections)
- validate (bool): If True, will try to fix potential issues with the mesh (e.g. infinite values, duplicate vertices, degenerate faces) before skeletonization. Note that this might make changes to your mesh inplace!
- **kwargs: Keyword arguments are passed to the respective method:
For method "knn"::
n : int (default 5)
Radius will be the mean over n nearest-neighbors.
For method "ray"::
n_rays : int (default 20)
Number of rays to cast for each node.
projection : "sphere" (default) | "tangents"
Whether to cast rays in a sphere around each node or in a
circle orthogonally to the node's tangent vector.
fallback : "knn" (default) | None | number
If a point is outside or right on the surface of the mesh
the raycasting will return nonesense results. We can either
ignore those cases (``None``), assign a arbitrary number or
we can fall back to radii from k-nearest-neighbors (``knn``).
Returns
- None: But attaches
radiusto the skeleton's SWC table. Existing values are replaced!
30def clean_up(s, mesh=None, validate=False, inplace=False, **kwargs): 31 """Clean up the skeleton. 32 33 This function bundles a bunch of procedures to clean up the skeleton: 34 35 1. Remove twigs that are running parallel to their parent branch 36 2. Move nodes outside the mesh back inside (or at least snap to surface) 37 38 Note that this is not a magic bullet and some of this will not work (well) 39 if the original mesh was degenerate (e.g. internal faces or not watertight) 40 to begin with. 41 42 Parameters 43 ---------- 44 s : skeletor.Skeleton 45 Skeleton to clean up. 46 mesh : trimesh.Trimesh, optional 47 Original mesh (e.g. before contraction). If not provided will 48 use the mesh associated with ``s``. 49 validate : bool 50 If True, will try to fix potential issues with the mesh 51 (e.g. infinite values, duplicate vertices, degenerate faces) 52 before cleaning up. Note that this might change your mesh 53 inplace! 54 inplace : bool 55 If False will make and return a copy of the skeleton. If True, 56 will modify the `s` inplace. 57 58 **kwargs 59 Keyword arguments are passed to the bundled function. 60 61 For `skeletor.postprocessing.drop_parallel_twigs`:: 62 63 theta : float (default 0.01) 64 For each twig we generate the dotproduct between the tangent 65 vectors of it and its parents. If these line up perfectly the 66 dotproduct will equal 1. ``theta`` determines how much that 67 value can differ from 1 for us to still prune the twig: higher 68 theta = more pruning. 69 70 Returns 71 ------- 72 s_clean : skeletor.Skeleton 73 Hopefully improved skeleton. 74 75 """ 76 if isinstance(mesh, type(None)): 77 mesh = s.mesh 78 79 mesh = make_trimesh(mesh, validate=validate) 80 81 if not inplace: 82 s = s.copy() 83 84 # Drop parallel twigs 85 _ = drop_parallel_twigs(s, theta=kwargs.get("theta", 0.01), inplace=True) 86 87 # Recenter vertices 88 _ = recenter_vertices(s, mesh, inplace=True) 89 90 return s
Clean up the skeleton.
This function bundles a bunch of procedures to clean up the skeleton:
- Remove twigs that are running parallel to their parent branch
- Move nodes outside the mesh back inside (or at least snap to surface)
Note that this is not a magic bullet and some of this will not work (well) if the original mesh was degenerate (e.g. internal faces or not watertight) to begin with.
Parameters
- s (skeletor.Skeleton): Skeleton to clean up.
- mesh (trimesh.Trimesh, optional):
Original mesh (e.g. before contraction). If not provided will
use the mesh associated with
s. - validate (bool): If True, will try to fix potential issues with the mesh (e.g. infinite values, duplicate vertices, degenerate faces) before cleaning up. Note that this might change your mesh inplace!
- inplace (bool):
If False will make and return a copy of the skeleton. If True,
will modify the
sinplace. - **kwargs: Keyword arguments are passed to the bundled function.
For skeletor.postprocessing.drop_parallel_twigs::
theta : float (default 0.01)
For each twig we generate the dotproduct between the tangent
vectors of it and its parents. If these line up perfectly the
dotproduct will equal 1. theta determines how much that
value can differ from 1 for us to still prune the twig: higher
theta = more pruning.
Returns
- s_clean (skeletor.Skeleton): Hopefully improved skeleton.
840def smooth( 841 s, window: int = 3, to_smooth: list = ["x", "y", "z"], inplace: bool = False 842): 843 """Smooth skeleton using rolling windows. 844 845 Parameters 846 ---------- 847 s : skeletor.Skeleton 848 Skeleton to be processed. 849 window : int, optional 850 Size (N observations) of the rolling window in number of 851 nodes. 852 to_smooth : list 853 Columns of the node table to smooth. Should work with any 854 numeric column (e.g. 'radius'). 855 inplace : bool 856 If False will make and return a copy of the skeleton. If 857 True, will modify the `s` inplace. 858 859 Returns 860 ------- 861 s : skeletor.Skeleton 862 Skeleton with smoothed node table. 863 864 """ 865 if not inplace: 866 s = s.copy() 867 868 # Prepare nodes (add parent_dist for later, set index) 869 nodes = s.swc.set_index("node_id", inplace=False).copy() 870 871 to_smooth = np.array(to_smooth) 872 miss = to_smooth[~np.isin(to_smooth, nodes.columns)] 873 if len(miss): 874 raise ValueError(f"Column(s) not found in node table: {miss}") 875 876 # Go over each segment and smooth 877 for seg in s.get_segments(): 878 # Get this segment's parent distances and get cumsum 879 this_co = nodes.loc[seg, to_smooth] 880 881 interp = this_co.rolling(window, min_periods=1).mean() 882 883 nodes.loc[seg, to_smooth] = interp.values 884 885 # Reassign nodes 886 s.swc = nodes.reset_index(drop=False, inplace=False) 887 888 return s
Smooth skeleton using rolling windows.
Parameters
- s (skeletor.Skeleton): Skeleton to be processed.
- window (int, optional): Size (N observations) of the rolling window in number of nodes.
- to_smooth (list): Columns of the node table to smooth. Should work with any numeric column (e.g. 'radius').
- inplace (bool):
If False will make and return a copy of the skeleton. If
True, will modify the
sinplace.
Returns
- s (skeletor.Skeleton): Skeleton with smoothed node table.
891def despike(s, sigma=5, max_spike_length=1, inplace=False, reverse=False): 892 r"""Remove spikes in skeleton. 893 894 For each node A, the euclidean distance to its next successor (parent) 895 B and that node's successor C (i.e A->B->C) is computed. If 896 :math:`\\frac{dist(A,B)}{dist(A,C)}>sigma`, node B is considered a spike 897 and realigned between A and C. 898 899 Parameters 900 ---------- 901 x : skeletor.Skeleton 902 Skeleton to be processed. 903 sigma : float | int, optional 904 Threshold for spike detection. Smaller sigma = more 905 aggressive spike detection. 906 max_spike_length : int, optional 907 Determines how long (# of nodes) a spike can be. 908 inplace : bool, optional 909 If False, a copy of the neuron is returned. 910 reverse : bool, optional 911 If True, will **also** walk the segments from proximal 912 to distal. Use this to catch spikes on e.g. terminal 913 nodes. 914 915 Returns 916 ------- 917 s skeletor.Skeleton 918 Despiked neuron. 919 920 """ 921 if not inplace: 922 s = s.copy() 923 924 # Index nodes table by node ID 925 this_nodes = s.swc.set_index("node_id", inplace=False) 926 927 segments = s.get_segments() 928 segs_to_walk = segments 929 930 if reverse: 931 segs_to_walk += segs_to_walk[::-1] 932 933 # For each spike length do -> do this in reverse to correct the long 934 # spikes first 935 for l in list(range(1, max_spike_length + 1))[::-1]: 936 # Go over all segments 937 for seg in segs_to_walk: 938 # Get nodes A, B and C of this segment 939 this_A = this_nodes.loc[seg[: -l - 1]] 940 this_B = this_nodes.loc[seg[l:-1]] 941 this_C = this_nodes.loc[seg[l + 1 :]] 942 943 # Get coordinates 944 A = this_A[["x", "y", "z"]].values 945 B = this_B[["x", "y", "z"]].values 946 C = this_C[["x", "y", "z"]].values 947 948 # Calculate euclidian distances A->B and A->C 949 dist_AB = np.linalg.norm(A - B, axis=1) 950 dist_AC = np.linalg.norm(A - C, axis=1) 951 952 # Get the spikes 953 spikes_ix = np.where( 954 np.divide(dist_AB, dist_AC, where=dist_AC != 0) > sigma 955 )[0] 956 spikes = this_B.iloc[spikes_ix] 957 958 if not spikes.empty: 959 # Interpolate new position(s) between A and C 960 new_positions = A[spikes_ix] + (C[spikes_ix] - A[spikes_ix]) / 2 961 962 this_nodes.loc[spikes.index, ["x", "y", "z"]] = new_positions 963 964 # Reassign node table 965 s.swc = this_nodes.reset_index(drop=False, inplace=False) 966 967 return s
Remove spikes in skeleton.
For each node A, the euclidean distance to its next successor (parent) B and that node's successor C (i.e A->B->C) is computed. If \( \frac{dist(A,B)}{dist(A,C)}>sigma \), node B is considered a spike and realigned between A and C.
Parameters
- x (skeletor.Skeleton): Skeleton to be processed.
- sigma (float | int, optional): Threshold for spike detection. Smaller sigma = more aggressive spike detection.
- max_spike_length (int, optional): Determines how long (# of nodes) a spike can be.
- inplace (bool, optional): If False, a copy of the neuron is returned.
- reverse (bool, optional): If True, will also walk the segments from proximal to distal. Use this to catch spikes on e.g. terminal nodes.
Returns
- s skeletor.Skeleton: Despiked neuron.
93def remove_bristles(s, mesh=None, los_only=False, inplace=False): 94 """Remove "bristles" that sometimes occurr along the backbone. 95 96 Works by finding terminal twigs that consist of only a single node. 97 98 Parameters 99 ---------- 100 s : skeletor.Skeleton 101 Skeleton to clean up. 102 mesh : trimesh.Trimesh, optional 103 Original mesh (e.g. before contraction). If not provided will 104 use the mesh associated with ``s``. 105 los_only : bool 106 If True, will only remove bristles that are in line of sight of 107 their parent. If False, will remove all single-node bristles. 108 inplace : bool 109 If False will make and return a copy of the skeleton. If True, 110 will modify the `s` inplace. 111 112 Returns 113 ------- 114 s : skeletor.Skeleton 115 Skeleton with single-node twigs removed. 116 117 """ 118 if isinstance(mesh, type(None)): 119 mesh = s.mesh 120 121 # Make a copy of the skeleton 122 if not inplace: 123 s = s.copy() 124 125 # Find branch points 126 pcount = s.swc[s.swc.parent_id >= 0].groupby("parent_id").size() 127 bp = pcount[pcount > 1].index 128 129 # Find terminal twigs 130 twigs = s.swc[~s.swc.node_id.isin(s.swc.parent_id)] 131 twigs = twigs[twigs.parent_id.isin(bp)] 132 133 if twigs.empty: 134 return s 135 136 if los_only: 137 # Initialize ncollpyde Volume 138 coll = ncollpyde.Volume(mesh.vertices, mesh.faces, validate=False) 139 140 # Remove twigs that aren't inside the volume 141 twigs = twigs[coll.contains(twigs[["x", "y", "z"]].values)] 142 143 # Generate rays between all pairs and their parents 144 sources = twigs[["x", "y", "z"]].values 145 targets = ( 146 s.swc.set_index("node_id").loc[twigs.parent_id, ["x", "y", "z"]].values 147 ) 148 149 # Get intersections: `ix` points to index of line segment; `loc` is the 150 # x/y/z coordinate of the intersection and `is_backface` is True if 151 # intersection happened at the inside of a mesh 152 ix, loc, is_backface = coll.intersections(sources, targets) 153 154 # Find pairs of twigs with no intersection - i.e. with line of sight 155 los = ~np.isin(np.arange(sources.shape[0]), ix) 156 157 # To remove: have line of sight 158 to_remove = twigs[los] 159 else: 160 to_remove = twigs 161 162 s.swc = s.swc[~s.swc.node_id.isin(to_remove.node_id)].copy() 163 164 # Update the mesh map 165 mesh_map = getattr(s, "mesh_map", None) 166 if not isinstance(mesh_map, type(None)): 167 for t in to_remove.itertuples(): 168 mesh_map[mesh_map == t.node_id] = t.parent_id 169 170 # Reindex nodes 171 s.reindex(inplace=True) 172 173 return s
Remove "bristles" that sometimes occurr along the backbone.
Works by finding terminal twigs that consist of only a single node.
Parameters
- s (skeletor.Skeleton): Skeleton to clean up.
- mesh (trimesh.Trimesh, optional):
Original mesh (e.g. before contraction). If not provided will
use the mesh associated with
s. - los_only (bool): If True, will only remove bristles that are in line of sight of their parent. If False, will remove all single-node bristles.
- inplace (bool):
If False will make and return a copy of the skeleton. If True,
will modify the
sinplace.
Returns
- s (skeletor.Skeleton): Skeleton with single-node twigs removed.
176def recenter_vertices(s, mesh=None, inplace=False): 177 """Move nodes that ended up outside the mesh back inside. 178 179 Nodes can end up outside the original mesh e.g. if the mesh contraction 180 didn't do a good job (most likely because of internal/degenerate faces that 181 messed up the normals). This function rectifies this by snapping those nodes 182 nodes back to the closest vertex and then tries to move them into the 183 mesh's center. That second step is not guaranteed to work but at least you 184 won't have any more nodes outside the mesh. 185 186 Please note that if connected (!) nodes end up on the same position (i.e 187 because they snapped to the same vertex), we will collapse them. 188 189 Parameters 190 ---------- 191 s : skeletor.Skeleton 192 mesh : trimesh.Trimesh 193 Original mesh. 194 inplace : bool 195 If False will make and return a copy of the skeleton. If True, 196 will modify the `s` inplace. 197 198 Returns 199 ------- 200 s : skeletor.Skeleton 201 Skeleton with vertices recentered. 202 203 """ 204 if isinstance(mesh, type(None)): 205 mesh = s.mesh 206 207 # Copy skeleton 208 if not inplace: 209 s = s.copy() 210 211 # Find nodes that are outside the mesh 212 coll = ncollpyde.Volume(mesh.vertices, mesh.faces, validate=False) 213 outside = ~coll.contains(s.vertices) 214 215 # Skip if all inside 216 if not any(outside): 217 return s 218 219 # For each outside find the closest vertex 220 tree = scipy.spatial.cKDTree(mesh.vertices) 221 222 # Find nodes that are right on top of original vertices 223 dist, ix = tree.query(s.vertices[outside]) 224 225 # We don't want to just snap them back to the closest vertex but try to find 226 # the center. For this we will: 227 # 1. Move each vertex inside the mesh by just a bit 228 # 2. Cast a ray along the vertices' normals and find the opposite sides of the mesh 229 # 3. Calculate the distance 230 231 # Get the closest vertex... 232 closest_vertex = mesh.vertices[ix] 233 # .. and offset the vertex positions by just a bit so they "should" be 234 # inside the mesh. In reality that doesn't always happen if the mesh is not 235 # watertight 236 vnormals = mesh.vertex_normals[ix] 237 sources = closest_vertex - vnormals 238 239 # Prepare rays to cast 240 targets = sources - vnormals * 1e4 241 242 # Cast rays 243 hit_ix, loc, is_backface = coll.intersections(sources, targets) 244 245 # center-point if ray hits, otherwise keep closest_vertex 246 final_pos = closest_vertex.copy() 247 248 if len(loc) != 0: 249 # Get half-vector 250 halfvec = np.zeros(sources.shape) 251 halfvec[hit_ix] = (loc - closest_vertex[hit_ix]) / 2 252 253 # Offset vertices 254 candidate = closest_vertex + halfvec 255 256 # Keep only those that are properly inside the mesh 257 now_inside = coll.contains(candidate) 258 final_pos[now_inside] = candidate[now_inside] 259 260 # try harder to get strictly inside 261 still_outside = ~coll.contains(final_pos) 262 if still_outside.any(): 263 edge_len = get_edges_unique(mesh, lengths=True)[1] 264 push = ( 265 edge_len.mean() / 100 266 if edge_len.size 267 else 1e-4 268 ) # for high-res meshes 269 push = max(push, 1e-6) 270 271 candidate_in = closest_vertex - vnormals * push 272 candidate_out = closest_vertex + vnormals * push 273 274 in_ok = coll.contains(candidate_in) 275 out_ok = coll.contains(candidate_out) 276 277 use_in = still_outside & in_ok 278 use_out = still_outside & ~in_ok & out_ok 279 280 final_pos[use_in] = candidate_in[use_in] 281 final_pos[use_out] = candidate_out[use_out] 282 283 # Keep only those that are properly inside the mesh and fall back to the 284 # closest vertex if that's not the case 285 now_inside = coll.contains(final_pos) 286 final_pos[~now_inside] = closest_vertex[~now_inside] 287 288 # Replace coordinates 289 s.swc.loc[outside, "x"] = final_pos[:, 0] 290 s.swc.loc[outside, "y"] = final_pos[:, 1] 291 s.swc.loc[outside, "z"] = final_pos[:, 2] 292 293 # At this point we may have nodes that snapped to the same vertex and 294 # therefore end up at the same position. We will collapse those nodes 295 # - but only if they are actually connected! 296 # First find duplicate locations 297 u, i, c = np.unique(s.vertices, return_counts=True, return_inverse=True, axis=0) 298 299 # If any coordinates have counter higher 1 300 if c.max() > 1: 301 rewire = {} 302 # Find out which unique coordinates are duplicated 303 dupl = np.where(c > 1)[0] 304 305 # Go over each duplicated coordinate 306 for ix in dupl: 307 # Find the nodes on this duplicate coordinate 308 node_ix = np.where(i == ix)[0] 309 310 # Get their edges 311 edges = s.edges[np.all(np.isin(s.edges, node_ix), axis=1)] 312 313 # We will work on the graph to collapse nodes sequentially A->B->C 314 G = nx.DiGraph() 315 G.add_edges_from(edges) 316 for cc in nx.connected_components(G.to_undirected()): 317 # Root is the node without any outdegree in this subgraph 318 root = [n for n in cc if G.out_degree[n] == 0][0] 319 # We don't want to collapse into `root` because it's not actually 320 # among the nodes with the same coordinates but rather the "last" 321 # nodes parent 322 clps_into = next(G.predecessors(root)) 323 # Keep track of how we need to rewire 324 rewire.update({c: clps_into for c in cc if c not in {root, clps_into}}) 325 326 # Only mess with the skeleton if there were nodes to be merged 327 if rewire: 328 # Rewire 329 s.swc["parent_id"] = s.swc.parent_id.map(lambda x: rewire.get(x, x)) 330 331 # Drop nodes that were collapsed 332 s.swc = s.swc.loc[~s.swc.node_id.isin(rewire)] 333 334 # Update mesh map 335 if not isinstance(s.mesh_map, type(None)): 336 s.mesh_map = [rewire.get(x, x) for x in s.mesh_map] 337 338 # Reindex to make vertex IDs continous again 339 s.reindex(inplace=True) 340 341 # This prevents future SettingsWithCopy Warnings: 342 if not inplace: 343 s.swc = s.swc.copy() 344 345 return s
Move nodes that ended up outside the mesh back inside.
Nodes can end up outside the original mesh e.g. if the mesh contraction didn't do a good job (most likely because of internal/degenerate faces that messed up the normals). This function rectifies this by snapping those nodes nodes back to the closest vertex and then tries to move them into the mesh's center. That second step is not guaranteed to work but at least you won't have any more nodes outside the mesh.
Please note that if connected (!) nodes end up on the same position (i.e because they snapped to the same vertex), we will collapse them.
Parameters
s (skeletor.Skeleton):
mesh (trimesh.Trimesh): Original mesh.
- inplace (bool):
If False will make and return a copy of the skeleton. If True,
will modify the
sinplace.
Returns
- s (skeletor.Skeleton): Skeleton with vertices recentered.
348def fix_outside_edges( 349 s, mesh=None, inplace=False, max_iter=8, smooth_iters=1, eps=1e-6 350): 351 """Fix edges that cross outside the mesh boundary. 352 353 This function detects skeleton edges that intersect the mesh boundary and 354 fixes them by iteratively splitting crossing edges (inserting new vertices 355 along the edge) and then recentering any vertices that end up outside the 356 mesh using `recenter_vertices()`. 357 358 Notes 359 ----- 360 This will also modify original vertices positions (via `skeletor.post.recenter_vertices()`). 361 Splitting edges inserts new skeleton nodes that are not represented in "skel_map". Currently, 362 we invalidate any existing "mesh_map" and "skel_map" (setting it to `None`). In the 363 future, we may add functionality to update the mapping instead. 364 365 Parameters 366 ---------- 367 s : skeletor.Skeleton 368 mesh : trimesh.Trimesh 369 Original mesh. If mesh is None, will use the mesh associated with input 370 skeleton (`s.mesh`). 371 inplace : bool 372 If False will make and return a copy of the skeleton. If True, 373 will modify the `s` inplace. 374 max_iter : int 375 Max split iterations for boundary-crossing edges. 376 smooth_iters : int 377 Number of smoothing iterations for degree-2 chain nodes. 378 eps : float or {'auto'} 379 Ignore intersections within eps of either endpoint. 380 If "auto", uses mesh mean unique edge length * 1e-4. 381 382 Returns 383 ------- 384 s : skeletor.Skeleton 385 386 """ 387 if isinstance(mesh, type(None)): 388 mesh = s.mesh 389 390 if mesh is None: 391 raise ValueError( 392 "Mesh is required for fixing outside edges. Please provide a mesh or ensure `s.mesh` is set." 393 ) 394 395 if not inplace: 396 s = s.copy() 397 398 if s.swc is None or s.swc.empty: 399 return s 400 401 # Determine eps (scale-aware) 402 if isinstance(eps, str): 403 if eps.lower() != "auto": 404 raise ValueError("Invalid value for `eps`. Must be a number or 'auto'.") 405 try: 406 mean_length = float(np.nanmean(get_edges_unique(mesh, lengths=True)[1])) 407 except Exception: 408 mean_length = np.nan 409 eps = ( 410 mean_length * 1e-4 411 if (np.isfinite(mean_length) and mean_length > 0) 412 else 1e-6 413 ) 414 else: 415 eps = float(eps) 416 417 max_iter = int(max_iter) 418 if max_iter < 0: 419 raise ValueError("`max_iter` must be >= 0") 420 421 smooth_iters = int(smooth_iters) 422 if smooth_iters < 0: 423 raise ValueError("`smooth_iters` must be >= 0") 424 425 coll = ncollpyde.Volume(mesh.vertices, mesh.faces, validate=False) 426 427 # 1. Recenter any nodes outside the mesh 428 if (~coll.contains(s.vertices)).any(): 429 recenter_vertices(s, mesh=mesh, inplace=True) 430 431 has_radius = "radius" in s.swc.columns 432 433 # 2. Iteratively split crossing edges 434 for _ in range(max_iter): 435 swc = s.swc 436 edge_rows = np.where(swc.parent_id.values >= 0)[0] 437 if edge_rows.size == 0: 438 break 439 440 sources = swc.loc[edge_rows, ["x", "y", "z"]].values 441 parent_ids = swc.loc[edge_rows, "parent_id"].values 442 targets = swc.set_index("node_id").loc[parent_ids, ["x", "y", "z"]].values 443 444 ix, loc, _ = coll.intersections(sources, targets) 445 446 crossing = np.zeros(edge_rows.shape[0], dtype=bool) 447 448 if len(ix): 449 d_src = np.linalg.norm(loc - sources[ix], axis=1) 450 d_tgt = np.linalg.norm(loc - targets[ix], axis=1) 451 real_crossings = (d_src > eps) & (d_tgt > eps) 452 if real_crossings.any(): 453 crossing[np.unique(ix[real_crossings])] = True 454 455 if not crossing.any(): 456 break 457 458 to_split = edge_rows[crossing] 459 next_node_id = int(swc.node_id.max()) + 1 460 461 new_rows = [] 462 nodes = swc.set_index("node_id") 463 464 # Cache arrays for cheap positional access inside the loop 465 parent_id_arr = swc["parent_id"].to_numpy(copy=True) 466 parent_col = swc.columns.get_loc("parent_id") 467 468 xyz_arr = swc[["x", "y", "z"]].to_numpy(copy=False) 469 if not np.issubdtype(xyz_arr.dtype, np.number): 470 xyz_arr = xyz_arr.astype(float) 471 472 for edge_row in to_split: 473 # edge_row is a positional row index (from np.where) 474 parent_id = int(parent_id_arr[edge_row]) 475 if parent_id < 0: 476 continue 477 478 # Midpoint (no projection; recenter will handle) 479 child_co = xyz_arr[edge_row].astype(float, copy=False) 480 parent_co = nodes.loc[parent_id, ["x", "y", "z"]].values.astype( 481 float, copy=False 482 ) 483 midpoint = (child_co + parent_co) / 2.0 484 485 # Rewire child -> new node (positional write; avoids boolean mask on node_id) 486 swc.iat[edge_row, parent_col] = next_node_id 487 parent_id_arr[edge_row] = next_node_id # keep cached view consistent 488 489 # Create new node row 490 row = {col: np.nan for col in swc.columns} 491 row["node_id"] = next_node_id 492 row["parent_id"] = parent_id 493 row["x"], row["y"], row["z"] = midpoint 494 495 if has_radius: 496 child_r = pd.to_numeric( 497 pd.Series([swc.at[edge_row, "radius"]]), errors="coerce" 498 ).iloc[0] 499 parent_r = pd.to_numeric( 500 pd.Series([nodes.loc[parent_id, "radius"]]), errors="coerce" 501 ).iloc[0] 502 row["radius"] = np.nanmean(np.array([child_r, parent_r], dtype=float)) 503 504 new_rows.append(row) 505 next_node_id += 1 506 507 if not new_rows: 508 break 509 510 swc = pd.concat( 511 [swc, pd.DataFrame(new_rows, columns=swc.columns)], ignore_index=True 512 ) 513 s.swc = swc 514 515 coords = s.swc[["x", "y", "z"]].values 516 if (~coll.contains(coords)).any(): 517 recenter_vertices(s, mesh=mesh, inplace=True) 518 519 # 3. Smoothing (degree-2 chain nodes), then recenter 520 for _ in range(smooth_iters): 521 swc = s.swc 522 523 child_counts = swc[swc.parent_id >= 0].groupby("parent_id").size() 524 is_chain = (swc.parent_id >= 0) & ( 525 swc.node_id.map(child_counts).fillna(0).astype(int) == 1 526 ) 527 chain_nodes = swc.loc[is_chain, "node_id"].values.astype(int) 528 529 if chain_nodes.size == 0: 530 break 531 532 only_child = ( 533 swc[swc.parent_id >= 0].groupby("parent_id").node_id.first().to_dict() 534 ) 535 nodes = swc.set_index("node_id") 536 537 parent_ids = nodes.loc[chain_nodes, "parent_id"].values.astype(int) 538 child_ids = np.array([only_child[n] for n in chain_nodes], dtype=int) 539 540 parent_co = nodes.loc[parent_ids, ["x", "y", "z"]].values.astype(float) 541 child_co = nodes.loc[child_ids, ["x", "y", "z"]].values.astype(float) 542 smoothed = (parent_co + child_co) / 2.0 543 544 swc.loc[is_chain, ["x", "y", "z"]] = smoothed 545 s.swc = swc 546 547 coords = s.swc[["x", "y", "z"]].values 548 if (~coll.contains(coords)).any(): 549 recenter_vertices(s, mesh=mesh, inplace=True) 550 551 swc = s.swc 552 edge_rows = np.where(swc.parent_id.values >= 0)[0] 553 remaining_crossings = 0 554 # Detect crossing edges again for double-checking 555 if edge_rows.size: 556 sources = swc.loc[edge_rows, ["x", "y", "z"]].values 557 parent_ids = swc.loc[edge_rows, "parent_id"].values 558 nodes = swc.set_index("node_id") 559 try: 560 targets = nodes.loc[parent_ids, ["x", "y", "z"]].values 561 ix, loc, _ = coll.intersections(sources, targets) 562 if len(ix): 563 d_src = np.linalg.norm(loc - sources[ix], axis=1) 564 d_tgt = np.linalg.norm(loc - targets[ix], axis=1) 565 real = (d_src > eps) & (d_tgt > eps) 566 if np.any(real): 567 crossing = np.zeros(edge_rows.shape[0], dtype=bool) 568 crossing[np.unique(ix[real])] = True 569 remaining_crossings = int(crossing.sum()) 570 except KeyError: 571 remaining_crossings = 0 572 573 if remaining_crossings > 0: 574 warnings.warn( 575 f"{remaining_crossings} crossing edges remain after {max_iter} " 576 "fix iteration(s); returning best-effort result. Consider increasing " 577 "`max_iter`, adjusting `eps`, and/or running `post.clean_up` / " 578 "`post.remove_bristles` first. Also check mesh quality (e.g. non-watertight " 579 "or degenerate faces).", 580 RuntimeWarning, 581 ) 582 583 # Invalidate mesh_map 584 s.mesh_map = None 585 586 return s
Fix edges that cross outside the mesh boundary.
This function detects skeleton edges that intersect the mesh boundary and
fixes them by iteratively splitting crossing edges (inserting new vertices
along the edge) and then recentering any vertices that end up outside the
mesh using recenter_vertices().
Notes
This will also modify original vertices positions (via skeletor.post.recenter_vertices()).
Splitting edges inserts new skeleton nodes that are not represented in "skel_map". Currently,
we invalidate any existing "mesh_map" and "skel_map" (setting it to None). In the
future, we may add functionality to update the mapping instead.
Parameters
s (skeletor.Skeleton):
mesh (trimesh.Trimesh): Original mesh. If mesh is None, will use the mesh associated with input skeleton (
s.mesh).- inplace (bool):
If False will make and return a copy of the skeleton. If True,
will modify the
sinplace. - max_iter (int): Max split iterations for boundary-crossing edges.
- smooth_iters (int): Number of smoothing iterations for degree-2 chain nodes.
- eps (float or {'auto'}): Ignore intersections within eps of either endpoint. If "auto", uses mesh mean unique edge length * 1e-4.
Returns
- s (skeletor.Skeleton):