|
| 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