Skip to content

Commit bc5d2c4

Browse files
anna-grimanna-grim
andauthored
feat: major simplification, unified pipelines (#744)
* feat: major simplification, unified pipelines * refactor: updated results formating * refactor: save results format * refactor: split/merge pipelines use different img patch shapes * feat: input patch shapes * bug: updated import name * bug: removed parameter * feat: remove detected merges * refactor: upd * refactor: retrun merge nodes * bug: merge inference * bug: parameters for saving --------- Co-authored-by: anna-grim <anna.grim@alleninstitute.org>
1 parent 936607e commit bc5d2c4

16 files changed

Lines changed: 987 additions & 4089 deletions
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
"""
2+
Created on Wed July 2 14:00:00 2025
3+
4+
@author: Anna Grim
5+
@email: anna.grim@alleninstitute.org
6+
7+
Implementation of FragmentsGraph, a subclass of SkeletonGraph that represents
8+
a collection of neuron fragments and provides proofreading-specific operations.
9+
10+
"""
11+
12+
from tqdm import tqdm
13+
14+
import networkx as nx
15+
import numpy as np
16+
17+
from arborist.skeleton_graph import SkeletonGraph
18+
from arborist.utils.graph_loading import GraphLoader, count_nodes
19+
from neuron_proofreader.utils import geometry_util, img_util, util
20+
21+
22+
class FragmentsGraph(SkeletonGraph):
23+
"""
24+
Subclass of SkeletonGraph for neuron fragment graphs. Extends the base
25+
class with SWC loading, soma handling, and proofreading-specific
26+
operations.
27+
"""
28+
29+
def __init__(
30+
self,
31+
anisotropy=(1.0, 1.0, 1.0),
32+
min_cable_length=0,
33+
min_swc_pts=1,
34+
node_spacing=1,
35+
prune_depth=20,
36+
use_anisotropy=True,
37+
verbose=False,
38+
):
39+
"""
40+
Instantiates a FragmentsGraph object.
41+
42+
Parameters
43+
----------
44+
anisotropy : Tuple[float], optional
45+
Image to physical coordinates scaling factors. Default is
46+
(1.0, 1.0, 1.0).
47+
min_cable_length : float, optional
48+
Minimum path length of fragments loaded into graph. Default is 0.
49+
node_spacing : float, optional
50+
Distance (in microns) between neighboring nodes. Default is 1μm.
51+
prune_depth : float, optional
52+
Branches shorter than this (in microns) are removed. Default is
53+
20μm.
54+
use_anisotropy : bool, optional
55+
Whether to apply anisotropy to SWC coordinates. Default is True.
56+
verbose : bool, optional
57+
Whether to display progress bars. Default is False.
58+
"""
59+
super().__init__(
60+
anisotropy=anisotropy,
61+
node_spacing=node_spacing,
62+
verbose=verbose,
63+
)
64+
self.soma_centroids = list()
65+
self.soma_component_ids = list()
66+
67+
anisotropy_actual = anisotropy if use_anisotropy else (1.0, 1.0, 1.0)
68+
self.graph_loader = GraphLoader(
69+
anisotropy=anisotropy_actual,
70+
min_cable_length=min_cable_length,
71+
node_spacing=node_spacing,
72+
prune_depth=prune_depth,
73+
verbose=verbose,
74+
)
75+
76+
# --- Node Attribute Helpers ---
77+
def resize_node_attr(self, new_shape, attr_name):
78+
node_attr = getattr(self, attr_name)
79+
new_node_attr = np.empty(new_shape, dtype=node_attr.dtype)
80+
new_node_attr[: len(node_attr)] = node_attr
81+
setattr(self, attr_name, new_node_attr)
82+
83+
# --- Load ---
84+
def load(self, swc_pointer):
85+
"""
86+
Loads SWC files into the graph.
87+
88+
Parameters
89+
----------
90+
swc_pointer : str
91+
Object that points to SWC files to be loaded.
92+
"""
93+
irreducibles = self.graph_loader(swc_pointer)
94+
95+
num_nodes = count_nodes(irreducibles)
96+
self.node_component_id = np.zeros((num_nodes), dtype=int)
97+
self.node_radius = np.zeros((num_nodes), dtype=np.float16)
98+
self.node_xyz = np.zeros((num_nodes, 3), dtype=np.float32)
99+
100+
component_id = 0
101+
while irreducibles:
102+
self.add_connected_component(irreducibles.pop(), component_id)
103+
component_id += 1
104+
105+
self.check_swc_ids()
106+
self.set_kdtree()
107+
108+
# --- Soma Operations ---
109+
def load_somas(self, soma_centroids):
110+
num_components = nx.number_connected_components(self)
111+
num_nodes = self.number_of_nodes()
112+
num_somas = len(soma_centroids)
113+
114+
self.resize_node_attr((num_nodes + num_somas), "node_component_id")
115+
self.resize_node_attr((num_nodes + num_somas), "node_radius")
116+
self.resize_node_attr((num_nodes + num_somas, 3), "node_xyz")
117+
118+
for idx, xyz in enumerate(soma_centroids, start=1):
119+
node_id = self.number_of_nodes()
120+
assert node_id not in self.nodes
121+
dist_i, i = self.kdtree.query(xyz)
122+
if dist_i < 25:
123+
self.add_edge(i, node_id)
124+
component_id = self.node_component_id[i]
125+
swc_id = self.node_swc_id(i)
126+
elif dist_i < 50:
127+
self.add_node(node_id)
128+
component_id = num_components + idx
129+
swc_id = f"soma-component-{idx}"
130+
else:
131+
continue
132+
self.component_id_to_swc_id[component_id] = swc_id
133+
self.node_component_id[node_id] = component_id
134+
self.node_radius[node_id] = 20
135+
self.node_xyz[node_id] = xyz
136+
self.soma_centroids.append(xyz)
137+
138+
self.relabel_nodes()
139+
140+
def connect_soma_fragments(self, max_dist=25):
141+
merge_cnt, somas_connected = 0, list()
142+
for soma_node in self.soma_nodes():
143+
soma_xyz = self.node_xyz[soma_node]
144+
nodes = self.kdtree.query_ball_point(soma_xyz, max_dist)
145+
nodes = np.array(nodes, dtype=int)
146+
for cid in np.unique(self.node_component_id[nodes]):
147+
soma_component_id = self.node_component_id[soma_node]
148+
if cid != soma_component_id:
149+
idxs = np.where(self.node_component_id[nodes] == cid)[0]
150+
dists = np.sum(
151+
(self.node_xyz[nodes[idxs]] - soma_xyz) ** 2, axis=1
152+
)
153+
node = nodes[idxs[np.argmin(dists)]]
154+
if not nx.has_path(self, node, soma_node):
155+
self.add_edge(node, soma_node)
156+
self.update_component_ids(soma_component_id, node)
157+
merge_cnt += 1
158+
somas_connected.append(soma_component_id)
159+
160+
results = [
161+
f"# Somas Connected: {len(np.unique(somas_connected))}",
162+
f"# Connections Added: {merge_cnt}",
163+
]
164+
return "\n".join(results)
165+
166+
def soma_nodes(self):
167+
soma_nodes = list()
168+
for dist_i, i in map(self.kdtree.query, self.soma_centroids):
169+
if dist_i < 5:
170+
soma_nodes.append(i)
171+
return soma_nodes
172+
173+
def remove_merge_sites(self, merge_site_nodes, max_depth=10):
174+
"""
175+
Removes detected merge sites and their local neighborhoods from the
176+
graph.
177+
178+
Parameters
179+
----------
180+
merge_site_nodes : list[int]
181+
Node IDs identified as merge sites.
182+
max_depth : float, optional
183+
Radius (in microns) around each merge site to remove. Default
184+
is 10.
185+
"""
186+
rm_nodes = set()
187+
for root in tqdm(merge_site_nodes, desc="Remove Merge Sites"):
188+
root = self.find_nearby_branching_node(root)
189+
nbhd = self.nodes_within_distance(root, max_depth)
190+
for i in list(nbhd):
191+
if i != root and self.degree[i] >= 3:
192+
nbhd.extend(self.nodes_within_distance(root, 8))
193+
rm_nodes.update(set(nbhd))
194+
self.remove_nodes(rm_nodes)
195+
print("# Nodes Deleted:", len(rm_nodes))
196+
197+
# --- Image Coordinate Helpers ---
198+
def node_voxel(self, i):
199+
"""
200+
Gets the voxel coordinate of the given node.
201+
"""
202+
return img_util.to_voxels(self.node_xyz[i], self.anisotropy)
203+
204+
def node_local_voxel(self, node, offset):
205+
"""
206+
Computes the local voxel coordinate of the given node within a patch.
207+
"""
208+
return tuple([v - o for v, o in zip(self.node_voxel(node), offset)])
209+
210+
def clip_to_bbox(self, metadata_path):
211+
"""
212+
Clips skeletons to the bounding box defined in a metadata JSON file.
213+
"""
214+
if util.check_gcs_file_exists(metadata_path):
215+
metadata = util.read_json(metadata_path)
216+
origin = metadata["chunk_origin"][::-1]
217+
shape = metadata["chunk_shape"][::-1]
218+
nodes = list()
219+
for i in self.nodes:
220+
voxel = np.array(self.node_voxel(i))
221+
if not img_util.is_contained(voxel - origin, shape):
222+
nodes.append(i)
223+
self.remove_nodes_from(nodes)
224+
self.relabel_nodes()
225+
226+
def tangent_from_leaf(self, leaf, max_depth=np.inf):
227+
"""
228+
Computes the tangent vector of the path emanating from a leaf.
229+
"""
230+
path = self.path_from_leaf(leaf, max_depth=max_depth)
231+
return geometry_util.tangent(self.node_xyz[np.array(path)])
232+
233+
def __repr__(self):
234+
n_components = format(nx.number_connected_components(self), ",")
235+
n_nodes = format(self.number_of_nodes(), ",")
236+
n_edges = format(self.number_of_edges(), ",")
237+
return (
238+
f" FragmentsGraph(\n"
239+
f" num_connected_components={n_components},\n"
240+
f" num_nodes={n_nodes},\n"
241+
f" num_edges={n_edges},\n"
242+
f" )"
243+
)

0 commit comments

Comments
 (0)