skeletor

What is skeletor?

Unlike its namesake, this skeletor does not (yet) seek to conquer Eternia but to turn meshes into skeletons.

Before we get started some terminology:

  • a mesh is something that consists of vertices and faces
  • a skeleton is a (hierarchical) tree-like structure consisting of vertices (also called nodes) and edges that connect them

Skeletons are useful for a range of reasons. For example:

  1. Typically smaller (less vertices) than the mesh
  2. Have an implicit sense of topology (e.g. "this node is distal to that node")

Extracting skeletons from meshes (or other types of data such as voxels) is non-trivial and there are a great many research papers exploring various different approaches (see Google scholar).

skeletor implements some algorithms that I found useful in my work with neurons. In my experience there is unfortuntely no magic bullet when it comes to skeletonization and chances are you will have to fiddle around a bit to get decent results.

Installation

From PyPI:

pip3 install skeletor

For the bleeding-edge version from Github:

pip3 install git+https://github.com/navis-org/skeletor@master

Getting started

A skeletonization pipeline typically consists of:

  1. Some pre-processing of the mesh (e.g. fixing some potential errors like degenerate faces, unreferenced vertices, etc.)
  2. The skeletonization itself
  3. Some post-processing of the skeleton (e.g. adding radius information, smoothing, etc.)

Here is a complete list of available functions:

function description
example data
example_mesh() load an example mesh
pre-processing
skeletor.pre.fix_mesh() fix some common errors found in meshes
skeletor.pre.remesh() re-generate mesh (uses Blender 3D)
skeletor.pre.simplify() reduce mesh complexity (uses Blender 3D)
skeletor.pre.contract() contract mesh to facilitate skeletonization [1]
skeletonization
skeletor.skeletonize.by_wavefront() very fast, works well for tubular meshes (like neurons)
skeletor.skeletonize.by_vertex_clusters() very fast but needs mesh to be contracted (see above)
skeletor.skeletonize.by_edge_collapse() presented in [1] but never got this to work well
skeletor.skeletonize.by_teasar() very fast and robust, works on mesh surface
skeletor.skeletonize.by_tangent_ball() very fast, best on smooth meshes
postprocessing
skeletor.post.clean_up() fix some potential errors in the skeleton
skeletor.post.radii() add radius information using various method
skeletor.post.smooth() smooth the skeleton
skeletor.post.remove_bristles() remove single-node bristles from the skeleton
skeletor.post.despike() smooth out jumps in the skeleton

See docstrings of the respective functions for details.

A pipeline might look like this:

  1. skeletor.pre.fix_mesh() to fix the mesh
  2. skeletor.pre.simplify() to simplify the mesh
  3. skeletor.pre.contract() to contract the mesh [1]
  4. skeletor.skeletonize.by_vertex_clusters() to generate a skeleton
  5. skeletor.post.clean_up() to clean up some potential issues with the skeleton
  6. skeletor.post.smooth() to smooth the skeleton
  7. skeletor.post.radii() to extract radii either by k-nearest neighbours or ray-casting

In my experience there is no one-size-fits-all. You will have to play around to find the right approach and parameters to get nice skeletons for your meshes. If you need help just open an issue.

Also check out the Gotchas below!

Examples

First load the example mesh (a fruit fly neuron):

>>> import skeletor as sk
>>> mesh = sk.example_mesh()
>>> mesh
<trimesh.Trimesh(vertices.shape=(6582, 3), faces.shape=(13772, 3))>

Next see if there is stuff to fix in the mesh (degenerate faces, duplicate vertices, etc.):

>>> fixed = sk.pre.fix_mesh(mesh, remove_disconnected=5, inplace=False)
>>> fixed
<trimesh.Trimesh(vertices.shape=(6213, 3), faces.shape=(12805, 3))>

Now for tubular meshes like this neuron, the "wave front" skeletonization method performs really well: it works by casting waves across the mesh and collapsing the resulting rings into a skeleton (kinda like when you throw a stone in a pond and track the expanding ripples).

>>> skel = sk.skeletonize.by_wavefront(fixed, waves=1, step_size=1)
>>> skel
<Skeleton(vertices=(1258, 3), edges=(1194, 2), method=wavefront)>

All skeletonization methods return a Skeleton object. These are just convenient objects to bundle the various outputs of the skeletonization.

>>> # x/y/z location of skeleton vertices (nodes)
>>> skel.vertices
array([[16744, 36720, 26407],
       ...,
       [22076, 23217, 24472]])
>>> # child -> parent edges
>>> skel.edges
array([[  64,   31],
       ...,
       [1257, 1252]])
>>> # Mapping for mesh to skeleton vertex indices
>>> skel.mesh_map
array([ 157,  158, 1062, ...,  525,  474,  547])
>>> # SWC table
>>> skel.swc.head()
   node_id  parent_id             x             y             z    radius
0        0         -1  16744.005859  36720.058594  26407.902344  0.000000
1        1         -1   5602.751953  22266.756510  15799.991211  7.542587
2        2         -1  16442.666667  14999.978516  10887.916016  5.333333

SWC is a commonly used format for saving skeletons. Skeleton objects have a method for quickly saving a correctly formatted SWC file:

>>> skel.save_swc('~/Documents/my_skeleton.swc')

If you installed pyglet (see above) you can also use trimesh's plotting capabilities to inspect the results:

>>> skel.show(mesh=True)

skeletor_example

That looks pretty good already but let's run some pro-forma postprocessing.

>>> sk.post.clean_up(skel, inplace=True)
<Skeleton(vertices=(1071, 3), edges=(1070, 2))>

So that would be a full pipeline mesh to skeleton. Don't expect your own meshes to produce such nice results off the bat though. Chances are you will need to play around to find the right recipe. If you don't know where to start, I suggest you try out mesh contraction + vertex clustering first:

>>> import skeletor as sk
>>> # Load the example mesh that ships with skeletor
>>> mesh = sk.example_mesh()
>>> # Alternatively use trimesh to load/construct your own mesh:
>>> # import trimesh as tm
>>> # mesh = tm.Trimesh(vertices, faces)
>>> # mesh = tm.load_mesh('some/mesh.obj')
>>> # Run some general clean-up (see docstring for details)
>>> fixed = sk.pre.fix_mesh(mesh, remove_disconnected=5, inplace=False)
>>> # Contract mesh to 10% (0.1) of original volume
>>> cont = sk.pre.contract(fixed, epsilon=0.1)
>>> # Skeletonize
>>> skel = sk.skeletonize.by_vertex_clusters(cont, sampling_dist=100)
>>> # Replace contracted mesh with original for postprocessing and plotting
>>> skel.mesh = fixed
>>> # Add radii (vertex cluster method does not do that automatically)
>>> sk.post.radii(skel, method='knn')
>>> skel.show(mesh=True)

Gotchas

  • while this is a general purpose library, my personal focus is on neurons and this has certainly influenced things like default parameter values and certain post-processing steps
  • meshes need to be triangular (we are using trimesh)
  • use sk.pre.simplify if your mesh is very complex (half a million vertices is where things start getting sluggish)
  • a good mesh contraction is often half the battle but it can be tricky to get to work
  • if the mesh consists of multiple disconnected pieces the skeleton will likewise be fragmented (i.e. will have multiple roots)
  • it's often a good idea to fix issues with the skeleton in postprocessing rather than trying to get the skeletonization to be perfect

Benchmarks

skeletor_benchmark

Benchmarks were run on a 2018 MacBook Pro (2.2 GHz Core i7, 32Gb memory) with optional fastremap dependency installed. Note some of these functions (e.g. contraction and TEASAR/vertex cluster skeletonization) vary a lot in speed based on parameterization.

What about algorithm X?

skeletor contains some algorithms that I found easy enough to implement and useful for my work with neurons. If you have some interesting paper/approach that could make a nice addition to skeletor, please get in touch on Github. Pull requests are always welcome!

References

[1] Au OK, Tai CL, Chu HK, Cohen-Or D, Lee TY. Skeleton extraction by mesh contraction. ACM Transactions on Graphics (TOG). 2008 Aug 1;27(3):44.

The abstract and the paper can be found here. Also see this YouTube video.

Some of the code in skeletor was modified from the Py_BL_MeshSkeletonization addon created by #0K Srinivasan Ramachandran and published under GPL3.

Top-level functions and classes

At top-level we only expose example_mesh() and the Skeleton class (which you probably won't ever need to touch manually). Everything else is neatly tucked away into submodules (see side-bar or above table).

  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"""
 18# What is skeletor?
 19
 20Unlike its [namesake](https://en.wikipedia.org/wiki/Skeletor), this `skeletor`
 21does not (yet) seek to conquer Eternia but to turn meshes into skeletons.
 22
 23Before we get started some terminology:
 24
 25- a *mesh* is something that consists of *vertices* and *faces*
 26- a *skeleton* is a (hierarchical) tree-like structure consisting of *vertices*
 27  (also called *nodes*) and *edges* that connect them
 28
 29Skeletons are useful for a range of reasons. For example:
 30
 311. Typically smaller (less vertices) than the mesh
 322. Have an implicit sense of topology (e.g. "this node is distal to that node")
 33
 34Extracting skeletons from meshes (or other types of data such as voxels) is
 35non-trivial and there are a great many research papers exploring various
 36different approaches (see [Google scholar](https://scholar.google.com/scholar
 37?hl=en&as_sdt=0%2C5&q=skeleton+extraction&btnG=)).
 38
 39`skeletor` implements some algorithms that I found useful in my work with
 40neurons. In my experience there is unfortuntely no magic bullet when it
 41comes to skeletonization and chances are you will have to fiddle around a bit
 42to get decent results.
 43
 44# Installation
 45
 46From PyPI:
 47```bash
 48pip3 install skeletor
 49```
 50
 51For the bleeding-edge version from Github:
 52```bash
 53pip3 install git+https://github.com/navis-org/skeletor@master
 54```
 55
 56# Getting started
 57
 58A skeletonization pipeline typically consists of:
 59
 601. Some pre-processing of the mesh (e.g. fixing some potential errors like
 61   degenerate faces, unreferenced vertices, etc.)
 622. The skeletonization itself
 633. Some post-processing of the skeleton (e.g. adding radius information, smoothing, etc.)
 64
 65------
 66
 67Here is a complete list of available functions:
 68
 69| function                                    | description                                                 |
 70| ------------------------------------------- | ----------------------------------------------------------- |
 71| **example data**                            |                                                             |
 72| `skeletor.example_mesh()`                   | load an example mesh                                        |
 73| **pre-processing**                          |                                                             |
 74| `skeletor.pre.fix_mesh()`                   | fix some common errors found in meshes                      |
 75| `skeletor.pre.remesh()`                     | re-generate mesh (uses Blender 3D)                          |
 76| `skeletor.pre.simplify()`                   | reduce mesh complexity (uses Blender 3D)                    |
 77| `skeletor.pre.contract()`                   | contract mesh to facilitate skeletonization [1]             |
 78| **skeletonization**                         |                                                             |
 79| `skeletor.skeletonize.by_wavefront()`       | very fast, works well for tubular meshes (like neurons)     |
 80| `skeletor.skeletonize.by_vertex_clusters()` | very fast but needs mesh to be contracted (see above)       |
 81| `skeletor.skeletonize.by_edge_collapse()`   | presented in [1] but never got this to work well            |
 82| `skeletor.skeletonize.by_teasar()`          | very fast and robust, works on mesh surface                 |
 83| `skeletor.skeletonize.by_tangent_ball()`    | very fast, best on smooth meshes                            |
 84| **postprocessing**                          |                                                             |
 85| `skeletor.post.clean_up()`                  | fix some potential errors in the skeleton                   |
 86| `skeletor.post.radii()`                     | add radius information using various method                 |
 87| `skeletor.post.smooth()`                    | smooth the skeleton                                         |
 88| `skeletor.post.remove_bristles()`           | remove single-node bristles from the skeleton               |
 89| `skeletor.post.despike()`                   | smooth out jumps in the skeleton                            |
 90
 91------
 92
 93See docstrings of the respective functions for details.
 94
 95A pipeline might look like this:
 96
 97 1. `skeletor.pre.fix_mesh()` to fix the mesh
 98 2. `skeletor.pre.simplify()` to simplify the mesh
 99 3. `skeletor.pre.contract()` to contract the mesh [1]
100 4. `skeletor.skeletonize.by_vertex_clusters()` to generate a skeleton
101 5. `skeletor.post.clean_up()` to clean up some potential issues with the skeleton
102 6. `skeletor.post.smooth()` to smooth the skeleton
103 7. `skeletor.post.radii()` to extract radii either by k-nearest neighbours or ray-casting
104
105In my experience there is no one-size-fits-all. You will have to play around to
106find the right approach and parameters to get nice skeletons for your meshes.
107If you need help just open an [issue](https://github.com/navis-org/skeletor/issues).
108
109Also check out the Gotchas below!
110
111# Examples
112
113First load the example mesh (a fruit fly neuron):
114
115```Python
116>>> import skeletor as sk
117>>> mesh = sk.example_mesh()
118>>> mesh
119<trimesh.Trimesh(vertices.shape=(6582, 3), faces.shape=(13772, 3))>
120```
121
122Next see if there is stuff to fix in the mesh (degenerate faces, duplicate
123vertices, etc.):
124
125```Python
126>>> fixed = sk.pre.fix_mesh(mesh, remove_disconnected=5, inplace=False)
127>>> fixed
128<trimesh.Trimesh(vertices.shape=(6213, 3), faces.shape=(12805, 3))>
129```
130
131Now for tubular meshes like this neuron, the "wave front" skeletonization method
132performs really well: it works by casting waves across the mesh and collapsing
133the resulting rings into a skeleton (kinda like when you throw a stone in a
134pond and track the expanding ripples).
135
136```Python
137>>> skel = sk.skeletonize.by_wavefront(fixed, waves=1, step_size=1)
138>>> skel
139<Skeleton(vertices=(1258, 3), edges=(1194, 2), method=wavefront)>
140```
141
142All skeletonization methods return a `Skeleton` object. These are just
143convenient objects to bundle the various outputs of the skeletonization.
144
145```Python
146>>> # x/y/z location of skeleton vertices (nodes)
147>>> skel.vertices
148array([[16744, 36720, 26407],
149       ...,
150       [22076, 23217, 24472]])
151>>> # child -> parent edges
152>>> skel.edges
153array([[  64,   31],
154       ...,
155       [1257, 1252]])
156>>> # Mapping for mesh to skeleton vertex indices
157>>> skel.mesh_map
158array([ 157,  158, 1062, ...,  525,  474,  547])
159>>> # SWC table
160>>> skel.swc.head()
161   node_id  parent_id             x             y             z    radius
1620        0         -1  16744.005859  36720.058594  26407.902344  0.000000
1631        1         -1   5602.751953  22266.756510  15799.991211  7.542587
1642        2         -1  16442.666667  14999.978516  10887.916016  5.333333
165```
166
167SWC is a commonly used format for saving skeletons. `Skeleton` objects
168have a method for quickly saving a correctly formatted SWC file:
169
170```Python
171>>> skel.save_swc('~/Documents/my_skeleton.swc')
172```
173
174If you installed `pyglet` (see above) you can also use `trimesh`'s plotting
175capabilities to inspect the results:
176
177```Python
178>>> skel.show(mesh=True)
179```
180
181<img src="https://github.com/navis-org/skeletor/raw/master/_static/example1.png" alt="skeletor_example" width="100%"/>
182
183That looks pretty good already but let's run some pro-forma postprocessing.
184
185```Python
186>>> sk.post.clean_up(skel, inplace=True)
187<Skeleton(vertices=(1071, 3), edges=(1070, 2))>
188```
189
190So that would be a full pipeline mesh to skeleton. Don't expect your own meshes
191to produce such nice results off the bat though. Chances are you will need to
192play around to find the right recipe. If you don't know where to start, I suggest
193you try out mesh contraction + vertex clustering first:
194
195```Python
196>>> import skeletor as sk
197>>> # Load the example mesh that ships with skeletor
198>>> mesh = sk.example_mesh()
199>>> # Alternatively use trimesh to load/construct your own mesh:
200>>> # import trimesh as tm
201>>> # mesh = tm.Trimesh(vertices, faces)
202>>> # mesh = tm.load_mesh('some/mesh.obj')
203>>> # Run some general clean-up (see docstring for details)
204>>> fixed = sk.pre.fix_mesh(mesh, remove_disconnected=5, inplace=False)
205>>> # Contract mesh to 10% (0.1) of original volume
206>>> cont = sk.pre.contract(fixed, epsilon=0.1)
207>>> # Skeletonize
208>>> skel = sk.skeletonize.by_vertex_clusters(cont, sampling_dist=100)
209>>> # Replace contracted mesh with original for postprocessing and plotting
210>>> skel.mesh = fixed
211>>> # Add radii (vertex cluster method does not do that automatically)
212>>> sk.post.radii(skel, method='knn')
213>>> skel.show(mesh=True)
214```
215
216# Gotchas
217
218- while this is a general purpose library, my personal focus is on neurons and
219  this has certainly influenced things like default parameter values and certain
220  post-processing steps
221- meshes need to be triangular (we are using `trimesh`)
222- use `sk.pre.simplify` if your mesh is very complex (half a million vertices is
223  where things start getting sluggish)
224- a good mesh contraction is often half the battle but it can be tricky to get
225  to work
226- if the mesh consists of multiple disconnected pieces the skeleton will
227  likewise be fragmented (i.e. will have multiple roots)
228- it's often a good idea to fix issues with the skeleton in postprocessing rather
229  than trying to get the skeletonization to be perfect
230
231# Benchmarks
232
233<img src="https://github.com/navis-org/skeletor/raw/master/benchmarks/benchmark_2.png" alt="skeletor_benchmark" width="100%"/>
234
235[Benchmarks](https://github.com/navis-org/skeletor/blob/master/benchmarks/skeletor_benchmark.ipynb)
236were run on a 2018 MacBook Pro (2.2 GHz Core i7, 32Gb memory) with optional
237`fastremap` dependency installed. Note some of these functions (e.g.
238contraction and TEASAR/vertex cluster skeletonization) vary a lot in
239speed based on parameterization.
240
241# What about algorithm `X`?
242
243`skeletor` contains some algorithms that I found easy enough to implement
244and useful for my work with neurons. If you have some interesting paper/approach
245that could make a nice addition to `skeletor`, please get in touch on Github.
246Pull requests are always welcome!
247
248# References
249
250`[1] Au OK, Tai CL, Chu HK, Cohen-Or D, Lee TY. Skeleton extraction by mesh contraction. ACM Transactions on Graphics (TOG). 2008 Aug 1;27(3):44.`
251
252The abstract and the paper can be found [here](http://visgraph.cse.ust.hk/projects/skeleton/).
253Also see [this](https://www.youtube.com/watch?v=-H7n59YQCRM&feature=youtu.be) YouTube video.
254
255Some of the code in skeletor was modified from the
256[Py_BL_MeshSkeletonization](https://github.com/aalavandhaann/Py_BL_MeshSkeletonization)
257addon created by #0K Srinivasan Ramachandran and published under GPL3.
258
259# Top-level functions and classes
260At top-level we only expose `example_mesh()` and the `Skeleton` class (which
261you probably won't ever need to touch manually). Everything else is neatly
262tucked away into submodules (see side-bar or above table).
263
264"""
265
266__version__ = "1.3.0"
267__version_vector__ = (1, 3, 0)
268
269from . import skeletonize
270from . import pre
271from . import post
272
273from .skeletonize.base import Skeleton
274from .data import example_mesh
275
276__docformat__ = "numpy"
277
278__all__ = ['Skeleton', 'example_mesh', 'pre', 'post', 'skeletonize']
class Skeleton:
 33class Skeleton:
 34    """Class representing a skeleton.
 35
 36    Typically returned as results from a skeletonization.
 37
 38    Attributes
 39    ----------
 40    swc :       pd.DataFrame, optional
 41                SWC table.
 42    vertices :  (N, 3) array
 43                Vertex (node) positions.
 44    edges :     (M, 2) array
 45                Indices of connected vertex pairs.
 46    radii :     (N, ) array, optional
 47                Radii for each vertex (node) in the skeleton.
 48    mesh :      trimesh, optional
 49                The original mesh.
 50    mesh_map :  array, optional
 51                Same length as ``mesh``. Maps mesh vertices to vertices (nodes)
 52                in the skeleton.
 53    skel_map :  array of arrays, optional
 54                Inverse of `mesh_map`: maps skeleton vertices (nodes) to mesh
 55                vertices.
 56    method :    str, optional
 57                Which method was used to generate the skeleton.
 58
 59    """
 60
 61    def __init__(self, swc, mesh=None, mesh_map=None, method=None):
 62        self.swc = swc
 63        self.mesh = mesh
 64        self.mesh_map = mesh_map
 65        self.method = method
 66
 67    def __str__(self):
 68        """Summary."""
 69        return self.__repr__()
 70
 71    def __repr__(self):
 72        """Return quick summary of the skeleton's geometry."""
 73        elements = []
 74        if hasattr(self, "vertices"):
 75            elements.append(f"vertices={self.vertices.shape}")
 76        if hasattr(self, "edges"):
 77            elements.append(f"edges={self.edges.shape}")
 78        if hasattr(self, "method"):
 79            elements.append(f"method={self.method}")
 80        return f'<Skeleton({", ".join(elements)})>'
 81
 82    @property
 83    def edges(self):
 84        """Return skeleton edges."""
 85        return self.swc.loc[self.swc.parent_id >= 0, ["node_id", "parent_id"]].values
 86
 87    @property
 88    def vertices(self):
 89        """Return skeleton vertices (nodes)."""
 90        return self.swc[["x", "y", "z"]].values
 91
 92    @property
 93    def radius(self):
 94        """Return radii."""
 95        if "radius" not in self.swc.columns:
 96            raise ValueError(
 97                "No radius info found. Run `skeletor.post.radii()`" " to get them."
 98            )
 99        return self.swc["radius"].values
100
101    @property
102    def skeleton(self):
103        """Skeleton as trimesh Path3D."""
104        if not hasattr(self, "_skeleton"):
105            lines = [tm.path.entities.Line(e) for e in self.edges]
106
107            self._skeleton = tm.path.Path3D(
108                entities=lines, vertices=self.vertices, process=False
109            )
110        return self._skeleton
111
112    @property
113    def skel_map(self):
114        """Skeleton vertex (nodes) to mesh vertices. Based on `mesh_map`."""
115        if isinstance(self.mesh_map, type(None)):
116            return None
117        return (
118            pd.DataFrame(self.mesh_map)
119            .reset_index(drop=False)
120            .groupby(0)["index"]
121            .apply(np.array)
122            .values
123        )
124
125    @property
126    def roots(self):
127        """Root node(s)."""
128        return self.swc.loc[self.swc.parent_id < 0].index.values
129
130    @property
131    def leafs(self):
132        """Leaf nodes (includes root)."""
133        swc = self.swc
134        leafs = swc[~swc.node_id.isin(swc.parent_id.values) | (swc.parent_id < 0)]
135        return leafs.copy()
136
137    def reindex(self, inplace=False):
138        """Clean up skeleton."""
139        x = self
140        if not inplace:
141            x = x.copy()
142
143        # Re-index to make node IDs continous again
144        x.swc, new_ids = reindex_swc(x.swc)
145
146        # Update mesh map
147        if not isinstance(x.mesh_map, type(None)):
148            x.mesh_map = np.array([new_ids.get(i, i) for i in x.mesh_map])
149
150        if not inplace:
151            return x
152
153    def reroot(self, new_root):
154        """Reroot the skeleton.
155
156        Parameters
157        ----------
158        new_root :  int
159                    Index of node to use as new root. If the skeleton
160                    consists of multiple trees, only the tree containing the
161                    new root will be updated.
162
163        Returns
164        -------
165        Skeleton
166                    Skeleton with new root.
167
168        """
169        assert new_root in self.swc.index.values, f"Node index {new_root} not in skeleton."
170
171        # Make copy of self
172        x = self.copy()
173
174        # Check if the new root is already a root
175        if new_root in x.roots:
176            return x
177
178        # Get graph representation
179        G = x.get_graph()
180
181        # Get the path from the new root to the current root (of the same tree)
182        for r in x.roots:
183            try:
184                path = nx.shortest_path(G, source=new_root, target=r)
185                break
186            except nx.NetworkXNoPath:
187                continue
188
189        # Now we need to invert the path from the old root to the new root
190        new_parents = x.swc.set_index('node_id').parent_id.to_dict()
191        new_parents.update({c: p for p, c in zip(path[:-1], path[1:])})
192        new_parents[new_root] = -1
193
194        # Update the SWC table
195        x.swc["parent_id"] = x.swc.node_id.map(new_parents)
196
197        return x
198
199    def copy(self):
200        """Return copy of the skeleton."""
201        return Skeleton(
202            swc=self.swc.copy() if not isinstance(self.swc, type(None)) else None,
203            mesh=self.mesh.copy() if not isinstance(self.mesh, type(None)) else None,
204            mesh_map=self.mesh_map.copy()
205            if not isinstance(self.mesh_map, type(None))
206            else None,
207            method=self.method,
208        )
209
210    def get_graph(self):
211        """Generate networkX representation of the skeletons.
212
213        Distance between nodes will be used as edge weights.
214
215        Returns
216        -------
217        networkx.DiGraph
218
219        """
220        not_root = self.swc.parent_id >= 0
221        nodes = self.swc.loc[not_root]
222        parents = self.swc.set_index("node_id").loc[
223            self.swc.loc[not_root, "parent_id"].values
224        ]
225
226        dists = nodes[["x", "y", "z"]].values - parents[["x", "y", "z"]].values
227        dists = np.sqrt((dists**2).sum(axis=1))
228
229        G = nx.DiGraph()
230        G.add_nodes_from(self.swc.node_id.values)
231        G.add_weighted_edges_from(
232            zip(nodes.node_id.values, nodes.parent_id.values, dists)
233        )
234
235        return G
236
237    def get_segments(self, weight="weight", return_lengths=False):
238        """Generate a list of linear segments while maximizing segment lengths.
239
240        Parameters
241        ----------
242        weight :    'weight' | None, optional
243                    If ``"weight"`` use physical, geodesic length to determine
244                    segment length. If ``None`` use number of nodes (faster).
245        return_lengths : bool
246                    If True, also return lengths of segments according to ``weight``.
247
248        Returns
249        -------
250        segments :  list
251                    Segments as list of lists containing node IDs. List is
252                    sorted by segment lengths.
253        lengths :   list
254                    Length for each segment according to ``weight``. Only provided
255                    if `return_lengths` is True.
256
257        """
258        assert weight in ("weight", None), f'Unable to use weight "{weight}"'
259
260        # Get graph representation
261        G = self.get_graph()
262
263        # Get distances to root
264        dists = {}
265        for root in self.swc[self.swc.parent_id < 0].node_id.values:
266            dists.update(nx.shortest_path_length(G, target=root, weight=weight))
267
268        # Sort leaf nodes
269        endNodeIDs = self.leafs[self.leafs.parent_id >= 0].node_id.values
270        endNodeIDs = sorted(endNodeIDs, key=lambda x: dists.get(x, 0), reverse=True)
271
272        seen: set = set()
273        sequences = []
274        for nodeID in endNodeIDs:
275            sequence = [nodeID]
276            parents = list(G.successors(nodeID))
277            while True:
278                if not parents:
279                    break
280                parentID = parents[0]
281                sequence.append(parentID)
282                if parentID in seen:
283                    break
284                seen.add(parentID)
285                parents = list(G.successors(parentID))
286
287            if len(sequence) > 1:
288                sequences.append(sequence)
289
290        # Sort sequences by length
291        lengths = [dists[s[0]] - dists[s[-1]] for s in sequences]
292        sequences = [x for _, x in sorted(zip(lengths, sequences), reverse=True)]
293
294        if return_lengths:
295            return sequences, sorted(lengths, reverse=True)
296        else:
297            return sequences
298
299    def save_swc(self, filepath):
300        """Save skeleton in SWC format.
301
302        Parameters
303        ----------
304        filepath :      path-like
305                        Filepath to save SWC to.
306
307        """
308        header = dedent(f"""\
309        # SWC format file
310        # based on specifications at http://www.neuronland.org/NLMorphologyConverter/MorphologyFormats/SWC/Spec.html
311        # Created on {datetime.date.today()} using skeletor (https://github.com/navis-org/skeletor)
312        # PointNo Label X Y Z Radius Parent
313        # Labels:
314        # 0 = undefined, 1 = soma, 5 = fork point, 6 = end point
315        """)
316
317        # Make copy of SWC table
318        swc = self.swc.copy()
319
320        # Set all labels to undefined
321        swc["label"] = 0
322        swc.loc[~swc.node_id.isin(swc.parent_id.values), "label"] = 6
323        n_childs = swc.groupby("parent_id").size()
324        bp = n_childs[n_childs > 1].index.values
325        swc.loc[swc.node_id.isin(bp), "label"] = 5
326
327        # Add radius if missing
328        if "radius" not in swc.columns:
329            swc["radius"] = 0
330
331        # Get things in order
332        swc = swc[["node_id", "label", "x", "y", "z", "radius", "parent_id"]]
333
334        # Adjust column titles
335        swc.columns = ["PointNo", "Label", "X", "Y", "Z", "Radius", "Parent"]
336
337        with open(filepath, "w") as file:
338            # Write header
339            file.write(header)
340
341            # Write data
342            writer = csv.writer(file, delimiter=" ")
343            writer.writerows(swc.astype(str).values)
344
345    def scene(self, mesh=False, **kwargs):
346        """Return a Scene object containing the skeleton.
347
348        Returns
349        -------
350        scene :     trimesh.scene.scene.Scene
351                    Contains the skeleton and optionally the mesh.
352
353        """
354        if mesh:
355            if isinstance(self.mesh, type(None)):
356                raise ValueError("Skeleton has no mesh.")
357
358            self.mesh.visual.face_colors = [100, 100, 100, 100]
359
360            # Note the copy(): without it the transform in show() changes
361            # the original meshes
362            sc = tm.Scene([self.mesh.copy(), self.skeleton.copy()], **kwargs)
363        else:
364            sc = tm.Scene(self.skeleton.copy(), **kwargs)
365
366        return sc
367
368    def show(self, mesh=False, **kwargs):
369        """Render the skeleton in an opengl window. Requires pyglet.
370
371        Parameters
372        ----------
373        mesh :      bool
374                    If True, will render transparent mesh on top of the
375                    skeleton.
376
377        Returns
378        --------
379        scene :     trimesh.scene.Scene
380                    Scene with skeleton in it.
381
382        """
383        scene = self.scene(mesh=mesh)
384
385        # I encountered some issues if object space is big and the easiest
386        # way to work around this is to apply a transform such that the
387        # coordinates have -5 to +5 bounds
388        fac = 5 / np.fabs(self.skeleton.bounds).max()
389        scene.apply_transform(np.diag([fac, fac, fac, 1]))
390
391        return scene.show(**kwargs)
392
393    def mend_breaks(self, dist_mult=5, dist_min=0, dist_max=np.inf):
394        """Mend breaks in the skeleton using the original mesh.
395
396        This works by comparing the connectivity of the original mesh with that
397        of the skeleton. If the shortest path between two adjacent vertices on the mesh
398        is shorter than the distance between the nodes in the skeleton, a new edge
399        is added to the skeleton.
400
401        Parameters
402        ----------
403        dist_mult : float, optional
404                    Factor by which the new edge should be shorter than the
405                    current shortest path between two nodes to be added.
406                    Lower values = fewer false negatives; higher values = fewer
407                    false positive edges.
408        dist_min :  float, optional
409                    Minimum distance between nodes to consider adding an edge.
410                    Use this to avoid adding very short edges.
411        dist_max :  float, optional
412                    Maximum distance between nodes to consider adding an edge.
413                    Use this to avoid adding very long edges.
414
415        Returns
416        -------
417        edges :     (N, 2) array
418                    Edges connecting the skeleton nodes.
419        vertices :  (N, 3) array
420                    Positions of the skeleton nodes.
421
422        """
423        # We need `.mesh_map` and `.mesh` to exist
424        if self.mesh_map is None:
425            raise ValueError("Skeleton must have a `mesh_map` to mend breaks.")
426        if self.mesh is None:
427            raise ValueError("Skeleton must have a `mesh` to mend breaks.")
428
429        # Make a copy of the mesh edges
430        edges = self.mesh.edges.copy()
431        # Map mesh vertices to skeleton vertices
432        edges[:, 0] = self.mesh_map[self.mesh.edges[:, 0]]
433        edges[:, 1] = self.mesh_map[self.mesh.edges[:, 1]]
434        # Deduplicate
435        edges = np.unique(edges, axis=0)
436        # Remove self edges
437        edges = edges[edges[:, 0] != edges[:, 1]]
438
439        G = self.get_graph().to_undirected()
440
441        # Remove edges that are already in the skeleton
442        edges = np.array([e for e in edges if not G.has_edge(*e)])
443
444        # Calculate distance between these new edge candidates
445        dists = np.sqrt(
446            ((self.vertices[edges[:, 0]] - self.vertices[edges[:, 1]]) ** 2).sum(axis=1)
447        )
448
449        # Sort by distance (lowest first)
450        edges = edges[np.argsort(dists)]
451        dists = dists[np.argsort(dists)]
452
453        for e, d in zip(edges, dists):
454            # Check if the new path would be shorter than the current shortest path
455            if (d * dist_mult) < nx.shortest_path_length(G, e[0], e[1]):
456                continue
457            # Check if the distance is within bounds
458            elif d < dist_min:
459                continue
460            elif d > dist_max:
461                continue
462            # Add edge
463            G.add_edge(*e, weight=d)
464
465        # The above may have introduced small triangles which we should try to remove
466        # by removing the longest edge in a triangle. I have also spotted more
467        # complex cases of four or more nodes forming false-positive loops but
468        # these will be harder to detect and remove.
469
470        # First collect neighbors for each node
471        later_nbrs = {}
472        for node, neighbors in G.adjacency():
473            later_nbrs[node] = {
474                n for n in neighbors if n not in later_nbrs and n != node
475            }
476
477        # Go over each node
478        triangles = set()
479        for node1, neighbors in later_nbrs.items():
480            # Go over each neighbor
481            for node2 in neighbors:
482                # Check if there is one or more third nodes that are connected to both
483                third_nodes = neighbors & later_nbrs[node2]
484                for node3 in third_nodes:
485                    # Add triangle (sort to deduplicate)
486                    triangles.add(tuple(sorted([node1, node2, node3])))
487
488        # Remove longest edge in each triangle
489        for t in triangles:
490            e1, e2, e3 = t[:2], t[1:], t[::2]
491            # Make sure all edges still exist (we may have already removed edges
492            # that were part of a previous triangle)
493            if any(not G.has_edge(*e) for e in (e1, e2, e3)):
494                continue
495            # Remove the longest edge
496            G.remove_edge(*max((e1, e2, e3), key=lambda e: G.edges[e]["weight"]))
497
498        return np.array(G.edges), self.vertices.copy()

Class representing a skeleton.

Typically returned as results from a skeletonization.

Attributes
  • swc (pd.DataFrame, optional): SWC table.
  • vertices ((N, 3) array): Vertex (node) positions.
  • edges ((M, 2) array): Indices of connected vertex pairs.
  • radii ((N, ) array, optional): Radii for each vertex (node) in the skeleton.
  • mesh (trimesh, optional): The original mesh.
  • mesh_map (array, optional): Same length as mesh. Maps mesh vertices to vertices (nodes) in the skeleton.
  • skel_map (array of arrays, optional): Inverse of mesh_map: maps skeleton vertices (nodes) to mesh vertices.
  • method (str, optional): Which method was used to generate the skeleton.
Skeleton(swc, mesh=None, mesh_map=None, method=None)
61    def __init__(self, swc, mesh=None, mesh_map=None, method=None):
62        self.swc = swc
63        self.mesh = mesh
64        self.mesh_map = mesh_map
65        self.method = method
swc
mesh
mesh_map
method
edges
82    @property
83    def edges(self):
84        """Return skeleton edges."""
85        return self.swc.loc[self.swc.parent_id >= 0, ["node_id", "parent_id"]].values

Return skeleton edges.

vertices
87    @property
88    def vertices(self):
89        """Return skeleton vertices (nodes)."""
90        return self.swc[["x", "y", "z"]].values

Return skeleton vertices (nodes).

radius
92    @property
93    def radius(self):
94        """Return radii."""
95        if "radius" not in self.swc.columns:
96            raise ValueError(
97                "No radius info found. Run `skeletor.post.radii()`" " to get them."
98            )
99        return self.swc["radius"].values

Return radii.

skeleton
101    @property
102    def skeleton(self):
103        """Skeleton as trimesh Path3D."""
104        if not hasattr(self, "_skeleton"):
105            lines = [tm.path.entities.Line(e) for e in self.edges]
106
107            self._skeleton = tm.path.Path3D(
108                entities=lines, vertices=self.vertices, process=False
109            )
110        return self._skeleton

Skeleton as trimesh Path3D.

skel_map
112    @property
113    def skel_map(self):
114        """Skeleton vertex (nodes) to mesh vertices. Based on `mesh_map`."""
115        if isinstance(self.mesh_map, type(None)):
116            return None
117        return (
118            pd.DataFrame(self.mesh_map)
119            .reset_index(drop=False)
120            .groupby(0)["index"]
121            .apply(np.array)
122            .values
123        )

Skeleton vertex (nodes) to mesh vertices. Based on mesh_map.

roots
125    @property
126    def roots(self):
127        """Root node(s)."""
128        return self.swc.loc[self.swc.parent_id < 0].index.values

Root node(s).

leafs
130    @property
131    def leafs(self):
132        """Leaf nodes (includes root)."""
133        swc = self.swc
134        leafs = swc[~swc.node_id.isin(swc.parent_id.values) | (swc.parent_id < 0)]
135        return leafs.copy()

Leaf nodes (includes root).

def reindex(self, inplace=False):
137    def reindex(self, inplace=False):
138        """Clean up skeleton."""
139        x = self
140        if not inplace:
141            x = x.copy()
142
143        # Re-index to make node IDs continous again
144        x.swc, new_ids = reindex_swc(x.swc)
145
146        # Update mesh map
147        if not isinstance(x.mesh_map, type(None)):
148            x.mesh_map = np.array([new_ids.get(i, i) for i in x.mesh_map])
149
150        if not inplace:
151            return x

Clean up skeleton.

def reroot(self, new_root):
153    def reroot(self, new_root):
154        """Reroot the skeleton.
155
156        Parameters
157        ----------
158        new_root :  int
159                    Index of node to use as new root. If the skeleton
160                    consists of multiple trees, only the tree containing the
161                    new root will be updated.
162
163        Returns
164        -------
165        Skeleton
166                    Skeleton with new root.
167
168        """
169        assert new_root in self.swc.index.values, f"Node index {new_root} not in skeleton."
170
171        # Make copy of self
172        x = self.copy()
173
174        # Check if the new root is already a root
175        if new_root in x.roots:
176            return x
177
178        # Get graph representation
179        G = x.get_graph()
180
181        # Get the path from the new root to the current root (of the same tree)
182        for r in x.roots:
183            try:
184                path = nx.shortest_path(G, source=new_root, target=r)
185                break
186            except nx.NetworkXNoPath:
187                continue
188
189        # Now we need to invert the path from the old root to the new root
190        new_parents = x.swc.set_index('node_id').parent_id.to_dict()
191        new_parents.update({c: p for p, c in zip(path[:-1], path[1:])})
192        new_parents[new_root] = -1
193
194        # Update the SWC table
195        x.swc["parent_id"] = x.swc.node_id.map(new_parents)
196
197        return x

Reroot the skeleton.

Parameters
  • new_root (int): Index of node to use as new root. If the skeleton consists of multiple trees, only the tree containing the new root will be updated.
Returns
  • Skeleton: Skeleton with new root.
def copy(self):
199    def copy(self):
200        """Return copy of the skeleton."""
201        return Skeleton(
202            swc=self.swc.copy() if not isinstance(self.swc, type(None)) else None,
203            mesh=self.mesh.copy() if not isinstance(self.mesh, type(None)) else None,
204            mesh_map=self.mesh_map.copy()
205            if not isinstance(self.mesh_map, type(None))
206            else None,
207            method=self.method,
208        )

Return copy of the skeleton.

def get_graph(self):
210    def get_graph(self):
211        """Generate networkX representation of the skeletons.
212
213        Distance between nodes will be used as edge weights.
214
215        Returns
216        -------
217        networkx.DiGraph
218
219        """
220        not_root = self.swc.parent_id >= 0
221        nodes = self.swc.loc[not_root]
222        parents = self.swc.set_index("node_id").loc[
223            self.swc.loc[not_root, "parent_id"].values
224        ]
225
226        dists = nodes[["x", "y", "z"]].values - parents[["x", "y", "z"]].values
227        dists = np.sqrt((dists**2).sum(axis=1))
228
229        G = nx.DiGraph()
230        G.add_nodes_from(self.swc.node_id.values)
231        G.add_weighted_edges_from(
232            zip(nodes.node_id.values, nodes.parent_id.values, dists)
233        )
234
235        return G

Generate networkX representation of the skeletons.

Distance between nodes will be used as edge weights.

Returns
  • networkx.DiGraph
def get_segments(self, weight='weight', return_lengths=False):
237    def get_segments(self, weight="weight", return_lengths=False):
238        """Generate a list of linear segments while maximizing segment lengths.
239
240        Parameters
241        ----------
242        weight :    'weight' | None, optional
243                    If ``"weight"`` use physical, geodesic length to determine
244                    segment length. If ``None`` use number of nodes (faster).
245        return_lengths : bool
246                    If True, also return lengths of segments according to ``weight``.
247
248        Returns
249        -------
250        segments :  list
251                    Segments as list of lists containing node IDs. List is
252                    sorted by segment lengths.
253        lengths :   list
254                    Length for each segment according to ``weight``. Only provided
255                    if `return_lengths` is True.
256
257        """
258        assert weight in ("weight", None), f'Unable to use weight "{weight}"'
259
260        # Get graph representation
261        G = self.get_graph()
262
263        # Get distances to root
264        dists = {}
265        for root in self.swc[self.swc.parent_id < 0].node_id.values:
266            dists.update(nx.shortest_path_length(G, target=root, weight=weight))
267
268        # Sort leaf nodes
269        endNodeIDs = self.leafs[self.leafs.parent_id >= 0].node_id.values
270        endNodeIDs = sorted(endNodeIDs, key=lambda x: dists.get(x, 0), reverse=True)
271
272        seen: set = set()
273        sequences = []
274        for nodeID in endNodeIDs:
275            sequence = [nodeID]
276            parents = list(G.successors(nodeID))
277            while True:
278                if not parents:
279                    break
280                parentID = parents[0]
281                sequence.append(parentID)
282                if parentID in seen:
283                    break
284                seen.add(parentID)
285                parents = list(G.successors(parentID))
286
287            if len(sequence) > 1:
288                sequences.append(sequence)
289
290        # Sort sequences by length
291        lengths = [dists[s[0]] - dists[s[-1]] for s in sequences]
292        sequences = [x for _, x in sorted(zip(lengths, sequences), reverse=True)]
293
294        if return_lengths:
295            return sequences, sorted(lengths, reverse=True)
296        else:
297            return sequences

Generate a list of linear segments while maximizing segment lengths.

Parameters
  • weight ('weight' | None, optional): If "weight" use physical, geodesic length to determine segment length. If None use number of nodes (faster).
  • return_lengths (bool): If True, also return lengths of segments according to weight.
Returns
  • segments (list): Segments as list of lists containing node IDs. List is sorted by segment lengths.
  • lengths (list): Length for each segment according to weight. Only provided if return_lengths is True.
def save_swc(self, filepath):
299    def save_swc(self, filepath):
300        """Save skeleton in SWC format.
301
302        Parameters
303        ----------
304        filepath :      path-like
305                        Filepath to save SWC to.
306
307        """
308        header = dedent(f"""\
309        # SWC format file
310        # based on specifications at http://www.neuronland.org/NLMorphologyConverter/MorphologyFormats/SWC/Spec.html
311        # Created on {datetime.date.today()} using skeletor (https://github.com/navis-org/skeletor)
312        # PointNo Label X Y Z Radius Parent
313        # Labels:
314        # 0 = undefined, 1 = soma, 5 = fork point, 6 = end point
315        """)
316
317        # Make copy of SWC table
318        swc = self.swc.copy()
319
320        # Set all labels to undefined
321        swc["label"] = 0
322        swc.loc[~swc.node_id.isin(swc.parent_id.values), "label"] = 6
323        n_childs = swc.groupby("parent_id").size()
324        bp = n_childs[n_childs > 1].index.values
325        swc.loc[swc.node_id.isin(bp), "label"] = 5
326
327        # Add radius if missing
328        if "radius" not in swc.columns:
329            swc["radius"] = 0
330
331        # Get things in order
332        swc = swc[["node_id", "label", "x", "y", "z", "radius", "parent_id"]]
333
334        # Adjust column titles
335        swc.columns = ["PointNo", "Label", "X", "Y", "Z", "Radius", "Parent"]
336
337        with open(filepath, "w") as file:
338            # Write header
339            file.write(header)
340
341            # Write data
342            writer = csv.writer(file, delimiter=" ")
343            writer.writerows(swc.astype(str).values)

Save skeleton in SWC format.

Parameters
  • filepath (path-like): Filepath to save SWC to.
def scene(self, mesh=False, **kwargs):
345    def scene(self, mesh=False, **kwargs):
346        """Return a Scene object containing the skeleton.
347
348        Returns
349        -------
350        scene :     trimesh.scene.scene.Scene
351                    Contains the skeleton and optionally the mesh.
352
353        """
354        if mesh:
355            if isinstance(self.mesh, type(None)):
356                raise ValueError("Skeleton has no mesh.")
357
358            self.mesh.visual.face_colors = [100, 100, 100, 100]
359
360            # Note the copy(): without it the transform in show() changes
361            # the original meshes
362            sc = tm.Scene([self.mesh.copy(), self.skeleton.copy()], **kwargs)
363        else:
364            sc = tm.Scene(self.skeleton.copy(), **kwargs)
365
366        return sc

Return a Scene object containing the skeleton.

Returns
  • scene (trimesh.scene.scene.Scene): Contains the skeleton and optionally the mesh.
def show(self, mesh=False, **kwargs):
368    def show(self, mesh=False, **kwargs):
369        """Render the skeleton in an opengl window. Requires pyglet.
370
371        Parameters
372        ----------
373        mesh :      bool
374                    If True, will render transparent mesh on top of the
375                    skeleton.
376
377        Returns
378        --------
379        scene :     trimesh.scene.Scene
380                    Scene with skeleton in it.
381
382        """
383        scene = self.scene(mesh=mesh)
384
385        # I encountered some issues if object space is big and the easiest
386        # way to work around this is to apply a transform such that the
387        # coordinates have -5 to +5 bounds
388        fac = 5 / np.fabs(self.skeleton.bounds).max()
389        scene.apply_transform(np.diag([fac, fac, fac, 1]))
390
391        return scene.show(**kwargs)

Render the skeleton in an opengl window. Requires pyglet.

Parameters
  • mesh (bool): If True, will render transparent mesh on top of the skeleton.
Returns
  • scene (trimesh.scene.Scene): Scene with skeleton in it.
def mend_breaks(self, dist_mult=5, dist_min=0, dist_max=inf):
393    def mend_breaks(self, dist_mult=5, dist_min=0, dist_max=np.inf):
394        """Mend breaks in the skeleton using the original mesh.
395
396        This works by comparing the connectivity of the original mesh with that
397        of the skeleton. If the shortest path between two adjacent vertices on the mesh
398        is shorter than the distance between the nodes in the skeleton, a new edge
399        is added to the skeleton.
400
401        Parameters
402        ----------
403        dist_mult : float, optional
404                    Factor by which the new edge should be shorter than the
405                    current shortest path between two nodes to be added.
406                    Lower values = fewer false negatives; higher values = fewer
407                    false positive edges.
408        dist_min :  float, optional
409                    Minimum distance between nodes to consider adding an edge.
410                    Use this to avoid adding very short edges.
411        dist_max :  float, optional
412                    Maximum distance between nodes to consider adding an edge.
413                    Use this to avoid adding very long edges.
414
415        Returns
416        -------
417        edges :     (N, 2) array
418                    Edges connecting the skeleton nodes.
419        vertices :  (N, 3) array
420                    Positions of the skeleton nodes.
421
422        """
423        # We need `.mesh_map` and `.mesh` to exist
424        if self.mesh_map is None:
425            raise ValueError("Skeleton must have a `mesh_map` to mend breaks.")
426        if self.mesh is None:
427            raise ValueError("Skeleton must have a `mesh` to mend breaks.")
428
429        # Make a copy of the mesh edges
430        edges = self.mesh.edges.copy()
431        # Map mesh vertices to skeleton vertices
432        edges[:, 0] = self.mesh_map[self.mesh.edges[:, 0]]
433        edges[:, 1] = self.mesh_map[self.mesh.edges[:, 1]]
434        # Deduplicate
435        edges = np.unique(edges, axis=0)
436        # Remove self edges
437        edges = edges[edges[:, 0] != edges[:, 1]]
438
439        G = self.get_graph().to_undirected()
440
441        # Remove edges that are already in the skeleton
442        edges = np.array([e for e in edges if not G.has_edge(*e)])
443
444        # Calculate distance between these new edge candidates
445        dists = np.sqrt(
446            ((self.vertices[edges[:, 0]] - self.vertices[edges[:, 1]]) ** 2).sum(axis=1)
447        )
448
449        # Sort by distance (lowest first)
450        edges = edges[np.argsort(dists)]
451        dists = dists[np.argsort(dists)]
452
453        for e, d in zip(edges, dists):
454            # Check if the new path would be shorter than the current shortest path
455            if (d * dist_mult) < nx.shortest_path_length(G, e[0], e[1]):
456                continue
457            # Check if the distance is within bounds
458            elif d < dist_min:
459                continue
460            elif d > dist_max:
461                continue
462            # Add edge
463            G.add_edge(*e, weight=d)
464
465        # The above may have introduced small triangles which we should try to remove
466        # by removing the longest edge in a triangle. I have also spotted more
467        # complex cases of four or more nodes forming false-positive loops but
468        # these will be harder to detect and remove.
469
470        # First collect neighbors for each node
471        later_nbrs = {}
472        for node, neighbors in G.adjacency():
473            later_nbrs[node] = {
474                n for n in neighbors if n not in later_nbrs and n != node
475            }
476
477        # Go over each node
478        triangles = set()
479        for node1, neighbors in later_nbrs.items():
480            # Go over each neighbor
481            for node2 in neighbors:
482                # Check if there is one or more third nodes that are connected to both
483                third_nodes = neighbors & later_nbrs[node2]
484                for node3 in third_nodes:
485                    # Add triangle (sort to deduplicate)
486                    triangles.add(tuple(sorted([node1, node2, node3])))
487
488        # Remove longest edge in each triangle
489        for t in triangles:
490            e1, e2, e3 = t[:2], t[1:], t[::2]
491            # Make sure all edges still exist (we may have already removed edges
492            # that were part of a previous triangle)
493            if any(not G.has_edge(*e) for e in (e1, e2, e3)):
494                continue
495            # Remove the longest edge
496            G.remove_edge(*max((e1, e2, e3), key=lambda e: G.edges[e]["weight"]))
497
498        return np.array(G.edges), self.vertices.copy()

Mend breaks in the skeleton using the original mesh.

This works by comparing the connectivity of the original mesh with that of the skeleton. If the shortest path between two adjacent vertices on the mesh is shorter than the distance between the nodes in the skeleton, a new edge is added to the skeleton.

Parameters
  • dist_mult (float, optional): Factor by which the new edge should be shorter than the current shortest path between two nodes to be added. Lower values = fewer false negatives; higher values = fewer false positive edges.
  • dist_min (float, optional): Minimum distance between nodes to consider adding an edge. Use this to avoid adding very short edges.
  • dist_max (float, optional): Maximum distance between nodes to consider adding an edge. Use this to avoid adding very long edges.
Returns
  • edges ((N, 2) array): Edges connecting the skeleton nodes.
  • vertices ((N, 3) array): Positions of the skeleton nodes.
def example_mesh():
43def example_mesh():
44    """Load and return example mesh.
45
46    The example mesh is a fruit fly neuron (an olfactory projection neuron of
47    the DA1 glomerulus) segmented from an EM image data set. It is part of the
48    Janelia hemibrain data set (see [here](https://neuprint.janelia.org)) [1].
49
50    References
51    ----------
52    [1] Louis K. Scheffer et al., eLife. 2020. doi: 10.7554/eLife.57443
53    A connectome and analysis of the adult Drosophila central brain
54
55    Returns
56    -------
57    trimesh.Trimesh
58
59    Examples
60    --------
61    >>> import skeletor as sk
62    >>> # Load this example mesh
63    >>> mesh = sk.example_mesh()
64
65    """
66    return tm.load_mesh(obj_path)

Load and return example mesh.

The example mesh is a fruit fly neuron (an olfactory projection neuron of the DA1 glomerulus) segmented from an EM image data set. It is part of the Janelia hemibrain data set (see here) [1].

References

[1] Louis K. Scheffer et al., eLife. 2020. doi: 10.7554/eLife.57443 A connectome and analysis of the adult Drosophila central brain

Returns
  • trimesh.Trimesh
Examples
>>> import skeletor as sk
>>> # Load this example mesh
>>> mesh = sk.example_mesh()