nettracer3d 1.1.9__tar.gz → 1.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {nettracer3d-1.1.9/src/nettracer3d.egg-info → nettracer3d-1.2.3}/PKG-INFO +3 -7
  2. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/README.md +2 -6
  3. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/pyproject.toml +1 -1
  4. nettracer3d-1.2.3/src/nettracer3d/branch_stitcher.py +420 -0
  5. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/filaments.py +29 -43
  6. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/nettracer.py +63 -24
  7. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/nettracer_gui.py +110 -37
  8. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/tutorial.py +22 -1
  9. {nettracer3d-1.1.9 → nettracer3d-1.2.3/src/nettracer3d.egg-info}/PKG-INFO +3 -7
  10. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d.egg-info/SOURCES.txt +1 -0
  11. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/LICENSE +0 -0
  12. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/setup.cfg +0 -0
  13. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/__init__.py +0 -0
  14. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/cellpose_manager.py +0 -0
  15. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/community_extractor.py +0 -0
  16. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/excelotron.py +0 -0
  17. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/modularity.py +0 -0
  18. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/morphology.py +0 -0
  19. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/neighborhoods.py +0 -0
  20. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/network_analysis.py +0 -0
  21. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/network_draw.py +0 -0
  22. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/node_draw.py +0 -0
  23. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/painting.py +0 -0
  24. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/proximity.py +0 -0
  25. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/run.py +0 -0
  26. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/segmenter.py +0 -0
  27. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/segmenter_GPU.py +0 -0
  28. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/simple_network.py +0 -0
  29. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/smart_dilate.py +0 -0
  30. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d/stats.py +0 -0
  31. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d.egg-info/dependency_links.txt +0 -0
  32. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d.egg-info/entry_points.txt +0 -0
  33. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d.egg-info/requires.txt +0 -0
  34. {nettracer3d-1.1.9 → nettracer3d-1.2.3}/src/nettracer3d.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nettracer3d
3
- Version: 1.1.9
3
+ Version: 1.2.3
4
4
  Summary: Scripts for intializing and analyzing networks from segmentations of three dimensional images.
5
5
  Author-email: Liam McLaughlin <liamm@wustl.edu>
6
6
  Project-URL: Documentation, https://nettracer3d.readthedocs.io/en/latest/
@@ -110,12 +110,8 @@ McLaughlin, L., Zhang, B., Sharma, S. et al. Three dimensional multiscalar neuro
110
110
 
111
111
  NetTracer3D was developed by Liam McLaughlin while working under Dr. Sanjay Jain at Washington University School of Medicine.
112
112
 
113
- -- Version 1.1.9 Updates --
113
+ -- Version 1.2.3 Updates --
114
114
 
115
- * Fixed saving of Boolean True/False arrays.
116
- * Fixed branch functions not working correctly when a temporary downsample was being applied
117
- * Added the 'filaments tracer' which can be used to improve vessel based segmentations
118
- * Removed options for 'cubic' downsampling during processing as this option actually made outputs worse.
119
- * Rearranged the 'Analyze -> Stats' menu
115
+ * Tweaked the 'branch unification params' - now hard rejects any sharp forks in the branches so branches will mostly be straight.
120
116
 
121
117
 
@@ -65,12 +65,8 @@ McLaughlin, L., Zhang, B., Sharma, S. et al. Three dimensional multiscalar neuro
65
65
 
66
66
  NetTracer3D was developed by Liam McLaughlin while working under Dr. Sanjay Jain at Washington University School of Medicine.
67
67
 
68
- -- Version 1.1.9 Updates --
68
+ -- Version 1.2.3 Updates --
69
69
 
70
- * Fixed saving of Boolean True/False arrays.
71
- * Fixed branch functions not working correctly when a temporary downsample was being applied
72
- * Added the 'filaments tracer' which can be used to improve vessel based segmentations
73
- * Removed options for 'cubic' downsampling during processing as this option actually made outputs worse.
74
- * Rearranged the 'Analyze -> Stats' menu
70
+ * Tweaked the 'branch unification params' - now hard rejects any sharp forks in the branches so branches will mostly be straight.
75
71
 
76
72
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nettracer3d"
3
- version = "1.1.9"
3
+ version = "1.2.3"
4
4
  authors = [
5
5
  { name="Liam McLaughlin", email="liamm@wustl.edu" },
6
6
  ]
@@ -0,0 +1,420 @@
1
+ import numpy as np
2
+ import networkx as nx
3
+ from . import nettracer as n3d
4
+ from scipy.ndimage import distance_transform_edt, gaussian_filter, binary_fill_holes
5
+ from scipy.spatial import cKDTree
6
+ from skimage.morphology import remove_small_objects, skeletonize
7
+ import warnings
8
+ warnings.filterwarnings('ignore')
9
+
10
+
11
+ class VesselDenoiser:
12
+ """
13
+ Denoise vessel segmentations using graph-based geometric features
14
+ """
15
+
16
+ def __init__(self,
17
+ score_thresh = 2):
18
+
19
+ self.score_thresh = score_thresh
20
+
21
+
22
+ def select_kernel_points_topology(self, data, skeleton):
23
+ """
24
+ ENDPOINTS ONLY version: Returns only skeleton endpoints (degree=1 nodes)
25
+ """
26
+ skeleton_coords = np.argwhere(skeleton)
27
+ if len(skeleton_coords) == 0:
28
+ return skeleton_coords
29
+
30
+ # Map coord -> index
31
+ coord_to_idx = {tuple(c): i for i, c in enumerate(skeleton_coords)}
32
+
33
+ # Build full 26-connected skeleton graph
34
+ skel_graph = nx.Graph()
35
+ for i, c in enumerate(skeleton_coords):
36
+ skel_graph.add_node(i, pos=c)
37
+
38
+ nbr_offsets = [(dz, dy, dx)
39
+ for dz in (-1, 0, 1)
40
+ for dy in (-1, 0, 1)
41
+ for dx in (-1, 0, 1)
42
+ if not (dz == dy == dx == 0)]
43
+
44
+ for i, c in enumerate(skeleton_coords):
45
+ cz, cy, cx = c
46
+ for dz, dy, dx in nbr_offsets:
47
+ nb = (cz + dz, cy + dy, cx + dx)
48
+ j = coord_to_idx.get(nb, None)
49
+ if j is not None and j > i:
50
+ skel_graph.add_edge(i, j)
51
+
52
+ # Get degree per voxel
53
+ deg = dict(skel_graph.degree())
54
+
55
+ # ONLY keep endpoints (degree=1)
56
+ endpoints = {i for i, d in deg.items() if d == 1}
57
+
58
+ # Return endpoint coordinates
59
+ kernel_coords = np.array([skeleton_coords[i] for i in endpoints])
60
+ return kernel_coords
61
+
62
+
63
+ def extract_kernel_features(self, skeleton, distance_map, kernel_pos, radius=5):
64
+ """Extract geometric features for a kernel at a skeleton point"""
65
+ z, y, x = kernel_pos
66
+ shape = skeleton.shape
67
+
68
+ features = {}
69
+
70
+ # Vessel radius at this point
71
+ features['radius'] = distance_map[z, y, x]
72
+
73
+ # Local skeleton density (connectivity measure)
74
+ z_min = max(0, z - radius)
75
+ z_max = min(shape[0], z + radius + 1)
76
+ y_min = max(0, y - radius)
77
+ y_max = min(shape[1], y + radius + 1)
78
+ x_min = max(0, x - radius)
79
+ x_max = min(shape[2], x + radius + 1)
80
+
81
+ local_region = skeleton[z_min:z_max, y_min:y_max, x_min:x_max]
82
+ features['local_density'] = np.sum(local_region) / max(local_region.size, 1)
83
+
84
+ # Local direction vector
85
+ features['direction'] = self._compute_local_direction(
86
+ skeleton, kernel_pos, radius
87
+ )
88
+
89
+ # Position
90
+ features['pos'] = np.array(kernel_pos)
91
+
92
+ # ALL kernels are endpoints in this version
93
+ features['is_endpoint'] = True
94
+
95
+ return features
96
+
97
+
98
+ def _compute_local_direction(self, skeleton, pos, radius=5):
99
+ """Compute principal direction of skeleton in local neighborhood"""
100
+ z, y, x = pos
101
+ shape = skeleton.shape
102
+
103
+ z_min = max(0, z - radius)
104
+ z_max = min(shape[0], z + radius + 1)
105
+ y_min = max(0, y - radius)
106
+ y_max = min(shape[1], y + radius + 1)
107
+ x_min = max(0, x - radius)
108
+ x_max = min(shape[2], x + radius + 1)
109
+
110
+ local_skel = skeleton[z_min:z_max, y_min:y_max, x_min:x_max]
111
+ coords = np.argwhere(local_skel)
112
+
113
+ if len(coords) < 2:
114
+ return np.array([0., 0., 1.])
115
+
116
+ # PCA to find principal direction
117
+ centered = coords - coords.mean(axis=0)
118
+ cov = np.cov(centered.T)
119
+ eigenvalues, eigenvectors = np.linalg.eigh(cov)
120
+ principal_direction = eigenvectors[:, -1] # largest eigenvalue
121
+
122
+ return principal_direction / (np.linalg.norm(principal_direction) + 1e-10)
123
+
124
+ def group_endpoints_by_vertex(self, skeleton_points, verts):
125
+ """
126
+ Group endpoints by which vertex (labeled blob) they belong to
127
+
128
+ Returns:
129
+ --------
130
+ vertex_to_endpoints : dict
131
+ Dictionary mapping vertex_label -> [list of endpoint indices]
132
+ """
133
+ vertex_to_endpoints = {}
134
+
135
+ for idx, pos in enumerate(skeleton_points):
136
+ z, y, x = pos.astype(int)
137
+ vertex_label = int(verts[z, y, x])
138
+
139
+ # Skip if endpoint is not in any vertex (label=0)
140
+ if vertex_label == 0:
141
+ continue
142
+
143
+ if vertex_label not in vertex_to_endpoints:
144
+ vertex_to_endpoints[vertex_label] = []
145
+
146
+ vertex_to_endpoints[vertex_label].append(idx)
147
+
148
+ return vertex_to_endpoints
149
+
150
+ def compute_edge_features(self, feat_i, feat_j):
151
+ """
152
+ Compute features for potential connection between two endpoints
153
+ NO DISTANCE-BASED FEATURES - only radius and direction
154
+ """
155
+ features = {}
156
+
157
+ # Euclidean distance (for reference only, not used in scoring)
158
+ pos_diff = feat_j['pos'] - feat_i['pos']
159
+ features['distance'] = np.linalg.norm(pos_diff)
160
+
161
+ # Radius similarity
162
+ r_i, r_j = feat_i['radius'], feat_j['radius']
163
+ features['radius_diff'] = abs(r_i - r_j)
164
+ features['radius_ratio'] = min(r_i, r_j) / (max(r_i, r_j) + 1e-10)
165
+ features['mean_radius'] = (r_i + r_j) / 2.0
166
+
167
+ # Direction alignment
168
+ direction_vec = pos_diff / (features['distance'] + 1e-10)
169
+
170
+ # Alignment with both local directions
171
+ align_i = abs(np.dot(feat_i['direction'], direction_vec))
172
+ align_j = abs(np.dot(feat_j['direction'], direction_vec))
173
+ features['alignment'] = (align_i + align_j) / 2.0
174
+
175
+ # Smoothness: how well does connection align with both local directions
176
+ features['smoothness'] = min(align_i, align_j)
177
+
178
+ # Density similarity
179
+ features['density_diff'] = abs(feat_i['local_density'] - feat_j['local_density'])
180
+
181
+ return features
182
+
183
+ def score_connection(self, edge_features):
184
+ score = 0.0
185
+
186
+ # HARD REJECT for definite forks/sharp turns
187
+ if edge_features['smoothness'] < 0.5: # At least one endpoint pointing away
188
+ return -999
189
+
190
+ # Base similarity scoring
191
+ score += edge_features['radius_ratio'] * 10.0
192
+ score += edge_features['alignment'] * 8.0
193
+ score += edge_features['smoothness'] * 6.0
194
+ score -= edge_features['density_diff'] * 0.5
195
+
196
+ # PENALTY for poor directional alignment (punish forks!)
197
+ # Alignment < 0.5 means vessels are pointing in different directions
198
+ # This doesn't trigger that often so it might be redundant with the above step
199
+ if edge_features['alignment'] < 0.5:
200
+ penalty = (0.5 - edge_features['alignment']) * 15.0
201
+ score -= penalty
202
+
203
+ # ADDITIONAL PENALTY for sharp turns/forks --- no longer in use since we now hard reject these, but I left this in here to reverse it later potentially
204
+ # Smoothness < 0.4 means at least one endpoint points away
205
+ #if edge_features['smoothness'] < 0.4:
206
+ # penalty = (0.4 - edge_features['smoothness']) * 20.0
207
+ # score -= penalty
208
+
209
+ # Size bonus: ONLY if vessels already match well
210
+
211
+ if edge_features['radius_ratio'] > 0.7 and edge_features['alignment'] > 0.5:
212
+ mean_radius = edge_features['mean_radius']
213
+ score += mean_radius * 1.5
214
+
215
+ return score
216
+
217
+ def connect_vertices_across_gaps(self, skeleton_points, kernel_features,
218
+ labeled_skeleton, vertex_to_endpoints, verbose=False):
219
+ """
220
+ Connect vertices by finding best endpoint pair across each vertex
221
+ Each vertex makes at most one connection
222
+ """
223
+ # Initialize label dictionary: label -> label (identity mapping)
224
+ unique_labels = np.unique(labeled_skeleton[labeled_skeleton > 0])
225
+ label_dict = {int(label): int(label) for label in unique_labels}
226
+
227
+ # Map endpoint index to its skeleton label
228
+ endpoint_to_label = {}
229
+ for idx, pos in enumerate(skeleton_points):
230
+ z, y, x = pos.astype(int)
231
+ label = int(labeled_skeleton[z, y, x])
232
+ endpoint_to_label[idx] = label
233
+
234
+ # Find root label (union-find helper)
235
+ def find_root(label):
236
+ root = label
237
+ while label_dict[root] != root:
238
+ root = label_dict[root]
239
+ return root
240
+
241
+ # Iterate through each vertex
242
+ for vertex_label, endpoint_indices in vertex_to_endpoints.items():
243
+ if len(endpoint_indices) < 2:
244
+ # Need at least 2 endpoints to make a connection
245
+ continue
246
+
247
+ if verbose and len(endpoint_indices) > 0:
248
+ print(f"\nVertex {vertex_label}: {len(endpoint_indices)} endpoints")
249
+
250
+ # Find best pair of endpoints to connect
251
+ best_i = None
252
+ best_j = None
253
+ best_score = -np.inf
254
+
255
+ # Try all pairs of endpoints within this vertex
256
+ for i in range(len(endpoint_indices)):
257
+ for j in range(i + 1, len(endpoint_indices)):
258
+ idx_i = endpoint_indices[i]
259
+ idx_j = endpoint_indices[j]
260
+
261
+ feat_i = kernel_features[idx_i]
262
+ feat_j = kernel_features[idx_j]
263
+
264
+ label_i = endpoint_to_label[idx_i]
265
+ label_j = endpoint_to_label[idx_j]
266
+
267
+ root_i = find_root(label_i)
268
+ root_j = find_root(label_j)
269
+
270
+ # Skip if already unified
271
+ if root_i == root_j:
272
+ continue
273
+
274
+ # Compute edge features (no skeleton needed, no distance penalty)
275
+ edge_feat = self.compute_edge_features(feat_i, feat_j)
276
+
277
+ # Score this connection
278
+ score = self.score_connection(edge_feat)
279
+
280
+ # Apply threshold
281
+ if score > self.score_thresh and score > best_score:
282
+ best_score = score
283
+ best_i = idx_i
284
+ best_j = idx_j
285
+
286
+ # Make the best connection for this vertex
287
+ if best_i is not None and best_j is not None:
288
+ label_i = endpoint_to_label[best_i]
289
+ label_j = endpoint_to_label[best_j]
290
+
291
+ root_i = find_root(label_i)
292
+ root_j = find_root(label_j)
293
+
294
+ # Unify labels: point larger label to smaller label
295
+ if root_i < root_j:
296
+ label_dict[root_j] = root_i
297
+ unified_label = root_i
298
+ else:
299
+ label_dict[root_i] = root_j
300
+ unified_label = root_j
301
+
302
+ if verbose:
303
+ feat_i = kernel_features[best_i]
304
+ feat_j = kernel_features[best_j]
305
+ print(f" ✓ Connected labels {label_i} <-> {label_j} (unified as {unified_label})")
306
+ print(f" Score: {best_score:.2f} | Radii: {feat_i['radius']:.1f}, {feat_j['radius']:.1f}")
307
+
308
+ return label_dict
309
+
310
+ def denoise(self, data, skeleton, labeled_skeleton, verts, verbose=False):
311
+ """
312
+ Main pipeline: unify skeleton labels by connecting endpoints at vertices
313
+
314
+ Parameters:
315
+ -----------
316
+ data : ndarray
317
+ 3D binary segmentation (for distance transform)
318
+ skeleton : ndarray
319
+ 3D binary skeleton
320
+ labeled_skeleton : ndarray
321
+ Labeled skeleton (each branch has unique label)
322
+ verts : ndarray
323
+ Labeled vertices (blobs where branches meet)
324
+ verbose : bool
325
+ Print progress
326
+
327
+ Returns:
328
+ --------
329
+ label_dict : dict
330
+ Dictionary mapping old labels to unified labels
331
+ """
332
+ if verbose:
333
+ print("Starting skeleton label unification...")
334
+ print(f"Initial unique labels: {len(np.unique(labeled_skeleton[labeled_skeleton > 0]))}")
335
+
336
+ # Compute distance transform
337
+ if verbose:
338
+ print("Computing distance transform...")
339
+ distance_map = distance_transform_edt(data)
340
+
341
+ # Extract endpoints
342
+ if verbose:
343
+ print("Extracting skeleton endpoints...")
344
+ kernel_points = self.select_kernel_points_topology(data, skeleton)
345
+
346
+ if verbose:
347
+ print(f"Found {len(kernel_points)} endpoints")
348
+
349
+ # Group endpoints by vertex
350
+ if verbose:
351
+ print("Grouping endpoints by vertex...")
352
+ vertex_to_endpoints = self.group_endpoints_by_vertex(kernel_points, verts)
353
+
354
+ if verbose:
355
+ print(f"Found {len(vertex_to_endpoints)} vertices with endpoints")
356
+ vertices_with_multiple = sum(1 for v in vertex_to_endpoints.values() if len(v) >= 2)
357
+ print(f" {vertices_with_multiple} vertices have 2+ endpoints (connection candidates)")
358
+
359
+ # Extract features for each endpoint
360
+ if verbose:
361
+ print("Extracting endpoint features...")
362
+ kernel_features = []
363
+ for pt in kernel_points:
364
+ feat = self.extract_kernel_features(skeleton, distance_map, pt)
365
+ kernel_features.append(feat)
366
+
367
+ # Connect vertices
368
+ if verbose:
369
+ print("Connecting endpoints at vertices...")
370
+ label_dict = self.connect_vertices_across_gaps(
371
+ kernel_points, kernel_features, labeled_skeleton,
372
+ vertex_to_endpoints, verbose
373
+ )
374
+
375
+ # Compress label dictionary (path compression for union-find)
376
+ if verbose:
377
+ print("\nCompressing label mappings...")
378
+ for label in list(label_dict.keys()):
379
+ root = label
380
+ while label_dict[root] != root:
381
+ root = label_dict[root]
382
+ label_dict[label] = root
383
+
384
+ # Count final unified components
385
+ final_labels = set(label_dict.values())
386
+ if verbose:
387
+ print(f"Final unified labels: {len(final_labels)}")
388
+ print(f"Reduced from {len(label_dict)} to {len(final_labels)} components")
389
+
390
+ return label_dict
391
+
392
+
393
+ def trace(data, labeled_skeleton, verts, score_thresh=10, verbose=False):
394
+ """
395
+ Trace and unify skeleton labels using vertex-based endpoint grouping
396
+ """
397
+ skeleton = n3d.binarize(labeled_skeleton)
398
+
399
+ # Create denoiser
400
+ denoiser = VesselDenoiser(score_thresh=score_thresh)
401
+
402
+ # Run label unification
403
+ label_dict = denoiser.denoise(data, skeleton, labeled_skeleton, verts, verbose=verbose)
404
+
405
+ # Apply unified labels efficiently (SINGLE PASS)
406
+ # Create lookup array: index by old label, get new label
407
+ max_label = np.max(labeled_skeleton)
408
+ label_map = np.arange(max_label + 1) # Identity mapping by default
409
+
410
+ for old_label, new_label in label_dict.items():
411
+ label_map[old_label] = new_label
412
+
413
+ # Single array indexing operation
414
+ relabeled_skeleton = label_map[labeled_skeleton]
415
+
416
+ return relabeled_skeleton
417
+
418
+
419
+ if __name__ == "__main__":
420
+ print("Test area")
@@ -633,8 +633,6 @@ class VesselDenoiser:
633
633
  edge_feat["skeleton_steps"] = steps
634
634
  G.add_edge(k_id, j_id, **edge_feat)
635
635
 
636
- # CRITICAL FIX FOR ISSUE 1: Second pass to catch any directly adjacent kernels
637
- # that might have been missed due to complex topology
638
636
  # This ensures ALL kernels that are neighbors in the skeleton are connected
639
637
  for k_id, s_idx in kernel_to_skel_idx.items():
640
638
  # Check all neighbors of this kernel in the skeleton
@@ -656,24 +654,20 @@ class VesselDenoiser:
656
654
  def connect_endpoints_across_gaps(self, G, skeleton_points, kernel_features, skeleton):
657
655
  """
658
656
  Second stage: Let endpoints reach out to connect across gaps
659
- Only endpoints can initiate connections
660
-
661
- Strategy:
662
- - Within same component: reconnect broken paths (gap filling)
663
- - Between components: only connect if strong evidence (path support, alignment)
657
+ Optimized version using Union-Find for fast connectivity checks
664
658
  """
659
+ from scipy.cluster.hierarchy import DisjointSet
660
+
665
661
  # Identify all endpoints
666
662
  endpoint_nodes = [i for i, feat in enumerate(kernel_features) if feat['is_endpoint']]
667
663
 
668
664
  if len(endpoint_nodes) == 0:
669
665
  return G
670
666
 
671
- # Get initial component membership
672
- components = list(nx.connected_components(G))
673
- node_to_component = {}
674
- for comp_idx, comp in enumerate(components):
675
- for node in comp:
676
- node_to_component[node] = comp_idx
667
+ # Initialize Union-Find with existing graph connections
668
+ ds = DisjointSet(range(len(skeleton_points)))
669
+ for u, v in G.edges():
670
+ ds.merge(u, v)
677
671
 
678
672
  # Build KD-tree for all points
679
673
  tree = cKDTree(skeleton_points)
@@ -682,7 +676,6 @@ class VesselDenoiser:
682
676
  feat_i = kernel_features[endpoint_idx]
683
677
  pos_i = skeleton_points[endpoint_idx]
684
678
  direction_i = feat_i['direction']
685
- component_i = node_to_component.get(endpoint_idx, -1)
686
679
 
687
680
  # Use radius-aware connection distance
688
681
  if self.radius_aware_distance:
@@ -698,32 +691,24 @@ class VesselDenoiser:
698
691
  if endpoint_idx == j:
699
692
  continue
700
693
 
701
- # Skip if already connected via any path
702
- try:
703
- if nx.has_path(G, endpoint_idx, j):
704
- continue
705
- except:
706
- pass
694
+ # FAST connectivity check - O(1) amortized instead of O(V+E)
695
+ if ds.connected(endpoint_idx, j):
696
+ continue
707
697
 
708
698
  feat_j = kernel_features[j]
709
699
  pos_j = skeleton_points[j]
710
700
  is_endpoint_j = feat_j['is_endpoint']
711
- component_j = node_to_component.get(j, -1)
712
701
 
713
- # Check if they're in the same component
714
- same_component = (component_i == component_j and component_i != -1)
702
+ # Check if they're in the same component (using union-find)
703
+ same_component = ds.connected(endpoint_idx, j)
715
704
 
716
- # Check directionality: is j in the direction this endpoint is pointing?
705
+ # Check directionality
717
706
  to_target = pos_j - pos_i
718
707
  to_target_normalized = to_target / (np.linalg.norm(to_target) + 1e-10)
719
708
  direction_dot = np.dot(direction_i, to_target_normalized)
720
709
 
721
710
  # Compute edge features
722
- edge_feat = self.compute_edge_features(
723
- feat_i,
724
- feat_j,
725
- skeleton
726
- )
711
+ edge_feat = self.compute_edge_features(feat_i, feat_j, skeleton)
727
712
 
728
713
  # Decide based on component membership
729
714
  should_connect = False
@@ -731,20 +716,16 @@ class VesselDenoiser:
731
716
  if same_component:
732
717
  should_connect = True
733
718
  else:
734
- # Different components = merging separate segments
735
- # Require STRONG evidence
719
+ # Different components - require STRONG evidence
736
720
  if edge_feat['path_support'] > 0.5:
737
- # Strong skeleton path exists
738
721
  should_connect = True
739
- elif direction_dot > 0.3 and edge_feat['radius_ratio'] > 0.5: # Moderate radius evidence, some directional evidence
740
- # Well aligned forward, similar radius
741
- #if is_endpoint_j: # Both endpoints
722
+ elif direction_dot > 0.3 and edge_feat['radius_ratio'] > 0.5:
742
723
  score = self.score_connection(edge_feat)
743
- if score > self.score_thresh: # High threshold for merging
724
+ if score > self.score_thresh:
744
725
  should_connect = True
745
- elif edge_feat['radius_ratio'] > 0.7: #stronger radius evidence
726
+ elif edge_feat['radius_ratio'] > 0.7:
746
727
  score = self.score_connection(edge_feat)
747
- if score > self.score_thresh: # High threshold for merging
728
+ if score > self.score_thresh:
748
729
  should_connect = True
749
730
 
750
731
  # Special check: if j is internal node, require alignment
@@ -754,6 +735,8 @@ class VesselDenoiser:
754
735
 
755
736
  if should_connect:
756
737
  G.add_edge(endpoint_idx, j, **edge_feat)
738
+ # Update union-find structure immediately
739
+ ds.merge(endpoint_idx, j)
757
740
 
758
741
  return G
759
742
 
@@ -807,7 +790,7 @@ class VesselDenoiser:
807
790
  degrees = [G.degree(n) for n in component]
808
791
 
809
792
  # Component statistics
810
- size = len(component)
793
+ size = len(component) * self.kernel_spacing
811
794
  mean_radius = np.mean(radii)
812
795
  max_radius = np.max(radii)
813
796
  avg_degree = np.mean(degrees)
@@ -824,13 +807,16 @@ class VesselDenoiser:
824
807
 
825
808
  # Measure elongation (max distance / mean deviation from center)
826
809
  if len(positions) > 1:
827
- pairwise_dists = np.linalg.norm(
828
- positions[:, None] - positions[None, :], axis=2
829
- )
830
- max_dist = np.max(pairwise_dists)
831
810
  mean_pos = positions.mean(axis=0)
832
811
  deviations = np.linalg.norm(positions - mean_pos, axis=1)
833
812
  mean_deviation = np.mean(deviations)
813
+
814
+ # FAST APPROXIMATION: Use bounding box diagonal
815
+ # This is O(n) instead of O(n²) and uses minimal memory
816
+ bbox_min = positions.min(axis=0)
817
+ bbox_max = positions.max(axis=0)
818
+ max_dist = np.linalg.norm(bbox_max - bbox_min)
819
+
834
820
  elongation = max_dist / (mean_deviation + 1) if mean_deviation > 0 else max_dist
835
821
  else:
836
822
  elongation = 0
@@ -781,7 +781,7 @@ def get_surface_areas(labeled, xy_scale=1, z_scale=1):
781
781
 
782
782
  return result
783
783
 
784
- def break_and_label_skeleton(skeleton, peaks = 1, branch_removal = 0, comp_dil = 0, max_vol = 0, directory = None, return_skele = False, nodes = None, compute = True, xy_scale = 1, z_scale = 1):
784
+ def break_and_label_skeleton(skeleton, peaks = 1, branch_removal = 0, comp_dil = 0, max_vol = 0, directory = None, return_skele = False, nodes = None, compute = True, unify = False, xy_scale = 1, z_scale = 1):
785
785
  """Internal method to break open a skeleton at its branchpoints and label the remaining components, for an 8bit binary array"""
786
786
 
787
787
  if type(skeleton) == str:
@@ -829,11 +829,16 @@ def break_and_label_skeleton(skeleton, peaks = 1, branch_removal = 0, comp_dil =
829
829
  tifffile.imwrite(filename, labeled_image, photometric='minisblack')
830
830
  print(f"Broken skeleton saved to {filename}")
831
831
 
832
+ if not unify:
833
+ verts = None
834
+ else:
835
+ verts = invert_array(verts)
836
+
832
837
  if compute:
833
838
 
834
- return labeled_image, None, skeleton, None
839
+ return labeled_image, verts, skeleton, None
835
840
 
836
- return labeled_image, None, None, None
841
+ return labeled_image, verts, None, None
837
842
 
838
843
  def compute_optional_branchstats(verts, labeled_array, endpoints, xy_scale = 1, z_scale = 1):
839
844
 
@@ -2371,7 +2376,7 @@ def skeletonize(arrayimage, directory = None):
2371
2376
 
2372
2377
  return arrayimage
2373
2378
 
2374
- def label_branches(array, peaks = 0, branch_removal = 0, comp_dil = 0, max_vol = 0, down_factor = None, directory = None, nodes = None, bonus_array = None, GPU = True, arrayshape = None, compute = False, xy_scale = 1, z_scale = 1):
2379
+ def label_branches(array, peaks = 0, branch_removal = 0, comp_dil = 0, max_vol = 0, down_factor = None, directory = None, nodes = None, bonus_array = None, GPU = True, arrayshape = None, compute = False, unify = False, union_val = 10, xy_scale = 1, z_scale = 1):
2375
2380
  """
2376
2381
  Can be used to label branches a binary image. Labelled output will be saved to the active directory if none is specified. Note this works better on already thin filaments and may over-divide larger trunkish objects.
2377
2382
  :param array: (Mandatory, string or ndarray) - If string, a path to a tif file to label. Note that the ndarray alternative is for internal use mainly and will not save its output.
@@ -2401,13 +2406,20 @@ def label_branches(array, peaks = 0, branch_removal = 0, comp_dil = 0, max_vol =
2401
2406
 
2402
2407
  other_array = skeletonize(array)
2403
2408
 
2404
- other_array, verts, skele, endpoints = break_and_label_skeleton(other_array, peaks = peaks, branch_removal = branch_removal, comp_dil = comp_dil, max_vol = max_vol, nodes = nodes, compute = compute, xy_scale = xy_scale, z_scale = z_scale)
2409
+ other_array, verts, skele, endpoints = break_and_label_skeleton(other_array, peaks = peaks, branch_removal = branch_removal, comp_dil = comp_dil, max_vol = max_vol, nodes = nodes, compute = compute, unify = unify, xy_scale = xy_scale, z_scale = z_scale)
2405
2410
 
2406
2411
  else:
2407
- array, verts, skele, endpoints = break_and_label_skeleton(array, peaks = peaks, branch_removal = branch_removal, comp_dil = comp_dil, max_vol = max_vol, nodes = nodes, compute = compute, xy_scale = xy_scale, z_scale = z_scale)
2412
+ if down_factor is not None:
2413
+ bonus_array = downsample(bonus_array, down_factor)
2414
+ array, verts, skele, endpoints = break_and_label_skeleton(array, peaks = peaks, branch_removal = branch_removal, comp_dil = comp_dil, max_vol = max_vol, nodes = nodes, compute = compute, unify = unify, xy_scale = xy_scale, z_scale = z_scale)
2415
+
2416
+ if unify is True and nodes is not None:
2417
+ from . import branch_stitcher
2418
+ verts = dilate_3D_old(verts, 3, 3, 3,)
2419
+ verts, _ = label_objects(verts)
2420
+ array = branch_stitcher.trace(bonus_array, array, verts, score_thresh = union_val)
2421
+ verts = None
2408
2422
 
2409
- if nodes is not None and down_factor is not None:
2410
- array = upsample_with_padding(array, down_factor, arrayshape)
2411
2423
 
2412
2424
  if nodes is None:
2413
2425
 
@@ -2441,6 +2453,9 @@ def label_branches(array, peaks = 0, branch_removal = 0, comp_dil = 0, max_vol =
2441
2453
  else:
2442
2454
  print("Branches labelled")
2443
2455
 
2456
+ if nodes is not None and down_factor is not None:
2457
+ array = upsample_with_padding(array, down_factor, arrayshape)
2458
+
2444
2459
 
2445
2460
  return array, verts, skele, endpoints
2446
2461
 
@@ -3349,8 +3364,11 @@ class Network_3D:
3349
3364
  try:
3350
3365
  tifffile.imwrite(f"{filename}", self._nodes, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3351
3366
  except:
3352
- self._nodes = binarize(self._nodes)
3353
- tifffile.imwrite(f"{filename}", self._nodes, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3367
+ try:
3368
+ tifffile.imwrite(f"{filename}", self._nodes)
3369
+ except:
3370
+ self._nodes = binarize(self._nodes)
3371
+ tifffile.imwrite(f"{filename}", self._nodes, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3354
3372
  else:
3355
3373
  tifffile.imwrite(f"{filename}", self._nodes)
3356
3374
  print(f"Nodes saved to {filename}")
@@ -3362,8 +3380,11 @@ class Network_3D:
3362
3380
  try:
3363
3381
  tifffile.imwrite(f"{directory}/{filename}", self._nodes, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3364
3382
  except:
3365
- self._nodes = binarize(self._nodes)
3366
- tifffile.imwrite(f"{directory}/{filename}", self._nodes, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3383
+ try:
3384
+ tifffile.imwrite(f"{directory}/{filename}", self._nodes)
3385
+ except:
3386
+ self._nodes = binarize(self._nodes)
3387
+ tifffile.imwrite(f"{directory}/{filename}", self._nodes, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3367
3388
  else:
3368
3389
  tifffile.imwrite(f"{directory}/{filename}", self._nodes)
3369
3390
  print(f"Nodes saved to {directory}/{filename}")
@@ -3398,16 +3419,22 @@ class Network_3D:
3398
3419
  try:
3399
3420
  tifffile.imwrite(f"{filename}", self._edges, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3400
3421
  except:
3401
- self._edges = binarize(self._edges)
3402
- tifffile.imwrite(f"{filename}", self._edges, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3422
+ try:
3423
+ tifffile.imwrite(f"{filename}", self._edges)
3424
+ except:
3425
+ self._edges = binarize(self._edges)
3426
+ tifffile.imwrite(f"{filename}", self._edges, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3403
3427
  print(f"Edges saved to {filename}")
3404
3428
 
3405
3429
  if directory is not None:
3406
3430
  try:
3407
3431
  tifffile.imwrite(f"{directory}/{filename}", self._edges, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3408
3432
  except:
3409
- self._edges = binarize(self._edges)
3410
- tifffile.imwrite(f"{directory}/{filename}", self._edges, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3433
+ try:
3434
+ tifffile.imwrite(f"{directory}/{filename}", self._edges)
3435
+ except:
3436
+ self._edges = binarize(self._edges)
3437
+ tifffile.imwrite(f"{directory}/{filename}", self._edges, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3411
3438
  print(f"Edges saved to {directory}/{filename}")
3412
3439
 
3413
3440
  if self._edges is None:
@@ -3583,8 +3610,11 @@ class Network_3D:
3583
3610
  try:
3584
3611
  tifffile.imwrite(f"{filename}", self._network_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3585
3612
  except:
3586
- self._network_overlay = binarize(self._network_overlay)
3587
- tifffile.imwrite(f"{filename}", self._network_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3613
+ try:
3614
+ tifffile.imwrite(f"{filename}", self._network_overlay)
3615
+ except:
3616
+ self._network_overlay = binarize(self._network_overlay)
3617
+ tifffile.imwrite(f"{filename}", self._network_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3588
3618
  else:
3589
3619
  tifffile.imwrite(f"{filename}", self._network_overlay)
3590
3620
  print(f"Network overlay saved to {filename}")
@@ -3594,8 +3624,11 @@ class Network_3D:
3594
3624
  try:
3595
3625
  tifffile.imwrite(f"{directory}/{filename}", self._network_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3596
3626
  except:
3597
- self._network_overlay = binarize(self._network_overlay)
3598
- tifffile.imwrite(f"{directory}/{filename}", self._network_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3627
+ try:
3628
+ tifffile.imwrite(f"{directory}/{filename}", self._network_overlay)
3629
+ except:
3630
+ self._network_overlay = binarize(self._network_overlay)
3631
+ tifffile.imwrite(f"{directory}/{filename}", self._network_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3599
3632
  else:
3600
3633
  tifffile.imwrite(f"{directory}/{filename}", self._network_overlay)
3601
3634
  print(f"Network overlay saved to {directory}/{filename}")
@@ -3622,8 +3655,11 @@ class Network_3D:
3622
3655
  try:
3623
3656
  tifffile.imwrite(f"{filename}", self._id_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3624
3657
  except:
3625
- self._id_overlay = binarize(self._id_overlay)
3626
- tifffile.imwrite(f"{filename}", self._id_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3658
+ try:
3659
+ tifffile.imwrite(f"{filename}", self._id_overlay)
3660
+ except:
3661
+ self._id_overlay = binarize(self._id_overlay)
3662
+ tifffile.imwrite(f"{filename}", self._id_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3627
3663
  else:
3628
3664
  tifffile.imwrite(f"{filename}", self._id_overlay, imagej=True)
3629
3665
  print(f"Network overlay saved to {filename}")
@@ -3633,8 +3669,11 @@ class Network_3D:
3633
3669
  try:
3634
3670
  tifffile.imwrite(f"{directory}/{filename}", self._id_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3635
3671
  except:
3636
- self._id_overlay = binarize(self._id_overlay)
3637
- tifffile.imwrite(f"{directory}/{filename}", self._id_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3672
+ try:
3673
+ tifffile.imwrite(f"{directory}/{filename}", self._id_overlay)
3674
+ except:
3675
+ self._id_overlay = binarize(self._id_overlay)
3676
+ tifffile.imwrite(f"{directory}/{filename}", self._id_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3638
3677
  else:
3639
3678
  tifffile.imwrite(f"{directory}/{filename}", self._id_overlay)
3640
3679
  print(f"ID overlay saved to {directory}/{filename}")
@@ -4887,7 +4887,7 @@ class ImageViewerWindow(QMainWindow):
4887
4887
  gennodes_action.triggered.connect(self.show_gennodes_dialog)
4888
4888
  branch_action = generate_menu.addAction("Label Branches")
4889
4889
  branch_action.triggered.connect(lambda: self.show_branch_dialog())
4890
- filament_action = generate_menu.addAction("Trace Filaments")
4890
+ filament_action = generate_menu.addAction("Trace Filaments (For Segmented Data)")
4891
4891
  filament_action.triggered.connect(self.show_filament_dialog)
4892
4892
  genvor_action = generate_menu.addAction("Generate Voronoi Diagram - goes in Overlay2")
4893
4893
  genvor_action.triggered.connect(self.voronoi)
@@ -6358,11 +6358,13 @@ class ImageViewerWindow(QMainWindow):
6358
6358
  if 'YResolution' in tags:
6359
6359
  y_res = tags['YResolution'].value
6360
6360
  y_scale = y_res[1] / y_res[0] if isinstance(y_res, tuple) else 1.0 / y_res
6361
-
6362
- if x_scale is None:
6361
+
6362
+ if x_scale == None:
6363
6363
  x_scale = 1
6364
- if z_scale is None:
6364
+ if z_scale == None:
6365
6365
  z_scale = 1
6366
+ if x_scale == 1 and z_scale == 1:
6367
+ return
6366
6368
 
6367
6369
  return x_scale, z_scale
6368
6370
 
@@ -7337,6 +7339,10 @@ class ImageViewerWindow(QMainWindow):
7337
7339
 
7338
7340
  try:
7339
7341
 
7342
+ if self.shape[0] == 1:
7343
+ print("The image is 2D and therefore does not have surface areas")
7344
+ return
7345
+
7340
7346
  surface_areas = n3d.get_surface_areas(self.channel_data[self.active_channel], xy_scale = my_network.xy_scale, z_scale = my_network.z_scale)
7341
7347
 
7342
7348
  if self.active_channel == 0:
@@ -7357,6 +7363,10 @@ class ImageViewerWindow(QMainWindow):
7357
7363
 
7358
7364
  try:
7359
7365
 
7366
+ if self.shape[0] == 1:
7367
+ print("The image is 2D and therefore does not have sphericities")
7368
+ return
7369
+
7360
7370
  self.volumes()
7361
7371
  self.handle_sa()
7362
7372
  volumes = self.volume_dict[self.active_channel]
@@ -12627,8 +12637,7 @@ class MachineWindow(QMainWindow):
12627
12637
  self.num_chunks = 0
12628
12638
  self.parent().update_display()
12629
12639
  except:
12630
- import traceback
12631
- traceback.print_exc()
12640
+
12632
12641
  pass
12633
12642
 
12634
12643
  except:
@@ -13216,22 +13225,28 @@ class ThresholdWindow(QMainWindow):
13216
13225
  data = self.parent().channel_data[self.parent().active_channel]
13217
13226
  nonzero_data = data[data != 0]
13218
13227
 
13219
- if nonzero_data.size > 578009537:
13220
- # For large arrays, use numpy histogram directly
13221
- # Store min/max separately if needed elsewhere
13222
- self.data_min = np.min(nonzero_data)
13223
- self.data_max = np.max(nonzero_data)
13224
- self.histo_list = [self.data_min, self.data_max]
13225
- nonzero_data = n3d.downsample(nonzero_data, 5)
13226
- print('start')
13227
- counts, bin_edges = np.histogram(nonzero_data, bins= min(int(np.sqrt(nonzero_data.size)), 500), density=False)
13228
- print('start')
13228
+ MAX_SAMPLES_FOR_HISTOGRAM = 10_000_000 # Downsample data above this size
13229
+ MAX_HISTOGRAM_BINS = 512 # Maximum bins for smooth matplotlib interaction
13230
+ MIN_HISTOGRAM_BINS = 128 # Minimum bins for decent resolution
13231
+
13232
+ # Always compute min/max first (before any downsampling)
13233
+ self.data_min = np.min(nonzero_data)
13234
+ self.data_max = np.max(nonzero_data)
13235
+ self.histo_list = [self.data_min, self.data_max]
13236
+
13237
+ # Downsample data if too large
13238
+ if nonzero_data.size > MAX_SAMPLES_FOR_HISTOGRAM:
13239
+ downsample_factor = int(np.ceil(nonzero_data.size / MAX_SAMPLES_FOR_HISTOGRAM))
13240
+ nonzero_data_sampled = n3d.downsample(nonzero_data, downsample_factor)
13229
13241
  else:
13230
- # For smaller arrays, can still use histogram method for consistency
13231
- counts, bin_edges = np.histogram(nonzero_data, bins='auto', density=False)
13232
- self.data_min = np.min(nonzero_data)
13233
- self.data_max = np.max(nonzero_data)
13234
- self.histo_list = [self.data_min, self.data_max]
13242
+ nonzero_data_sampled = nonzero_data
13243
+
13244
+ # Calculate optimal bin count (capped for matplotlib performance)
13245
+ # Using Sturges' rule but capped to reasonable limits
13246
+ n_bins = int(np.ceil(np.log2(nonzero_data_sampled.size)) + 1)
13247
+ n_bins = np.clip(n_bins, MIN_HISTOGRAM_BINS, MAX_HISTOGRAM_BINS)
13248
+
13249
+ counts, bin_edges = np.histogram(nonzero_data_sampled, bins=n_bins, density=False)
13235
13250
 
13236
13251
  self.bounds = True
13237
13252
  self.parent().bounds = True
@@ -13936,10 +13951,10 @@ class FilamentDialog(QDialog):
13936
13951
  # Speedup Group
13937
13952
  speedup_group = QGroupBox("Speedup")
13938
13953
  speedup_layout = QFormLayout()
13939
- self.kernel_spacing = QLineEdit("1")
13954
+ self.kernel_spacing = QLineEdit("3")
13940
13955
  speedup_layout.addRow("Kernel Spacing (1 is most accurate, can increase to speed up):", self.kernel_spacing)
13941
13956
  self.downsample_factor = QLineEdit("1")
13942
- speedup_layout.addRow("Temporary Downsample Factor (Note that the above distances are not adjusted for this):", self.downsample_factor)
13957
+ speedup_layout.addRow("Temporary Downsample Factor (Note that the below distances are not adjusted for this):", self.downsample_factor)
13943
13958
  speedup_group.setLayout(speedup_layout)
13944
13959
  main_layout.addWidget(speedup_group)
13945
13960
 
@@ -13959,7 +13974,7 @@ class FilamentDialog(QDialog):
13959
13974
  artifact_group = QGroupBox("Artifact Removal")
13960
13975
  artifact_layout = QFormLayout()
13961
13976
  self.min_component = QLineEdit("20")
13962
- artifact_layout.addRow("Minimum Component Size to Include (Filters out noise before connecting, calculate unscaled volumes if unsure):", self.min_component)
13977
+ artifact_layout.addRow("Minimum Component Size to Include:", self.min_component)
13963
13978
  self.blob_sphericity = QLineEdit("1.0")
13964
13979
  artifact_layout.addRow("Spherical Objects in the Output can Represent Noise. Enter a val 0 < x < 1 to consider removing spheroids. Larger vals are more spherical. 1.0 = a perfect sphere. 0.3 is usually the lower bound of a spheroid:", self.blob_sphericity)
13965
13980
  self.blob_volume = QLineEdit("200")
@@ -13968,7 +13983,8 @@ class FilamentDialog(QDialog):
13968
13983
  artifact_layout.addRow("Remove Branch Spines Below this Length?", self.spine_removal)
13969
13984
  artifact_group.setLayout(artifact_layout)
13970
13985
  main_layout.addWidget(artifact_group)
13971
-
13986
+
13987
+
13972
13988
  # Run Button
13973
13989
  run_button = QPushButton("Run Filament Tracer (Output Goes in Overlay 2)")
13974
13990
  run_button.clicked.connect(self.run)
@@ -13981,6 +13997,7 @@ class FilamentDialog(QDialog):
13981
13997
 
13982
13998
  from . import filaments
13983
13999
 
14000
+
13984
14001
  kernel_spacing = int(self.kernel_spacing.text()) if self.kernel_spacing.text().strip() else 1
13985
14002
  max_distance = float(self.max_distance.text()) if self.max_distance.text().strip() else 20
13986
14003
  min_component = int(self.min_component.text()) if self.min_component.text().strip() else 20
@@ -13995,7 +14012,6 @@ class FilamentDialog(QDialog):
13995
14012
  if downsample_factor and downsample_factor > 1:
13996
14013
  data = n3d.downsample(data, downsample_factor)
13997
14014
 
13998
-
13999
14015
  result = filaments.trace(data, kernel_spacing, max_distance, min_component, gap_tolerance, blob_sphericity, blob_volume, spine_removal, score_threshold)
14000
14016
 
14001
14017
  if downsample_factor and downsample_factor > 1:
@@ -14008,9 +14024,49 @@ class FilamentDialog(QDialog):
14008
14024
  self.accept()
14009
14025
 
14010
14026
  except Exception as e:
14011
-
14027
+ import traceback
14028
+ print(traceback.format_exc())
14012
14029
  print(f"Error: {e}")
14013
14030
 
14031
+ def wait_for_threshold_processing(self):
14032
+ """
14033
+ Opens ThresholdWindow and waits for user to process the image.
14034
+ Returns True if completed, False if cancelled.
14035
+ The thresholded image will be available in the main window after completion.
14036
+ """
14037
+ # Create event loop to wait for user
14038
+ loop = QEventLoop()
14039
+ result = {'completed': False}
14040
+
14041
+ # Create the threshold window
14042
+ thresh_window = ThresholdWindow(self.parent(), 0)
14043
+
14044
+
14045
+ # Connect signals
14046
+ def on_processing_complete():
14047
+ result['completed'] = True
14048
+ loop.quit()
14049
+
14050
+ def on_processing_cancelled():
14051
+ result['completed'] = False
14052
+ loop.quit()
14053
+
14054
+ thresh_window.processing_complete.connect(on_processing_complete)
14055
+ thresh_window.processing_cancelled.connect(on_processing_cancelled)
14056
+
14057
+ # Show window and wait
14058
+ thresh_window.show()
14059
+ thresh_window.raise_()
14060
+ thresh_window.activateWindow()
14061
+
14062
+ # Block until user clicks "Apply Threshold & Continue" or "Cancel"
14063
+ loop.exec()
14064
+
14065
+ # Clean up
14066
+ thresh_window.deleteLater()
14067
+
14068
+ return result['completed']
14069
+
14014
14070
 
14015
14071
 
14016
14072
  class MaskDialog(QDialog):
@@ -15139,30 +15195,40 @@ class BranchDialog(QDialog):
15139
15195
  self.fix = QPushButton("Auto-Correct 1")
15140
15196
  self.fix.setCheckable(True)
15141
15197
  self.fix.setChecked(False)
15142
- correction_layout.addWidget(QLabel("Auto-Correct Branches by Collapsing Busy Neighbors: "), 0, 0)
15143
- correction_layout.addWidget(self.fix, 0, 1)
15198
+ #correction_layout.addWidget(QLabel("Auto-Correct Branches by Collapsing Busy Neighbors: "), 0, 0)
15199
+ #correction_layout.addWidget(self.fix, 0, 1)
15144
15200
 
15145
15201
  # Fix value
15146
15202
  self.fix_val = QLineEdit('4')
15147
- correction_layout.addWidget(QLabel("Avg Degree of Nearby Branch Communities to Merge (4-6 recommended):"), 1, 0)
15148
- correction_layout.addWidget(self.fix_val, 1, 1)
15203
+ #correction_layout.addWidget(QLabel("(For Auto-Correct 1) Avg Degree of Nearby Branch Communities to Merge (4-6 recommended):"), 1, 0)
15204
+ #correction_layout.addWidget(self.fix_val, 1, 1)
15149
15205
 
15150
15206
  # Seed
15151
15207
  self.seed = QLineEdit('')
15152
- correction_layout.addWidget(QLabel("Random seed for auto correction (int - optional):"), 2, 0)
15153
- correction_layout.addWidget(self.seed, 2, 1)
15208
+ #correction_layout.addWidget(QLabel("Random seed for auto correction (int - optional):"), 2, 0)
15209
+ #correction_layout.addWidget(self.seed, 2, 1)
15154
15210
 
15155
- self.fix2 = QPushButton("Auto-Correct 2")
15211
+ self.fix2 = QPushButton("Auto-Correct Internal Branches")
15156
15212
  self.fix2.setCheckable(True)
15157
15213
  self.fix2.setChecked(True)
15158
15214
  correction_layout.addWidget(QLabel("Auto-Correct Branches by Collapsing Internal Labels: "), 3, 0)
15159
15215
  correction_layout.addWidget(self.fix2, 3, 1)
15160
15216
 
15161
- self.fix3 = QPushButton("Correct Nontouching Branches?")
15217
+ self.fix3 = QPushButton("Auto-Correct Nontouching Branches")
15162
15218
  self.fix3.setCheckable(True)
15163
15219
  self.fix3.setChecked(True)
15164
- correction_layout.addWidget(QLabel("Correct Nontouching Branches?: "), 4, 0)
15220
+ correction_layout.addWidget(QLabel("Auto-Correct Nontouching Branches?: "), 4, 0)
15165
15221
  correction_layout.addWidget(self.fix3, 4, 1)
15222
+
15223
+ self.fix4 = QPushButton("Auto-Attempt to Reunify Main Branches?")
15224
+ self.fix4.setCheckable(True)
15225
+ self.fix4.setChecked(False)
15226
+ correction_layout.addWidget(QLabel("Reunify Main Branches: "), 5, 0)
15227
+ correction_layout.addWidget(self.fix4, 5, 1)
15228
+
15229
+ self.fix4_val = QLineEdit('10')
15230
+ correction_layout.addWidget(QLabel("(For Reunify) Minimum Score to Merge? (Lower vals = More mergers, can be negative):"), 6, 0)
15231
+ correction_layout.addWidget(self.fix4_val, 6, 1)
15166
15232
 
15167
15233
  correction_group.setLayout(correction_layout)
15168
15234
  main_layout.addWidget(correction_group)
@@ -15241,7 +15307,9 @@ class BranchDialog(QDialog):
15241
15307
  fix = self.fix.isChecked()
15242
15308
  fix2 = self.fix2.isChecked()
15243
15309
  fix3 = self.fix3.isChecked()
15310
+ fix4 = self.fix4.isChecked()
15244
15311
  fix_val = float(self.fix_val.text()) if self.fix_val.text() else None
15312
+ fix4_val = float(self.fix4_val.text()) if self.fix4_val.text() else 10
15245
15313
  seed = int(self.seed.text()) if self.seed.text() else None
15246
15314
  compute = self.compute.isChecked()
15247
15315
 
@@ -15260,7 +15328,12 @@ class BranchDialog(QDialog):
15260
15328
 
15261
15329
  if my_network.edges is not None and my_network.nodes is not None and my_network.id_overlay is not None:
15262
15330
 
15263
- output, verts, skeleton, endpoints = n3d.label_branches(my_network.edges, nodes = my_network.nodes, bonus_array = original_array, GPU = GPU, down_factor = down_factor, arrayshape = original_shape, compute = compute, xy_scale = my_network.xy_scale, z_scale = my_network.z_scale)
15331
+ if fix4:
15332
+ unify = True
15333
+ else:
15334
+ unify = False
15335
+
15336
+ output, verts, skeleton, endpoints = n3d.label_branches(my_network.edges, nodes = my_network.nodes, bonus_array = original_array, GPU = GPU, down_factor = down_factor, arrayshape = original_shape, compute = compute, unify = unify, union_val = fix4_val, xy_scale = my_network.xy_scale, z_scale = my_network.z_scale)
15264
15337
 
15265
15338
  if fix2:
15266
15339
 
@@ -1765,6 +1765,7 @@ def setup_branch_tutorial(window):
1765
1765
  pre_action=open_dialog
1766
1766
  )
1767
1767
 
1768
+ """
1768
1769
  tutorial.add_step(
1769
1770
  MenuHelper.create_widget_getter(tutorial, 'branch_dialog', 'fix'),
1770
1771
  "This first auto-correction option is designed if you feel like the branch labels are generally too busy. Selecting this will have the program attempt to collapse overly-dense regions of branches into a single label. Note that this behavior is somewhat tricky to predict so I generally don't use it but feel free to give it a shot and see how it looks.",
@@ -1792,6 +1793,8 @@ def setup_branch_tutorial(window):
1792
1793
  action=MenuHelper.create_widget_interaction(tutorial, 'branch_dialog', 'seed', 'setText("")')
1793
1794
  )
1794
1795
 
1796
+ """
1797
+
1795
1798
  tutorial.add_step(
1796
1799
  MenuHelper.create_widget_getter(tutorial, 'branch_dialog', 'fix2'),
1797
1800
  "The second auto-correction option will automatically merge any internal labels that arise with their outer-neighbors. This is something that can occasionally happen with fat, trunk-like branches that are tricky to algorithmically decipher. I have found that this merge handles these issues quite well, so this option is enabled by default.",
@@ -1802,12 +1805,30 @@ def setup_branch_tutorial(window):
1802
1805
 
1803
1806
  tutorial.add_step(
1804
1807
  MenuHelper.create_widget_getter(tutorial, 'branch_dialog', 'fix3'),
1805
- "This final auto-correction step will automatically correct any branches that aren't contiguous in space. Rarely (Depending on the segmentation, really) a branch can initially be labeled non-contiguously, which is usually not correct. This is because the 'meat' of any branch is at first labeled based on which internal filament it's closest to. So if you have a very wide branch it may rarely aquire labels of nearby smaller branches across gaps. Enabling this will split those labels into seperate regions as to not confound the connectivity graph. The largest component is considered the 'correct one' and keeps its label, while smaller components inherit the label of the largest shared border of a 'real' branch they are bordering. It is enabled here by default to mitigate any potential errors, although note this does not apply to the branchpoint networks since they don't actually utilize the branches themselves.",
1808
+ "This auto-correction step will automatically correct any branches that aren't contiguous in space. Rarely (Depending on the segmentation, really) a branch can initially be labeled non-contiguously, which is usually not correct. This is because the 'meat' of any branch is at first labeled based on which internal filament it's closest to. So if you have a very wide branch it may rarely aquire labels of nearby smaller branches across gaps. Enabling this will split those labels into seperate regions as to not confound the connectivity graph. The largest component is considered the 'correct one' and keeps its label, while smaller components inherit the label of the largest shared border of a 'real' branch they are bordering. It is enabled here by default to mitigate any potential errors, although note this does not apply to the branchpoint networks since they don't actually utilize the branches themselves.",
1806
1809
  highlight_type=None,
1807
1810
  message_position="beside",
1808
1811
  pre_action=MenuHelper.create_widget_interaction(tutorial, 'branch_dialog', 'fix3', 'click()')
1809
1812
  )
1810
1813
 
1814
+ tutorial.add_step(
1815
+ MenuHelper.create_widget_getter(tutorial, 'branch_dialog', 'fix4'),
1816
+ "This final auto-correction step will try to automatically merge any similarly sized branches moving in the same direction, instead of just letting a larger branch with many sub-branches get chopped up. It is off by default because of its less predictable behavior, although its good if you want your branches to be more continuous. Just note each of these fixes does add extra processing time.",
1817
+ highlight_type=None,
1818
+ message_position="beside",
1819
+ pre_action=MenuHelper.create_widget_interaction(tutorial, 'branch_dialog', 'fix4', 'click()'),
1820
+ action=MenuHelper.create_widget_interaction(tutorial, 'branch_dialog', 'fix4', 'toggle()')
1821
+ )
1822
+
1823
+ tutorial.add_step(
1824
+ MenuHelper.create_widget_getter(tutorial, 'branch_dialog', 'fix4_val'),
1825
+ "This threshold values controls how likely a junction is to merge any pair of its nearby branches. Regardless of what you enter here, only two branches at a time can merge at a junction. Values between 20-40 are more meaningful, while those lower tend to merge everything and those higher usually emrge nothing.",
1826
+ highlight_type=None,
1827
+ message_position="beside",
1828
+ pre_action=MenuHelper.create_widget_interaction(tutorial, 'branch_dialog', 'fix4_val', 'setText("FLOAT!")'),
1829
+ action=MenuHelper.create_widget_interaction(tutorial, 'branch_dialog', 'fix4_val', 'setText("")')
1830
+ )
1831
+
1811
1832
  tutorial.add_step(
1812
1833
  MenuHelper.create_widget_getter(tutorial, 'branch_dialog', 'down_factor'),
1813
1834
  "This integer value can be used to temporarily downsample the image while creating branches. Aside from speeding up the process, this may actually improve branch-labeling behavior with thick branches but will lose labeling for smaller branches (instead merging them with nearby thicker branches they arise from). It is disabled by default. Larger values will downsample more aggressively.",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nettracer3d
3
- Version: 1.1.9
3
+ Version: 1.2.3
4
4
  Summary: Scripts for intializing and analyzing networks from segmentations of three dimensional images.
5
5
  Author-email: Liam McLaughlin <liamm@wustl.edu>
6
6
  Project-URL: Documentation, https://nettracer3d.readthedocs.io/en/latest/
@@ -110,12 +110,8 @@ McLaughlin, L., Zhang, B., Sharma, S. et al. Three dimensional multiscalar neuro
110
110
 
111
111
  NetTracer3D was developed by Liam McLaughlin while working under Dr. Sanjay Jain at Washington University School of Medicine.
112
112
 
113
- -- Version 1.1.9 Updates --
113
+ -- Version 1.2.3 Updates --
114
114
 
115
- * Fixed saving of Boolean True/False arrays.
116
- * Fixed branch functions not working correctly when a temporary downsample was being applied
117
- * Added the 'filaments tracer' which can be used to improve vessel based segmentations
118
- * Removed options for 'cubic' downsampling during processing as this option actually made outputs worse.
119
- * Rearranged the 'Analyze -> Stats' menu
115
+ * Tweaked the 'branch unification params' - now hard rejects any sharp forks in the branches so branches will mostly be straight.
120
116
 
121
117
 
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/nettracer3d/__init__.py
5
+ src/nettracer3d/branch_stitcher.py
5
6
  src/nettracer3d/cellpose_manager.py
6
7
  src/nettracer3d/community_extractor.py
7
8
  src/nettracer3d/excelotron.py
File without changes
File without changes