nettracer3d 0.8.9__py3-none-any.whl → 0.9.1__py3-none-any.whl

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.

Potentially problematic release.


This version of nettracer3d might be problematic. Click here for more details.

nettracer3d/nettracer.py CHANGED
@@ -500,7 +500,7 @@ def _upsample_3d_array(data, factor, original_shape):
500
500
  else:
501
501
  trimmed_rows = trimmed_planes[:, sub_before[1]:-sub_after[1], :]
502
502
 
503
- # Remove columns from the beginning and end
503
+ # Remove columns from the beginning and end
504
504
  if sub_dims[2] == 0:
505
505
  trimmed_array = trimmed_rows
506
506
  else:
@@ -508,6 +508,101 @@ def _upsample_3d_array(data, factor, original_shape):
508
508
 
509
509
  return trimmed_array
510
510
 
511
+
512
+ def remove_branches_new(skeleton, length):
513
+ """Used to compensate for overly-branched skeletons resulting from the scipy 3d skeletonization algorithm"""
514
+ def find_coordinate_difference(arr):
515
+ try:
516
+ arr[1,1,1] = 0
517
+ # Find the indices of non-zero elements
518
+ indices = np.array(np.nonzero(arr)).T
519
+
520
+ # Calculate the difference
521
+ diff = np.array([1,1,1]) - indices[0]
522
+
523
+ return diff
524
+ except:
525
+ return None
526
+
527
+ skeleton = np.pad(skeleton, pad_width=1, mode='constant', constant_values=0) #Add black planes over the 3d space to avoid index errors
528
+ image_copy = np.copy(skeleton)
529
+
530
+ # Find all endpoints ONCE at the beginning
531
+ nonzero_coords = np.transpose(np.nonzero(image_copy))
532
+ endpoints = []
533
+ nubs = []
534
+
535
+ for x, y, z in nonzero_coords:
536
+ mini = image_copy[x-1:x+2, y-1:y+2, z-1:z+2]
537
+ nearby_sum = np.sum(mini)
538
+ threshold = 2 * image_copy[x, y, z]
539
+
540
+ if nearby_sum <= threshold:
541
+ endpoints.append((x, y, z))
542
+
543
+ x, y, z = endpoints[0]
544
+ original_val = image_copy[x, y, z]
545
+
546
+ # Process each endpoint individually for nub assessment
547
+ for start_x, start_y, start_z in endpoints:
548
+
549
+ # Trace the branch from this endpoint, removing points as we go
550
+ branch_coords = []
551
+ current_coord = (start_x, start_y, start_z)
552
+ nub_reached = False
553
+
554
+ for step in range(length):
555
+ x, y, z = current_coord
556
+
557
+ # Store original value and coordinates
558
+ branch_coords.append((x, y, z))
559
+
560
+ # Remove this point temporarily
561
+ image_copy[x, y, z] = 0
562
+
563
+ # If we've reached the maximum length without hitting a nub, break
564
+ if step == length - 1:
565
+ break
566
+
567
+ # Find next coordinate in the branch
568
+ mini = image_copy[x-1:x+2, y-1:y+2, z-1:z+2]
569
+ dif = find_coordinate_difference(mini.copy())
570
+ if dif is None:
571
+ break
572
+
573
+ next_coord = (x - dif[0], y - dif[1], z - dif[2])
574
+
575
+ # Check if next coordinate is valid and exists
576
+ nx, ny, nz = next_coord
577
+
578
+ # Check if next point is a nub (has more neighbors than expected)
579
+ next_mini = image_copy[nx-1:nx+2, ny-1:ny+2, nz-1:nz+2]
580
+ next_nearby_sum = np.sum(next_mini)
581
+ next_threshold = 2 * image_copy[nx, ny, nz]
582
+
583
+ if next_nearby_sum > next_threshold:
584
+ nub_reached = True
585
+ nubs.append(next_coord)
586
+ nubs.append(current_coord) # Note, if we don't add the current coord here (and restore it below), the behavior of this method can be changed to trim branches beneath previous branches, which could be neat but its somewhat unpredictable so I opted out of it.
587
+ image_copy[x, y, z] = original_val
588
+ #image_copy[nx, ny, nz] = 0
589
+ break
590
+
591
+ current_coord = next_coord
592
+
593
+ # If no nub was reached, restore all the points we removed
594
+ if not nub_reached:
595
+ for i, (bx, by, bz) in enumerate(branch_coords):
596
+ image_copy[bx, by, bz] = original_val
597
+ # If nub was reached, points stay removed (branch is eliminated)
598
+
599
+ for item in nubs: #The nubs are endpoints of length = 1. They appear a bit different in the array so we just note when one is created and remove them all at the end in a batch.
600
+ image_copy[item[0], item[1], item[2]] = 0 # Removing the nub itself leaves a hole in the skeleton but for branchpoint detection that doesn't matter, which is why it behaves this way. To fill the hole, one option is to dilate once then erode/skeletonize again, but we want to avoid making anything that looks like local branching so I didn't bother.
601
+
602
+ # Remove padding and return
603
+ image_copy = (image_copy[1:-1, 1:-1, 1:-1]).astype(np.uint8)
604
+ return image_copy
605
+
511
606
  def remove_branches(skeleton, length):
512
607
  """Used to compensate for overly-branched skeletons resulting from the scipy 3d skeletonization algorithm"""
513
608
 
@@ -532,6 +627,7 @@ def remove_branches(skeleton, length):
532
627
  x, y, z = nonzero_coords[0]
533
628
  threshold = 2 * skeleton[x, y, z]
534
629
  nubs = []
630
+
535
631
 
536
632
  for b in range(length):
537
633
 
@@ -628,6 +724,8 @@ def break_and_label_skeleton(skeleton, peaks = 1, branch_removal = 0, comp_dil =
628
724
  else:
629
725
  broken_skele = None
630
726
 
727
+ #old_skeleton = copy.deepcopy(skeleton) # The skeleton might get modified in label_vertices so we can make a preserved copy of it to use later
728
+
631
729
  if nodes is None:
632
730
 
633
731
  verts = label_vertices(skeleton, peaks = peaks, branch_removal = branch_removal, comp_dil = comp_dil, max_vol = max_vol, return_skele = return_skele)
@@ -637,6 +735,8 @@ def break_and_label_skeleton(skeleton, peaks = 1, branch_removal = 0, comp_dil =
637
735
 
638
736
  verts = invert_array(verts)
639
737
 
738
+ #skeleton = old_skeleton
739
+
640
740
  image_copy = skeleton * verts
641
741
 
642
742
 
@@ -1031,10 +1131,93 @@ def remove_trunk(edges, num_iterations=1):
1031
1131
 
1032
1132
  return edges
1033
1133
 
1034
- def hash_inners(search_region, inner_edges, GPU = True):
1134
+ def get_all_label_coords(labeled_array, background=0):
1135
+ """
1136
+ Get coordinates for all labels using single pass method.
1137
+
1138
+ Parameters:
1139
+ -----------
1140
+ labeled_array : numpy.ndarray
1141
+ Labeled array with integer labels
1142
+ background : int, optional
1143
+ Background label to exclude (default: 0)
1144
+
1145
+ Returns:
1146
+ --------
1147
+ dict : {label: coordinates_array}
1148
+ Dictionary mapping each label to its coordinate array
1149
+ """
1150
+ coords_dict = {}
1151
+
1152
+ # Get all non-background coordinates at once
1153
+ all_coords = np.argwhere(labeled_array != background)
1154
+
1155
+ if len(all_coords) == 0:
1156
+ return coords_dict
1157
+
1158
+ # Get the label values at those coordinates
1159
+ labels_at_coords = labeled_array[tuple(all_coords.T)]
1160
+
1161
+ # Group by label
1162
+ unique_labels = np.unique(labels_at_coords)
1163
+ for label in unique_labels:
1164
+ mask = labels_at_coords == label
1165
+ coords_dict[label] = all_coords[mask]
1166
+
1167
+ return coords_dict
1168
+
1169
+ def approx_boundaries(array, iden_set = None, node_identities = None, keep_labels = False):
1170
+
1171
+ """Hollows out an array, can do it for only a set number of identities. Returns coords as dict if labeled or as 1d numpy array if binary is desired"""
1172
+
1173
+ if node_identities is not None:
1174
+
1175
+ nodes = []
1176
+
1177
+ for node in node_identities:
1178
+
1179
+ if node_identities[node] in iden_set: #Filter out only idens we need
1180
+ nodes.append(node)
1181
+
1182
+ mask = np.isin(array, nodes)
1183
+
1184
+ if keep_labels:
1185
+
1186
+ array = array * mask
1187
+ else:
1188
+ array = mask
1189
+ del mask
1190
+
1191
+ from skimage.segmentation import find_boundaries
1192
+
1193
+ borders = find_boundaries(array, mode='thick')
1194
+ array = array * borders
1195
+ del borders
1196
+ if not keep_labels:
1197
+ return np.argwhere(array != 0)
1198
+ else:
1199
+ return get_all_label_coords(array)
1200
+
1201
+
1202
+
1203
+ def hash_inners(search_region, inner_edges, GPU = False):
1035
1204
  """Internal method used to help sort out inner edge connections. The inner edges of the array will not differentiate between what nodes they contact if those nodes themselves directly touch each other.
1036
1205
  This method allows these elements to be efficiently seperated from each other"""
1037
1206
 
1207
+ from skimage.segmentation import find_boundaries
1208
+
1209
+ borders = find_boundaries(search_region, mode='thick')
1210
+
1211
+ inner_edges = inner_edges * borders #And as a result, we can mask out only 'inner edges' that themselves exist within borders
1212
+
1213
+ inner_edges = dilate_3D_old(inner_edges, 3, 3, 3) #Not sure if dilating is necessary. Want to ensure that the inner edge pieces still overlap with the proper nodes after the masking.
1214
+
1215
+ return inner_edges
1216
+
1217
+ def hash_inners_old(search_region, inner_edges, GPU = True):
1218
+ """Internal method used to help sort out inner edge connections. The inner edges of the array will not differentiate between what nodes they contact if those nodes themselves directly touch each other.
1219
+ This method allows these elements to be efficiently seperated from each other. Originally this was implemented using the gaussian blur because i didn't yet realize skimage could do the same more efficiently."""
1220
+
1038
1221
  print("Performing gaussian blur to hash inner edges.")
1039
1222
 
1040
1223
  blurred_search = smart_dilate.gaussian(search_region, GPU = GPU)
@@ -2170,6 +2353,9 @@ def label_vertices(array, peaks = 0, branch_removal = 0, comp_dil = 0, max_vol =
2170
2353
 
2171
2354
  array = skeletonize(array)
2172
2355
 
2356
+ if return_skele:
2357
+ old_skeleton = copy.deepcopy(array) # The skeleton might get modified in label_vertices so we can make a preserved copy of it to use later
2358
+
2173
2359
  if branch_removal > 0:
2174
2360
  array = remove_branches(array, branch_removal)
2175
2361
 
@@ -2235,7 +2421,7 @@ def label_vertices(array, peaks = 0, branch_removal = 0, comp_dil = 0, max_vol =
2235
2421
 
2236
2422
  if return_skele:
2237
2423
 
2238
- return labeled_image, (array[1:-1, 1:-1, 1:-1]).astype(np.uint8)
2424
+ return labeled_image, old_skeleton
2239
2425
 
2240
2426
  else:
2241
2427
 
@@ -5306,7 +5492,7 @@ class Network_3D:
5306
5492
  neighborhoods.visualize_cluster_composition_umap(self.node_centroids, None, id_dictionary = self.node_identities, graph_label = "Node ID", title = 'UMAP Visualization of Node Centroids')
5307
5493
 
5308
5494
 
5309
- def community_id_info_per_com(self, umap = False, label = False, limit = 0, proportional = False):
5495
+ def community_id_info_per_com(self, umap = False, label = 0, limit = 0, proportional = False, neighbors = None):
5310
5496
 
5311
5497
  community_dict = invert_dict(self.communities)
5312
5498
  summation = 0
@@ -5395,7 +5581,19 @@ class Network_3D:
5395
5581
  if umap:
5396
5582
  from . import neighborhoods
5397
5583
 
5398
- neighborhoods.visualize_cluster_composition_umap(umap_dict, id_set, label = label)
5584
+
5585
+ if self.communities is not None and label == 2:
5586
+ neighbor_group = {}
5587
+ for node, com in self.communities.items():
5588
+ neighbor_group[com] = neighbors[node]
5589
+ neighborhoods.visualize_cluster_composition_umap(umap_dict, id_set, neighborhoods = neighbor_group)
5590
+ elif label == 1:
5591
+ neighborhoods.visualize_cluster_composition_umap(umap_dict, id_set, label = True)
5592
+ else:
5593
+ neighborhoods.visualize_cluster_composition_umap(umap_dict, id_set, label = False)
5594
+
5595
+
5596
+ #neighborhoods.visualize_cluster_composition_umap(umap_dict, id_set, label = label)
5399
5597
 
5400
5598
  return output, id_set
5401
5599
 
@@ -5668,75 +5866,141 @@ class Network_3D:
5668
5866
  pass
5669
5867
 
5670
5868
 
5671
- def nearest_neighbors_avg(self, root, targ, xy_scale = 1, z_scale = 1, num = 1, heatmap = False, threed = True, numpy = False, quant = False):
5869
+ def nearest_neighbors_avg(self, root, targ, xy_scale = 1, z_scale = 1, num = 1, heatmap = False, threed = True, numpy = False, quant = False, centroids = True):
5672
5870
 
5673
- def get_theoretical_nearest_neighbor_distance(compare_set, num_neighbors, volume, is_2d=False):
5674
- """
5675
- Calculate theoretical expected distance to k-th nearest neighbor
5676
- assuming random uniform distribution in 2D or 3D space.
5677
- """
5678
- import math
5871
+ def distribute_points_uniformly(n, shape, z_scale, xy_scale, num, is_2d=False):
5872
+
5873
+ from scipy.spatial import KDTree
5874
+
5875
+ if n <= 1:
5876
+ return 0
5877
+
5878
+ # Calculate total positions and sampling step
5879
+ total_positions = np.prod(shape)
5880
+ if n >= total_positions:
5881
+ # If we want more points than positions, just return scaled unit distance
5882
+ return xy_scale if is_2d else min(z_scale, xy_scale)
5679
5883
 
5680
- if len(compare_set) == 0 or volume <= 0:
5681
- raise ValueError("Invalid input: empty set or non-positive volume")
5884
+ # Create uniformly spaced indices
5885
+ indices = np.linspace(0, total_positions - 1, n, dtype=int)
5682
5886
 
5683
- density = len(compare_set) / volume
5684
- k = num_neighbors
5887
+ # Convert flat indices to coordinates
5888
+ coords = []
5889
+ for idx in indices:
5890
+ coord = np.unravel_index(idx, shape)
5891
+ if len(shape) == 3:
5892
+ # Apply scaling: [z, y, x] with respective scales
5893
+ scaled_coord = [coord[0] * z_scale, coord[1] * xy_scale, coord[2] * xy_scale]
5894
+ elif len(shape) == 2:
5895
+ # Apply scaling: [y, x] with xy_scale
5896
+ scaled_coord = [coord[0] * xy_scale, coord[1] * xy_scale]
5897
+ coords.append(scaled_coord)
5685
5898
 
5686
- if is_2d:
5687
- # Expected distance to k-th nearest neighbor in 2D
5688
- # μ1' = Γ(k + 1/2) / (Γ(k) × √(m × π))
5689
- expected_distance = math.gamma(k + 0.5) / (math.gamma(k) * math.sqrt(density * math.pi))
5690
- else:
5691
- # Expected distance to k-th nearest neighbor in 3D
5692
- # μ1' = Γ(k + 1/3) / (Γ(k) × (m × Φ)^(1/3))
5693
- # where Φ = π^(3/2) / Γ(3/2 + 1) = π^(3/2) / Γ(5/2) = 4π/3
5694
- phi_3d = 4 * math.pi / 3 # Volume of unit sphere in 3D
5695
- expected_distance = math.gamma(k + 1/3) / (math.gamma(k) * (density * phi_3d)**(1/3))
5899
+ coords = np.array(coords)
5900
+
5901
+ # Build KDTree
5902
+ tree = KDTree(coords)
5903
+
5904
+ # Pick a point near the middle of the array
5905
+ middle_idx = len(coords) // 2
5906
+ query_point = coords[middle_idx]
5907
+
5908
+ # Find the num+1 nearest neighbors (including the point itself)
5909
+ distances, indices = tree.query(query_point, k=num+1)
5910
+
5911
+ # Exclude the point itself (distance 0) and get the actual neighbors
5912
+ neighbor_distances = distances[1:num+1]
5913
+
5914
+ if num == n:
5915
+ neighbor_distances[-1] = neighbor_distances[-2]
5916
+
5917
+ avg_distance = np.mean(neighbor_distances)
5696
5918
 
5697
- return expected_distance
5919
+
5920
+ return avg_distance
5698
5921
 
5699
- root_set = []
5922
+ do_borders = not centroids
5700
5923
 
5701
- compare_set = []
5924
+ if centroids:
5925
+ root_set = []
5702
5926
 
5703
- if root is None:
5927
+ compare_set = []
5704
5928
 
5705
- root_set = list(self.node_centroids.keys())
5706
- compare_set = root_set
5707
- title = "Nearest Neighbors Between Nodes Heatmap"
5929
+ if root is None:
5708
5930
 
5709
- else:
5931
+ root_set = list(self.node_centroids.keys())
5932
+ compare_set = root_set
5933
+ title = "Nearest Neighbors Between Nodes Heatmap"
5934
+
5935
+ else:
5710
5936
 
5711
- title = f"Nearest Neighbors of ID {targ} from ID {root} Heatmap"
5937
+ title = f"Nearest Neighbors of ID {targ} from ID {root} Heatmap"
5712
5938
 
5713
- for node, iden in self.node_identities.items():
5939
+ for node, iden in self.node_identities.items():
5714
5940
 
5715
- if iden == root:
5941
+ if iden == root:
5716
5942
 
5717
- root_set.append(node)
5943
+ root_set.append(node)
5718
5944
 
5719
- elif (iden == targ) or (targ == 'All Others (Excluding Self)'):
5945
+ elif (iden == targ) or (targ == 'All Others (Excluding Self)'):
5720
5946
 
5721
- compare_set.append(node)
5947
+ compare_set.append(node)
5722
5948
 
5723
- if root == targ:
5949
+ if root == targ:
5950
+
5951
+ compare_set = root_set
5952
+ if len(compare_set) - 1 < num:
5953
+
5954
+ num = len(compare_set) - 1
5955
+
5956
+ print(f"Error: Not enough neighbor nodes for requested number of neighbors. Using max available neighbors: {num}")
5957
+
5724
5958
 
5725
- compare_set = root_set
5726
- if len(compare_set) - 1 < num:
5959
+ if len(compare_set) < num:
5727
5960
 
5728
- num = len(compare_set) - 1
5961
+ num = len(compare_set)
5729
5962
 
5730
5963
  print(f"Error: Not enough neighbor nodes for requested number of neighbors. Using max available neighbors: {num}")
5731
-
5732
5964
 
5733
- if len(compare_set) < num:
5965
+ avg, output = proximity.average_nearest_neighbor_distances(self.node_centroids, root_set, compare_set, xy_scale=self.xy_scale, z_scale=self.z_scale, num = num, do_borders = do_borders)
5734
5966
 
5735
- num = len(compare_set)
5967
+ else:
5968
+ if heatmap:
5969
+ root_set = []
5970
+ compare_set = []
5971
+ if root is None:
5736
5972
 
5737
- print(f"Error: Not enough neighbor nodes for requested number of neighbors. Using max available neighbors: {num}")
5738
-
5739
- avg, output = proximity.average_nearest_neighbor_distances(self.node_centroids, root_set, compare_set, xy_scale=self.xy_scale, z_scale=self.z_scale, num = num)
5973
+ root_set = list(self.node_centroids.keys())
5974
+ compare_set = root_set
5975
+ else:
5976
+ for node, iden in self.node_identities.items():
5977
+
5978
+ if iden == root:
5979
+
5980
+ root_set.append(node)
5981
+
5982
+ elif (iden == targ) or (targ == 'All Others (Excluding Self)'):
5983
+
5984
+ compare_set.append(node)
5985
+
5986
+ if root is None:
5987
+ title = "Nearest Neighbors Between Nodes Heatmap"
5988
+ root_set_neigh = approx_boundaries(self.nodes, keep_labels = True)
5989
+ compare_set_neigh = approx_boundaries(self.nodes, keep_labels = False)
5990
+ else:
5991
+ title = f"Nearest Neighbors of ID {targ} from ID {root} Heatmap"
5992
+
5993
+ root_set_neigh = approx_boundaries(self.nodes, [root], self.node_identities, keep_labels = True)
5994
+
5995
+ if targ == 'All Others (Excluding Self)':
5996
+ compare_set_neigh = set(self.node_identities.values())
5997
+ compare_set_neigh.remove(root)
5998
+ targ = compare_set_neigh
5999
+ else:
6000
+ targ = [targ]
6001
+
6002
+ compare_set_neigh = approx_boundaries(self.nodes, targ, self.node_identities, keep_labels = False)
6003
+ avg, output = proximity.average_nearest_neighbor_distances(self.node_centroids, root_set_neigh, compare_set_neigh, xy_scale=self.xy_scale, z_scale=self.z_scale, num = num, do_borders = do_borders)
5740
6004
 
5741
6005
  if quant:
5742
6006
  try:
@@ -5778,8 +6042,7 @@ class Network_3D:
5778
6042
  else:
5779
6043
  is_2d = False
5780
6044
 
5781
- pred = get_theoretical_nearest_neighbor_distance(compare_set, num, volume, is_2d = is_2d)
5782
- #pred = avg
6045
+ pred = distribute_points_uniformly(len(compare_set), bounds, self.z_scale, self.xy_scale, num = num, is_2d = is_2d)
5783
6046
 
5784
6047
  node_intensity = {}
5785
6048
  import math
@@ -5787,18 +6050,22 @@ class Network_3D:
5787
6050
 
5788
6051
  for node in root_set:
5789
6052
  node_intensity[node] = math.log(pred/output[node])
6053
+ #print(output[node])
5790
6054
  node_centroids[node] = self.node_centroids[node]
5791
6055
 
5792
6056
  if numpy:
5793
6057
 
5794
6058
  overlay = neighborhoods.create_node_heatmap(node_intensity, node_centroids, shape = shape, is_3d=threed, labeled_array = self.nodes, colorbar_label="Clustering Intensity", title = title)
5795
6059
 
5796
- return avg, output, overlay, quant_overlay
6060
+ return avg, output, overlay, quant_overlay, pred
5797
6061
 
5798
6062
  else:
5799
6063
  neighborhoods.create_node_heatmap(node_intensity, node_centroids, shape = shape, is_3d=threed, labeled_array = None, colorbar_label="Clustering Intensity", title = title)
5800
6064
 
5801
- return avg, output, quant_overlay
6065
+ else:
6066
+ pred = None
6067
+
6068
+ return avg, output, quant_overlay, pred
5802
6069
 
5803
6070
 
5804
6071