From 408959fa280f0fa75b36380f3dbba3f9019f87a2 Mon Sep 17 00:00:00 2001 From: Philipp Schlegel Date: Sat, 7 Sep 2024 18:31:06 +0100 Subject: [PATCH] new support function: navis.graph.simplify_graph --- docs/api.md | 1 + docs/changelog.md | 1 + navis/graph/__init__.py | 77 ++++++++++++++++++++++++--------- navis/graph/converters.py | 87 ++++++++++++++++++++++++++++++++++++-- navis/graph/graph_utils.py | 5 ++- 5 files changed, 146 insertions(+), 25 deletions(-) diff --git a/docs/api.md b/docs/api.md index a989f73f..a7acb628 100644 --- a/docs/api.md +++ b/docs/api.md @@ -516,6 +516,7 @@ Functions to convert between neurons graph representation (networkx or iGraph). | [`navis.rewire_skeleton()`][navis.rewire_skeleton] | {{ autosummary("navis.rewire_skeleton") }} | | [`navis.insert_nodes()`][navis.insert_nodes] | {{ autosummary("navis.insert_nodes") }} | | [`navis.remove_nodes()`][navis.remove_nodes] | {{ autosummary("navis.remove_nodes") }} | +| [`navis.graph.simplify_graph()`][navis.graph.simplify_graph] | {{ autosummary("navis.graph.simplify_graph") }} | ### Connectivity metrics diff --git a/docs/changelog.md b/docs/changelog.md index c9122811..32eeea01 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -26,6 +26,7 @@ This version contains a major internal rework of both [`navis.plot2d`][] and [`n ##### Additions - Added [Octarine](https://github.com/schlegelp/octarine) as the default backend for plotting from terminal - New function: [`navis.graph.skeleton_adjacency_matrix`][] computes the node adjacency for skeletons +- New function: [`navis.graph.simplify_graph`][] simplifies skeleton graphs to only root, branch and leaf nodes while preserving branch length (i.e. weights) - New [`NeuronList`][navis.NeuronList] method: [`get_neuron_attributes`][navis.NeuronList.get_neuron_attributes] is analagous to `dict.get` - [`NeuronLists`][navis.NeuronList] now implemented the `|` (`__or__`) operator which can be used to get the union of two [`NeuronLists`][navis.NeuronList] - [`navis.Volume`][] now have an (optional) `.units` property similar to neurons diff --git a/navis/graph/__init__.py b/navis/graph/__init__.py index 55f354d6..685ba8b6 100644 --- a/navis/graph/__init__.py +++ b/navis/graph/__init__.py @@ -11,24 +11,63 @@ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. -from .converters import (network2nx, network2igraph, neuron2igraph, nx2neuron, - neuron2nx, neuron2KDTree, neuron2tangents) -from .graph_utils import (classify_nodes, cut_skeleton, longest_neurite, - split_into_fragments, reroot_skeleton, distal_to, - dist_between, find_main_branchpoint, - generate_list_of_childs, geodesic_matrix, - node_label_sorting, _break_segments, - _generate_segments, segment_length, - _connected_components, rewire_skeleton, - connected_subgraph, insert_nodes, remove_nodes, - dist_to_root, skeleton_adjacency_matrix) -from .clinic import (health_check) +from .converters import ( + network2nx, + network2igraph, + neuron2igraph, + nx2neuron, + neuron2nx, + neuron2KDTree, + neuron2tangents, + simplify_graph, +) +from .graph_utils import ( + classify_nodes, + cut_skeleton, + longest_neurite, + split_into_fragments, + reroot_skeleton, + distal_to, + dist_between, + find_main_branchpoint, + generate_list_of_childs, + geodesic_matrix, + node_label_sorting, + _break_segments, + _generate_segments, + segment_length, + _connected_components, + rewire_skeleton, + connected_subgraph, + insert_nodes, + remove_nodes, + dist_to_root, + skeleton_adjacency_matrix, +) +from .clinic import health_check -__all__ = ['cut_skeleton', 'longest_neurite', 'split_into_fragments', - 'reroot_skeleton', 'distal_to', 'dist_between', 'segment_length', - 'find_main_branchpoint', 'geodesic_matrix', - 'rewire_skeleton', 'insert_nodes', 'remove_nodes', 'health_check', - 'graph_utils', 'network2nx', 'network2igraph', 'neuron2igraph', - 'nx2neuron', 'neuron2nx', 'neuron2KDTree', 'neuron2tangents', - 'dist_to_root'] +__all__ = [ + "cut_skeleton", + "longest_neurite", + "split_into_fragments", + "reroot_skeleton", + "distal_to", + "dist_between", + "segment_length", + "find_main_branchpoint", + "geodesic_matrix", + "rewire_skeleton", + "insert_nodes", + "remove_nodes", + "health_check", + "graph_utils", + "network2nx", + "network2igraph", + "neuron2igraph", + "nx2neuron", + "neuron2nx", + "neuron2KDTree", + "neuron2tangents", + "dist_to_root", +] diff --git a/navis/graph/converters.py b/navis/graph/converters.py index 102120a6..a29a6a7e 100644 --- a/navis/graph/converters.py +++ b/navis/graph/converters.py @@ -30,7 +30,7 @@ logger = config.get_logger(__name__) __all__ = sorted(['network2nx', 'network2igraph', 'neuron2igraph', 'nx2neuron', - 'neuron2nx', 'neuron2KDTree', 'neuron2tangents']) + 'neuron2nx', 'neuron2KDTree', 'neuron2tangents', "simplify_graph"]) def neuron2tangents(x: 'core.NeuronObject') -> 'core.Dotprops': @@ -217,13 +217,17 @@ def network2igraph(x: Union[pd.DataFrame, Iterable], return g -def neuron2nx(x: 'core.NeuronObject') -> nx.DiGraph: +def neuron2nx(x: 'core.NeuronObject', simplify=False) -> nx.DiGraph: """Turn Tree-, Mesh- or VoxelNeuron into an NetworkX graph. Parameters ---------- x : TreeNeuron | MeshNeuron | VoxelNeuron | NeuronList Uses simple 6-connectedness for voxels. + simplify : bool + For TreeNeurons only: simplify the graph by keeping only roots, + leaves and branching points. Preserves the original + branch lengths (i.e. weights). Returns ------- @@ -253,6 +257,9 @@ def neuron2nx(x: 'core.NeuronObject') -> nx.DiGraph: G.add_nodes_from(x.nodes.node_id.values) # Add edges G.add_weighted_edges_from(elist) + + if simplify: + simplify_graph(G, inplace=True) elif isinstance(x, core.MeshNeuron): G = nx.Graph() G.add_nodes_from(np.arange(x.n_vertices)) @@ -285,6 +292,70 @@ def neuron2nx(x: 'core.NeuronObject') -> nx.DiGraph: return G +def simplify_graph(G, inplace=False): + """Simplify graph (networkX or igraph). + + This function will simplify the graph by keeping only roots, leaves and + branching points. Preserves branch lengths (i.e. weights). + + """ + if not inplace: + G = G.copy() + + if isinstance(G, nx.Graph): + # Find all leaf and branch points + leafs = {n for n in G.nodes if G.in_degree(n) == 0 and G.out_degree(n) != 0} + branches = {n for n in G.nodes if G.in_degree(n) > 1 and G.out_degree(n) != 0} + roots = {n for n in G.nodes if G.out_degree(n) == 0} + + stop_nodes = roots | leafs | branches + + # Walk from each leaf/branch point to the next leaf, branch or root + to_remove = [] + for start_node in leafs | branches: + dist = 0 + node = start_node + while True: + parent = next(G.successors(node)) + dist += G.edges[node, parent]['weight'] + + if parent in stop_nodes: + G.add_weighted_edges_from([(start_node, parent, dist)]) + break + + to_remove.append(parent) + node = parent + + G.remove_nodes_from(to_remove) + else: + # Find all leaf and branch points + leafs = G.vs.select(_indegree=0, _outdegree_ne=0) + branches = G.vs.select(_indegree_gt=1, _outdegree_ne=0) + roots = G.vs.select(_outdegree=0) + + stop_nodes = np.concatenate((roots.indices, leafs.indices, branches.indices)) + + # Walk from each leaf/branch point to the next leaf, branch or root + to_remove = [] + for start_node in np.concatenate((leafs.indices, branches.indices)): + dist = 0 + node = start_node + while True: + parent = G.successors(node)[0] + dist += G.es[G.get_eid(node, parent)]['weight'] + + if parent in stop_nodes: + G.add_edge(start_node, parent, weight=dist) + break + + to_remove.append(parent) + node = parent + + G.delete_vertices(to_remove) + + return G + + def _voxels2edges(x, connectivity=18): """Turn VoxelNeuron into an edges. @@ -344,7 +415,8 @@ def _voxels2edges(x, connectivity=18): def neuron2igraph(x: 'core.NeuronObject', - connectivity=18, + simplify: bool = False, + connectivity: int = 18, raise_not_installed: bool = True) -> 'igraph.Graph': """Turn Tree-, Mesh- or VoxelNeuron(s) into an iGraph graph. @@ -354,8 +426,12 @@ def neuron2igraph(x: 'core.NeuronObject', ---------- x : TreeNeuron | MeshNeuron | VoxelNeuron | NeuronList Neuron(s) to convert. + simplify : bool + For TreeNeurons only: simplify the graph by keeping only roots, + leaves and branching points. Preserves the original branch + lengths (i.e. weights). connectivity : 6 | 18 | 26 - Connectedness for VoxelNeurons: + For VoxelNeurons only. Defines the connectedness: - 6 = faces - 18 = faces + edges - 26 = faces + edges + vertices @@ -425,6 +501,9 @@ def neuron2igraph(x: 'core.NeuronObject', w = np.sqrt(np.sum((tn_coords - parent_coords) ** 2, axis=1)) G.es['weight'] = w + + if simplify: + simplify_graph(G, inplace=True) elif isinstance(x, core.MeshNeuron): elist = x.trimesh.edges_unique G = igraph.Graph(elist, n=x.n_vertices, directed=False) diff --git a/navis/graph/graph_utils.py b/navis/graph/graph_utils.py index 3de01c82..958a095b 100644 --- a/navis/graph/graph_utils.py +++ b/navis/graph/graph_utils.py @@ -1912,8 +1912,9 @@ def node_label_sorting( # Get starting points (i.e. branches off the root) and sort by longest # path to a terminal (note we're operating on the simplified version # of the skeleton) + G = graph.simplify_graph(x.graph) curr_points = sorted( - list(x.simple.graph.predecessors(x.root[0])), + list(G.predecessors(x.root[0])), key=lambda n: dist_mat[n].max() + dist_mat.loc[n, x.root[0]], reverse=True, ) @@ -1927,7 +1928,7 @@ def node_label_sorting( pass else: new_points = sorted( - list(x.simple.graph.predecessors(nodes_walked[-1])), + list(G.predecessors(nodes_walked[-1])), # Use distance to the farthest terminal + distance to current node as sorting key key=lambda n: dist_mat[n].max() + dist_mat.loc[n, nodes_walked[-1]], reverse=True,