nettracer3d 0.9.9__py3-none-any.whl → 1.1.5__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.
nettracer3d/nettracer.py CHANGED
@@ -35,6 +35,7 @@ from . import proximity
35
35
  from skimage.segmentation import watershed as water
36
36
 
37
37
 
38
+
38
39
  #These next several methods relate to searching with 3D objects by dilating each one in a subarray around their neighborhood although I don't explicitly use this anywhere... can call them deprecated although I may want to use them later again so I have them still written out here.
39
40
 
40
41
 
@@ -383,6 +384,13 @@ def invert_dict(d):
383
384
  inverted.setdefault(value, []).append(key)
384
385
  return inverted
385
386
 
387
+ def revert_dict(d):
388
+ inverted = {}
389
+ for key, value_list in d.items():
390
+ for value in value_list:
391
+ inverted[value] = key
392
+ return inverted
393
+
386
394
  def invert_dict_special(d):
387
395
 
388
396
  d = invert_dict(d)
@@ -737,7 +745,7 @@ def estimate_object_radii(labeled_array, gpu=False, n_jobs=None, xy_scale = 1, z
737
745
  return morphology.estimate_object_radii_cpu(labeled_array, n_jobs, xy_scale = xy_scale, z_scale = z_scale)
738
746
 
739
747
 
740
- def break_and_label_skeleton(skeleton, peaks = 1, branch_removal = 0, comp_dil = 0, max_vol = 0, directory = None, return_skele = False, nodes = None):
748
+ 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):
741
749
  """Internal method to break open a skeleton at its branchpoints and label the remaining components, for an 8bit binary array"""
742
750
 
743
751
  if type(skeleton) == str:
@@ -746,18 +754,28 @@ def break_and_label_skeleton(skeleton, peaks = 1, branch_removal = 0, comp_dil =
746
754
  else:
747
755
  broken_skele = None
748
756
 
749
- #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
750
-
751
757
  if nodes is None:
752
758
 
753
- verts = label_vertices(skeleton, peaks = peaks, branch_removal = branch_removal, comp_dil = comp_dil, max_vol = max_vol, return_skele = return_skele)
759
+ verts = label_vertices(skeleton, peaks = peaks, branch_removal = branch_removal, comp_dil = comp_dil, max_vol = max_vol, return_skele = return_skele, compute = compute)
754
760
 
755
761
  else:
756
762
  verts = nodes
757
763
 
758
764
  verts = invert_array(verts)
759
765
 
760
- #skeleton = old_skeleton
766
+ """
767
+ if compute: # We are interested in the endpoints if we are doing the optional computation later
768
+ endpoints = []
769
+ image_copy = np.pad(skeleton, pad_width=1, mode='constant', constant_values=0)
770
+ nonzero_coords = np.transpose(np.nonzero(image_copy))
771
+ for x, y, z in nonzero_coords:
772
+ mini = image_copy[x-1:x+2, y-1:y+2, z-1:z+2]
773
+ nearby_sum = np.sum(mini)
774
+ threshold = 2 * image_copy[x, y, z]
775
+
776
+ if nearby_sum <= threshold:
777
+ endpoints.append((x, y, z))
778
+ """
761
779
 
762
780
  image_copy = skeleton * verts
763
781
 
@@ -775,9 +793,137 @@ def break_and_label_skeleton(skeleton, peaks = 1, branch_removal = 0, comp_dil =
775
793
  tifffile.imwrite(filename, labeled_image, photometric='minisblack')
776
794
  print(f"Broken skeleton saved to {filename}")
777
795
 
778
- return labeled_image
796
+ if compute:
797
+
798
+ return labeled_image, None, skeleton, None
779
799
 
800
+ return labeled_image, None, None, None
780
801
 
802
+ def compute_optional_branchstats(verts, labeled_array, endpoints, xy_scale = 1, z_scale = 1):
803
+
804
+ #Lengths:
805
+ # Get all non-background coordinates and their labels in one pass
806
+ z, y, x = np.where(labeled_array != 0)
807
+ labels = labeled_array[z, y, x]
808
+
809
+ # Sort by label
810
+ sort_idx = np.argsort(labels)
811
+ labels_sorted = labels[sort_idx]
812
+ z_sorted = z[sort_idx]
813
+ y_sorted = y[sort_idx]
814
+ x_sorted = x[sort_idx]
815
+
816
+ # Find where each label starts
817
+ unique_labels, split_idx = np.unique(labels_sorted, return_index=True)
818
+ split_idx = split_idx[1:] # Remove first index for np.split
819
+
820
+ # Split into groups
821
+ z_split = np.split(z_sorted, split_idx)
822
+ y_split = np.split(y_sorted, split_idx)
823
+ x_split = np.split(x_sorted, split_idx)
824
+
825
+ # Build dict
826
+ coords_dict = {label: np.column_stack([z, y, x])
827
+ for label, z, y, x in zip(unique_labels, z_split, y_split, x_split)}
828
+
829
+ from sklearn.neighbors import NearestNeighbors
830
+ from scipy.spatial.distance import pdist, squareform
831
+ len_dict = {}
832
+ tortuosity_dict = {}
833
+ angle_dict = {}
834
+ for label, coords in coords_dict.items():
835
+ len_dict[label] = morphology.calculate_skeleton_lengths(labeled_array.shape, xy_scale=xy_scale, z_scale=z_scale, skeleton_coords=coords)
836
+
837
+ # Find neighbors for all points at once
838
+ nbrs = NearestNeighbors(radius=1.74, algorithm='kd_tree').fit(coords)
839
+ neighbor_counts = nbrs.radius_neighbors(coords, return_distance=False)
840
+ neighbor_counts = np.array([len(n) - 1 for n in neighbor_counts]) # -1 to exclude self
841
+
842
+ # Endpoints have exactly 1 neighbor
843
+ endpoints = coords[neighbor_counts == 1]
844
+
845
+ if len(endpoints) > 1:
846
+ # Scale endpoints
847
+ scaled_endpoints = endpoints.copy().astype(float)
848
+ scaled_endpoints[:, 0] *= z_scale # z dimension
849
+ scaled_endpoints[:, 1] *= xy_scale # y dimension
850
+ scaled_endpoints[:, 2] *= xy_scale # x dimension
851
+
852
+ # calculate distances on scaled coordinates
853
+ distances = pdist(scaled_endpoints, metric='euclidean')
854
+ max_distance = distances.max()
855
+
856
+ tortuosity_dict[label] = len_dict[label]/max_distance
857
+
858
+ """
859
+ verts = invert_array(verts)
860
+ for x, y, z in endpoints:
861
+ try:
862
+ verts[z,y,x] = 1
863
+ except IndexError:
864
+ print(x, y, z)
865
+
866
+ temp_network = Network_3D(nodes = verts, edges = labeled_array, xy_scale = xy_scale, z_scale = z_scale)
867
+ temp_network.calculate_all(temp_network.nodes, temp_network.edges, xy_scale = temp_network.xy_scale, z_scale = temp_network.z_scale, search = None, diledge = None, inners = False, remove_trunk = 0, ignore_search_region = True, other_nodes = None, label_nodes = True, directory = None, GPU = False, fast_dil = False, skeletonize = False, GPU_downsample = None)
868
+ temp_network.calculate_node_centroids()
869
+ from itertools import combinations
870
+ for node in temp_network.network.nodes:
871
+ neighbors = list(temp_network.network.neighbors(node))
872
+
873
+ # Skip if fewer than 2 neighbors (endpoints or isolated nodes)
874
+ if len(neighbors) < 2:
875
+ continue
876
+
877
+ # Get all unique pairs of neighbors
878
+ neighbor_pairs = combinations(neighbors, 2)
879
+
880
+ angles = []
881
+ for neighbor1, neighbor2 in neighbor_pairs:
882
+ # Get coordinates from centroids
883
+ point_a = temp_network.node_centroids[neighbor1]
884
+ point_b = temp_network.node_centroids[node] # vertex
885
+ point_c = temp_network.node_centroids[neighbor2]
886
+
887
+ # Calculate angle
888
+ angle_result = calculate_3d_angle(point_a, point_b, point_c, xy_scale = xy_scale, z_scale = z_scale)
889
+ angles.append(angle_result)
890
+
891
+ angle_dict[node] = angles
892
+ """
893
+
894
+ return len_dict, tortuosity_dict, angle_dict
895
+
896
+ def calculate_3d_angle(point_a, point_b, point_c, xy_scale = 1, z_scale = 1):
897
+ """Calculate 3D angle at vertex B between points A-B-C."""
898
+ z1, y1, x1 = point_a
899
+ z2, y2, x2 = point_b # vertex
900
+ z3, y3, x3 = point_c
901
+
902
+ # Apply scaling
903
+ scaled_a = np.array([x1 * xy_scale, y1 * xy_scale, z1 * z_scale])
904
+ scaled_b = np.array([x2 * xy_scale, y2 * xy_scale, z2 * z_scale])
905
+ scaled_c = np.array([x3 * xy_scale, y3 * xy_scale, z3 * z_scale])
906
+
907
+ # Create vectors from vertex B
908
+ vec_ba = scaled_a - scaled_b
909
+ vec_bc = scaled_c - scaled_b
910
+
911
+ # Calculate angle using dot product
912
+ dot_product = np.dot(vec_ba, vec_bc)
913
+ magnitude_ba = np.linalg.norm(vec_ba)
914
+ magnitude_bc = np.linalg.norm(vec_bc)
915
+
916
+ # Avoid division by zero
917
+ if magnitude_ba == 0 or magnitude_bc == 0:
918
+ return {'angle_degrees': 0}
919
+
920
+ cos_angle = dot_product / (magnitude_ba * magnitude_bc)
921
+ cos_angle = np.clip(cos_angle, -1.0, 1.0) # Handle numerical errors
922
+
923
+ angle_radians = np.arccos(cos_angle)
924
+ angle_degrees = np.degrees(angle_radians)
925
+
926
+ return angle_degrees
781
927
 
782
928
  def threshold(arr, proportion, custom_rad = None):
783
929
 
@@ -803,7 +949,7 @@ def threshold(arr, proportion, custom_rad = None):
803
949
 
804
950
  threshold_index = int(len(sorted_values) * proportion)
805
951
  threshold_value = sorted_values[threshold_index]
806
- print(f"Thresholding as if smallest_radius as assigned {threshold_value}")
952
+ print(f"Thresholding as if smallest_radius was assigned {threshold_value}")
807
953
 
808
954
 
809
955
  mask = arr > threshold_value
@@ -906,9 +1052,12 @@ def show_3d(arrays_3d=None, arrays_4d=None, down_factor=None, order=0, xy_scale=
906
1052
  # Downsample arrays if specified
907
1053
  arrays_3d = [downsample(array, down_factor, order=order) for array in arrays_3d] if arrays_3d is not None else None
908
1054
  arrays_4d = [downsample(array, down_factor, order=order) for array in arrays_4d] if arrays_4d is not None else None
1055
+ scale = [z_scale * down_factor, xy_scale * down_factor, xy_scale * down_factor]
1056
+ else:
1057
+ scale = [z_scale, xy_scale, xy_scale]
1058
+
909
1059
 
910
1060
  viewer = napari.Viewer(ndisplay=3)
911
- scale = [z_scale, xy_scale, xy_scale] # [z, y, x] order for napari
912
1061
 
913
1062
  # Add 3D arrays if provided
914
1063
  if arrays_3d is not None:
@@ -992,6 +1141,61 @@ def z_project(array3d, method='max'):
992
1141
  raise ValueError("Method must be one of: 'max', 'mean', 'min', 'sum', 'std'")
993
1142
 
994
1143
  def fill_holes_3d(array, head_on = False, fill_borders = True):
1144
+ def process_slice(slice_2d, border_threshold=0.08, fill_borders = True):
1145
+ """
1146
+ Process a 2D slice, considering components that touch less than border_threshold
1147
+ of any border length as potential holes.
1148
+
1149
+ Args:
1150
+ slice_2d: 2D binary array
1151
+ border_threshold: proportion of border that must be touched to be considered background
1152
+ """
1153
+ from scipy.ndimage import binary_fill_holes
1154
+
1155
+ slice_2d = slice_2d.astype(np.uint8)
1156
+
1157
+ # Apply scipy's binary_fill_holes to the result
1158
+ slice_2d = binary_fill_holes(slice_2d)
1159
+
1160
+ return slice_2d
1161
+
1162
+ print("Filling Holes...")
1163
+
1164
+ array = binarize(array)
1165
+ #inv_array = invert_array(array)
1166
+
1167
+ # Create arrays for all three planes
1168
+ array_xy = np.zeros_like(array, dtype=np.uint8)
1169
+ array_xz = np.zeros_like(array, dtype=np.uint8)
1170
+ array_yz = np.zeros_like(array, dtype=np.uint8)
1171
+
1172
+ # Process XY plane
1173
+ for z in range(array.shape[0]):
1174
+ array_xy[z] = process_slice(array[z], fill_borders = fill_borders)
1175
+
1176
+ if (array.shape[0] > 3) and not head_on: #only use these dimensions for sufficiently large zstacks
1177
+
1178
+ # Process XZ plane
1179
+ for y in range(array.shape[1]):
1180
+ slice_xz = array[:, y, :]
1181
+ array_xz[:, y, :] = process_slice(slice_xz, fill_borders = fill_borders)
1182
+
1183
+ # Process YZ plane
1184
+ for x in range(array.shape[2]):
1185
+ slice_yz = array[:, :, x]
1186
+ array_yz[:, :, x] = process_slice(slice_yz, fill_borders = fill_borders)
1187
+
1188
+ # Combine results from all three planes
1189
+ filled = (array_xy | array_xz | array_yz) * 255
1190
+ return array + filled
1191
+ else:
1192
+ # Apply scipy's binary_fill_holes to each XY slice
1193
+ from scipy.ndimage import binary_fill_holes
1194
+ for z in range(array_xy.shape[0]):
1195
+ array_xy[z] = binary_fill_holes(array_xy[z])
1196
+ return array_xy * 255
1197
+
1198
+ def fill_holes_3d_old(array, head_on = False, fill_borders = True):
995
1199
 
996
1200
  def process_slice(slice_2d, border_threshold=0.08, fill_borders = True):
997
1201
  """
@@ -1847,6 +2051,7 @@ def mirror_points_for_edge_correction(points_array, bounds, max_r, dim=3):
1847
2051
  all_points = np.vstack([all_points, mirrored_points])
1848
2052
 
1849
2053
  return all_points
2054
+
1850
2055
  def get_max_r_from_proportion(bounds, proportion):
1851
2056
  """
1852
2057
  Calculate max_r based on bounds and proportion, matching your generate_r_values logic.
@@ -2072,7 +2277,7 @@ def erode(arrayimage, amount, xy_scale = 1, z_scale = 1, mode = 0, preserve_labe
2072
2277
  arrayimage = binarize(arrayimage)
2073
2278
  erode_xy, erode_z = dilation_length_to_pixels(xy_scale, z_scale, amount, amount)
2074
2279
 
2075
- if mode == 0:
2280
+ if mode == 2:
2076
2281
  arrayimage = (erode_3D(arrayimage, erode_xy, erode_xy, erode_z)) * 255
2077
2282
  else:
2078
2283
  arrayimage = erode_3D_dt(arrayimage, amount, xy_scaling=xy_scale, z_scaling=z_scale, preserve_labels = preserve_labels)
@@ -2127,7 +2332,7 @@ def skeletonize(arrayimage, directory = None):
2127
2332
 
2128
2333
  return arrayimage
2129
2334
 
2130
- 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):
2335
+ 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):
2131
2336
  """
2132
2337
  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.
2133
2338
  :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.
@@ -2158,10 +2363,10 @@ def label_branches(array, peaks = 0, branch_removal = 0, comp_dil = 0, max_vol =
2158
2363
 
2159
2364
  other_array = skeletonize(array)
2160
2365
 
2161
- other_array = break_and_label_skeleton(other_array, peaks = peaks, branch_removal = branch_removal, comp_dil = comp_dil, max_vol = max_vol, nodes = nodes)
2366
+ 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)
2162
2367
 
2163
2368
  else:
2164
- array = break_and_label_skeleton(array, peaks = peaks, branch_removal = branch_removal, comp_dil = comp_dil, max_vol = max_vol, nodes = nodes)
2369
+ 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)
2165
2370
 
2166
2371
  if nodes is not None and down_factor is not None:
2167
2372
  array = upsample_with_padding(array, down_factor, arrayshape)
@@ -2200,7 +2405,7 @@ def label_branches(array, peaks = 0, branch_removal = 0, comp_dil = 0, max_vol =
2200
2405
  print("Branches labelled")
2201
2406
 
2202
2407
 
2203
- return array
2408
+ return array, verts, skele, endpoints
2204
2409
 
2205
2410
  def fix_branches_network(array, G, communities, fix_val = None):
2206
2411
 
@@ -2870,7 +3075,7 @@ class Network_3D:
2870
3075
  for _ in range(weight):
2871
3076
  lista.append(u)
2872
3077
  listb.append(v)
2873
- listc.append(weight)
3078
+ listc.append(0)
2874
3079
 
2875
3080
  self._network_lists = [lista, listb, listc]
2876
3081
 
@@ -3086,6 +3291,15 @@ class Network_3D:
3086
3291
  Can be called on a Network_3D object to save the nodes property to hard mem as a tif. It will save to the active directory if none is specified.
3087
3292
  :param directory: (Optional - Val = None; String). The path to an indended directory to save the nodes to.
3088
3293
  """
3294
+ if self._nodes is not None:
3295
+ imagej_metadata = {
3296
+ 'spacing': self.z_scale,
3297
+ 'slices': self._nodes.shape[0],
3298
+ 'channels': 1,
3299
+ 'axes': 'ZYX'
3300
+ }
3301
+ resolution_value = 1.0 / self.xy_scale if self.xy_scale != 0 else 1
3302
+
3089
3303
  if filename is None:
3090
3304
  filename = "labelled_nodes.tif"
3091
3305
  elif not filename.endswith(('.tif', '.tiff')):
@@ -3094,13 +3308,19 @@ class Network_3D:
3094
3308
  if self._nodes is not None:
3095
3309
  if directory is None:
3096
3310
  try:
3097
- tifffile.imwrite(f"{filename}", self._nodes)
3311
+ if len(self._nodes.shape) == 3:
3312
+ tifffile.imwrite(f"{filename}", self._nodes, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3313
+ else:
3314
+ tifffile.imwrite(f"{filename}", self._nodes)
3098
3315
  print(f"Nodes saved to {filename}")
3099
3316
  except Exception as e:
3100
3317
  print("Could not save nodes")
3101
3318
  if directory is not None:
3102
3319
  try:
3103
- tifffile.imwrite(f"{directory}/{filename}", self._nodes)
3320
+ if len(self._nodes.shape) == 3:
3321
+ tifffile.imwrite(f"{directory}/{filename}", self._nodes, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3322
+ else:
3323
+ tifffile.imwrite(f"{directory}/{filename}")
3104
3324
  print(f"Nodes saved to {directory}/{filename}")
3105
3325
  except Exception as e:
3106
3326
  print(f"Could not save nodes to {directory}")
@@ -3113,6 +3333,16 @@ class Network_3D:
3113
3333
  :param directory: (Optional - Val = None; String). The path to an indended directory to save the edges to.
3114
3334
  """
3115
3335
 
3336
+ if self._edges is not None:
3337
+ imagej_metadata = {
3338
+ 'spacing': self.z_scale,
3339
+ 'slices': self._edges.shape[0],
3340
+ 'channels': 1,
3341
+ 'axes': 'ZYX'
3342
+ }
3343
+
3344
+ resolution_value = 1.0 / self.xy_scale if self.xy_scale != 0 else 1
3345
+
3116
3346
  if filename is None:
3117
3347
  filename = "labelled_edges.tif"
3118
3348
  elif not filename.endswith(('.tif', '.tiff')):
@@ -3120,11 +3350,11 @@ class Network_3D:
3120
3350
 
3121
3351
  if self._edges is not None:
3122
3352
  if directory is None:
3123
- tifffile.imwrite(f"{filename}", self._edges)
3353
+ tifffile.imwrite(f"{filename}", self._edges, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3124
3354
  print(f"Edges saved to {filename}")
3125
3355
 
3126
3356
  if directory is not None:
3127
- tifffile.imwrite(f"{directory}/{filename}", self._edges)
3357
+ tifffile.imwrite(f"{directory}/{filename}", self._edges, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3128
3358
  print(f"Edges saved to {directory}/{filename}")
3129
3359
 
3130
3360
  if self._edges is None:
@@ -3280,6 +3510,14 @@ class Network_3D:
3280
3510
 
3281
3511
  def save_network_overlay(self, directory = None, filename = None):
3282
3512
 
3513
+ if self._network_overlay is not None:
3514
+ imagej_metadata = {
3515
+ 'spacing': self.z_scale,
3516
+ 'slices': self._network_overlay.shape[0],
3517
+ 'channels': 1,
3518
+ 'axes': 'ZYX'
3519
+ }
3520
+ resolution_value = 1.0 / self.xy_scale if self.xy_scale != 0 else 1
3283
3521
 
3284
3522
  if filename is None:
3285
3523
  filename = "overlay_1.tif"
@@ -3288,15 +3526,30 @@ class Network_3D:
3288
3526
 
3289
3527
  if self._network_overlay is not None:
3290
3528
  if directory is None:
3291
- tifffile.imwrite(f"{filename}", self._network_overlay)
3529
+ if len(self._network_overlay.shape) == 3:
3530
+ tifffile.imwrite(f"{filename}", self._network_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3531
+ else:
3532
+ tifffile.imwrite(f"{filename}", self._network_overlay)
3292
3533
  print(f"Network overlay saved to {filename}")
3293
3534
 
3294
3535
  if directory is not None:
3295
- tifffile.imwrite(f"{directory}/{filename}", self._network_overlay)
3536
+ if len(self._network_overlay.shape) == 3:
3537
+ tifffile.imwrite(f"{directory}/{filename}", self._network_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3538
+ else:
3539
+ tifffile.imwrite(f"{directory}/{filename}", self._network_overlay)
3296
3540
  print(f"Network overlay saved to {directory}/{filename}")
3297
3541
 
3298
3542
  def save_id_overlay(self, directory = None, filename = None):
3299
3543
 
3544
+ if self._id_overlay is not None:
3545
+ imagej_metadata = {
3546
+ 'spacing': self.z_scale,
3547
+ 'slices': self._id_overlay.shape[0],
3548
+ 'channels': 1,
3549
+ 'axes': 'ZYX'
3550
+ }
3551
+ resolution_value = 1.0 / self.xy_scale if self.xy_scale != 0 else 1
3552
+
3300
3553
  if filename is None:
3301
3554
  filename = "overlay_2.tif"
3302
3555
  if not filename.endswith(('.tif', '.tiff')):
@@ -3304,11 +3557,17 @@ class Network_3D:
3304
3557
 
3305
3558
  if self._id_overlay is not None:
3306
3559
  if directory is None:
3307
- tifffile.imwrite(f"{filename}", self._id_overlay)
3560
+ if len(self._id_overlay.shape) == 3:
3561
+ tifffile.imwrite(f"{filename}", self._id_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3562
+ else:
3563
+ tifffile.imwrite(f"{filename}", self._id_overlay, imagej=True)
3308
3564
  print(f"Network overlay saved to {filename}")
3309
3565
 
3310
3566
  if directory is not None:
3311
- tifffile.imwrite(f"{directory}/{filename}", self._id_overlay)
3567
+ if len(self._id_overlay.shape) == 3:
3568
+ tifffile.imwrite(f"{directory}/{filename}", self._id_overlay, imagej=True, metadata=imagej_metadata, resolution=(resolution_value, resolution_value))
3569
+ else:
3570
+ tifffile.imwrite(f"{directory}/{filename}", self._id_overlay)
3312
3571
  print(f"ID overlay saved to {directory}/{filename}")
3313
3572
 
3314
3573
 
@@ -3431,7 +3690,7 @@ class Network_3D:
3431
3690
 
3432
3691
  if file_path is not None:
3433
3692
  self._xy_scale, self_z_scale = read_scalings(file_path)
3434
- print("Succesfully loaded voxel_scalings")
3693
+ print(f"Succesfully loaded voxel_scalings; values overriden to xy_scale: {self.xy_scale}, z_scale: {self.z_scale}")
3435
3694
  return
3436
3695
 
3437
3696
  items = directory_info(directory)
@@ -3440,11 +3699,11 @@ class Network_3D:
3440
3699
  if item == 'voxel_scalings.txt':
3441
3700
  if directory is not None:
3442
3701
  self._xy_scale, self._z_scale = read_scalings(f"{directory}/{item}")
3443
- print("Succesfully loaded voxel_scalings")
3702
+ print(f"Succesfully loaded voxel_scalings; values overriden to xy_scale: {self.xy_scale}, z_scale: {self.z_scale}")
3444
3703
  return
3445
3704
  else:
3446
3705
  self._xy_scale, self._z_scale = read_scalings(item)
3447
- print("Succesfully loaded voxel_scalings")
3706
+ print(f"Succesfully loaded voxel_scaling; values overriden to xy_scale: {self.xy_scale}, z_scale: {self.z_scale}s")
3448
3707
  return
3449
3708
 
3450
3709
  print("Could not find voxel scalings. They must be in the specified directory and named 'voxel_scalings.txt'")
@@ -3877,7 +4136,7 @@ class Network_3D:
3877
4136
  """
3878
4137
  self._nodes, num_nodes = label_objects(nodes, structure_3d)
3879
4138
 
3880
- def combine_nodes(self, root_nodes, other_nodes, other_ID, identity_dict, root_ID = None, centroids = False):
4139
+ def combine_nodes(self, root_nodes, other_nodes, other_ID, identity_dict, root_ID = None, centroids = False, down_factor = None):
3881
4140
 
3882
4141
  """Internal method to merge two labelled node arrays into one"""
3883
4142
 
@@ -3888,7 +4147,10 @@ class Network_3D:
3888
4147
  max_val = np.max(root_nodes)
3889
4148
  other_nodes[:] = np.where(mask, other_nodes + max_val, 0)
3890
4149
  if centroids:
3891
- new_dict = network_analysis._find_centroids(other_nodes)
4150
+ new_dict = network_analysis._find_centroids(other_nodes, down_factor = down_factor)
4151
+ if down_factor is not None:
4152
+ for item in new_dict:
4153
+ new_dict[item] = down_factor * new_dict[item]
3892
4154
  self.node_centroids.update(new_dict)
3893
4155
 
3894
4156
  if root_ID is not None:
@@ -3928,7 +4190,7 @@ class Network_3D:
3928
4190
 
3929
4191
  return nodes, identity_dict
3930
4192
 
3931
- def merge_nodes(self, addn_nodes_name, label_nodes = True, root_id = "Root_Nodes", centroids = False):
4193
+ def merge_nodes(self, addn_nodes_name, label_nodes = True, root_id = "Root_Nodes", centroids = False, down_factor = None):
3932
4194
  """
3933
4195
  Merges the self._nodes attribute with alternate labelled node images. The alternate nodes can be inputted as a string for a filepath to a tif,
3934
4196
  or as a directory address containing only tif images, which will merge the _nodes attribute with all tifs in the folder. The _node_identities attribute
@@ -3949,19 +4211,21 @@ class Network_3D:
3949
4211
  identity_dict = {} #A dictionary to deliniate the node identities
3950
4212
 
3951
4213
  if centroids:
3952
- self.node_centroids = network_analysis._find_centroids(self._nodes)
3953
-
4214
+ self.node_centroids = network_analysis._find_centroids(self._nodes, down_factor = down_factor)
4215
+ if down_factor is not None:
4216
+ for item in self.node_centroids:
4217
+ self.node_centroids[item] = down_factor * self.node_centroids[item]
3954
4218
 
3955
4219
  try: #Try presumes the input is a tif
3956
4220
  addn_nodes = tifffile.imread(addn_nodes_name) #If not this will fail and activate the except block
3957
4221
 
3958
4222
  if label_nodes is True:
3959
4223
  addn_nodes, num_nodes2 = label_objects(addn_nodes) # Label the node objects. Note this presumes no overlap between node masks.
3960
- node_labels, identity_dict = self.combine_nodes(self._nodes, addn_nodes, addn_nodes_name, identity_dict, nodes_name, centroids = centroids) #This method stacks labelled arrays
4224
+ node_labels, identity_dict = self.combine_nodes(self._nodes, addn_nodes, addn_nodes_name, identity_dict, nodes_name, centroids = centroids, down_factor = down_factor) #This method stacks labelled arrays
3961
4225
  num_nodes = np.max(node_labels)
3962
4226
 
3963
4227
  else: #If nodes already labelled
3964
- node_labels, identity_dict = self.combine_nodes(self._nodes, addn_nodes, addn_nodes_name, identity_dict, nodes_name, centroids = centroids)
4228
+ node_labels, identity_dict = self.combine_nodes(self._nodes, addn_nodes, addn_nodes_name, identity_dict, nodes_name, centroids = centroids, down_factor = down_factor)
3965
4229
  num_nodes = int(np.max(node_labels))
3966
4230
 
3967
4231
  except: #Exception presumes the input is a directory containing multiple tifs, to allow multi-node stackage.
@@ -3979,15 +4243,15 @@ class Network_3D:
3979
4243
  if label_nodes is True:
3980
4244
  addn_nodes, num_nodes2 = label_objects(addn_nodes) # Label the node objects. Note this presumes no overlap between node masks.
3981
4245
  if i == 0:
3982
- node_labels, identity_dict = self.combine_nodes(self._nodes, addn_nodes, addn_nodes_ID, identity_dict, nodes_name, centroids = centroids)
4246
+ node_labels, identity_dict = self.combine_nodes(self._nodes, addn_nodes, addn_nodes_ID, identity_dict, nodes_name, centroids = centroids, down_factor = down_factor)
3983
4247
  else:
3984
- node_labels, identity_dict = self.combine_nodes(node_labels, addn_nodes, addn_nodes_ID, identity_dict, centroids = centroids)
4248
+ node_labels, identity_dict = self.combine_nodes(node_labels, addn_nodes, addn_nodes_ID, identity_dict, centroids = centroids, down_factor = down_factor)
3985
4249
 
3986
4250
  else:
3987
4251
  if i == 0:
3988
- node_labels, identity_dict = self.combine_nodes(self._nodes, addn_nodes, addn_nodes_ID, identity_dict, nodes_name, centroids = centroids)
4252
+ node_labels, identity_dict = self.combine_nodes(self._nodes, addn_nodes, addn_nodes_ID, identity_dict, nodes_name, centroids = centroids, down_factor = down_factor)
3989
4253
  else:
3990
- node_labels, identity_dict = self.combine_nodes(node_labels, addn_nodes, addn_nodes_ID, identity_dict, centroids = centroids)
4254
+ node_labels, identity_dict = self.combine_nodes(node_labels, addn_nodes, addn_nodes_ID, identity_dict, centroids = centroids, down_factor = down_factor)
3991
4255
  except Exception as e:
3992
4256
  print("Could not open additional nodes, verify they are being inputted correctly...")
3993
4257
 
@@ -4037,6 +4301,88 @@ class Network_3D:
4037
4301
  self._network_lists = network_analysis.read_excel_to_lists(df)
4038
4302
  self._network, net_weights = network_analysis.weighted_network(df)
4039
4303
 
4304
+ def create_id_network(self, n=5):
4305
+ import ast
4306
+ import random
4307
+
4308
+ if self.node_identities is None:
4309
+ return
4310
+
4311
+ def invert_dict(d):
4312
+ inverted = {}
4313
+ for key, value in d.items():
4314
+ inverted.setdefault(value, []).append(key)
4315
+ return inverted
4316
+
4317
+ # Invert to get identity -> list of nodes
4318
+ identity_to_nodes = invert_dict(self.node_identities)
4319
+
4320
+ G = nx.Graph()
4321
+ edge_set = set()
4322
+
4323
+ # Step 1: Connect nodes within same exact identity
4324
+ for identity, nodes in identity_to_nodes.items():
4325
+ if len(nodes) <= 1:
4326
+ continue
4327
+
4328
+ # Each node chooses n random neighbors from its identity group
4329
+ for node in nodes:
4330
+ available = [other for other in nodes if other != node]
4331
+ num_to_choose = min(n, len(available))
4332
+ neighbors = random.sample(available, num_to_choose)
4333
+
4334
+ for neighbor in neighbors:
4335
+ edge = tuple(sorted([node, neighbor]))
4336
+ edge_set.add(edge)
4337
+
4338
+ # Step 2: For list-like identities, connect across groups with shared sub-identities
4339
+ for identity, nodes in identity_to_nodes.items():
4340
+ if identity.startswith('['):
4341
+ try:
4342
+ sub_identities = ast.literal_eval(identity)
4343
+
4344
+ # For each sub-identity in this list-like identity
4345
+ for sub_id in sub_identities:
4346
+ # Find all OTHER identity groups that contain this sub-identity
4347
+ for other_identity, other_nodes in identity_to_nodes.items():
4348
+ if other_identity == identity:
4349
+ continue # Skip connecting to same exact identity (already done in Step 1)
4350
+
4351
+ # Check if other_identity contains sub_id
4352
+ contains_sub_id = False
4353
+
4354
+ if other_identity.startswith('['):
4355
+ try:
4356
+ other_sub_ids = ast.literal_eval(other_identity)
4357
+ if sub_id in other_sub_ids:
4358
+ contains_sub_id = True
4359
+ except (ValueError, SyntaxError):
4360
+ pass
4361
+ elif other_identity == sub_id:
4362
+ # Single identity that matches our sub-identity
4363
+ contains_sub_id = True
4364
+
4365
+ if contains_sub_id:
4366
+ # Each node from current identity connects to n nodes from other_identity
4367
+ for node in nodes:
4368
+ num_to_choose = min(n, len(other_nodes))
4369
+ if num_to_choose > 0:
4370
+ neighbors = random.sample(other_nodes, num_to_choose)
4371
+
4372
+ for neighbor in neighbors:
4373
+ edge = tuple(sorted([node, neighbor]))
4374
+ edge_set.add(edge)
4375
+
4376
+ except (ValueError, SyntaxError):
4377
+ pass # Not a valid list, treat as already handled in Step 1
4378
+
4379
+ G.add_edges_from(edge_set)
4380
+ self.network = G
4381
+
4382
+
4383
+
4384
+
4385
+
4040
4386
  def calculate_all(self, nodes, edges, xy_scale = 1, z_scale = 1, down_factor = None, search = None, diledge = None, inners = True, remove_trunk = 0, ignore_search_region = False, other_nodes = None, label_nodes = True, directory = None, GPU = True, fast_dil = True, skeletonize = False, GPU_downsample = None):
4041
4387
  """
4042
4388
  Method to calculate and save to mem all properties of a Network_3D object. In general, after initializing a Network_3D object, this method should be called on the node and edge masks that will be used to calculate the network.
@@ -5232,32 +5578,35 @@ class Network_3D:
5232
5578
  new_list.append(centroid)
5233
5579
 
5234
5580
  else:
5235
-
5236
5581
  if mode == 1:
5237
-
5238
5582
  legal = self.edges != 0
5239
-
5240
5583
  elif mode == 2:
5241
-
5242
5584
  legal = self.network_overlay != 0
5243
-
5244
5585
  elif mode == 3:
5245
-
5246
5586
  legal = self.id_overlay != 0
5247
-
5248
5587
  if self.nodes is None:
5249
-
5250
5588
  temp_array = proximity.populate_array(self.node_centroids, shape = legal.shape)
5251
5589
  else:
5252
5590
  temp_array = self.nodes
5253
-
5254
5591
  if dim == 2:
5255
5592
  volume = np.count_nonzero(legal) * self.xy_scale**2
5593
+ # Pad in x and y dimensions (assuming shape is [y, x])
5594
+ legal = np.pad(legal, pad_width=1, mode='constant', constant_values=0)
5256
5595
  else:
5257
5596
  volume = np.count_nonzero(legal) * self.z_scale * self.xy_scale**2
5597
+ # Pad in x, y, and z dimensions (assuming shape is [z, y, x])
5598
+ legal = np.pad(legal, pad_width=1, mode='constant', constant_values=0)
5599
+
5258
5600
  print(f"Using {volume} for the volume measurement (Volume of provided mask as scaled by xy and z scaling)")
5259
-
5260
- legal = smart_dilate.compute_distance_transform_distance(legal, sampling = [self.z_scale, self.xy_scale, self.xy_scale]) # Get true distances
5601
+
5602
+ # Compute distance transform on padded array
5603
+ legal = smart_dilate.compute_distance_transform_distance(legal, sampling = [self.z_scale, self.xy_scale, self.xy_scale])
5604
+
5605
+ # Remove padding after distance transform
5606
+ if dim == 2:
5607
+ legal = legal[1:-1, 1:-1] # Remove padding from x and y dimensions
5608
+ else:
5609
+ legal = legal[1:-1, 1:-1, 1:-1] # Remove padding from x, y, and z dimensions
5261
5610
 
5262
5611
  max_avail = np.max(legal) # Most internal point
5263
5612
  min_legal = factor * max_avail # Values of stuff 25% within the tissue
@@ -5338,9 +5687,9 @@ class Network_3D:
5338
5687
 
5339
5688
 
5340
5689
 
5341
- def interactions(self, search = 0, cores = 0, resize = None, save = False, skele = False, fastdil = False):
5690
+ def interactions(self, search = 0, cores = 0, resize = None, save = False, skele = False, length = False, auto = True, fastdil = False):
5342
5691
 
5343
- return morphology.quantify_edge_node(self._nodes, self._edges, search = search, xy_scale = self._xy_scale, z_scale = self._z_scale, cores = cores, resize = resize, save = save, skele = skele, fastdil = fastdil)
5692
+ return morphology.quantify_edge_node(self._nodes, self._edges, search = search, xy_scale = self._xy_scale, z_scale = self._z_scale, cores = cores, resize = resize, save = save, skele = skele, length = length, auto = auto, fastdil = fastdil)
5344
5693
 
5345
5694
 
5346
5695
 
@@ -5511,7 +5860,7 @@ class Network_3D:
5511
5860
  neighborhoods.visualize_cluster_composition_umap(self.node_centroids, None, id_dictionary = self.node_identities, graph_label = "Node ID", title = 'UMAP Visualization of Node Centroids')
5512
5861
 
5513
5862
 
5514
- def identity_umap(self, data):
5863
+ def identity_umap(self, data, mode = 0):
5515
5864
 
5516
5865
  try:
5517
5866
 
@@ -5531,16 +5880,18 @@ class Network_3D:
5531
5880
  else:
5532
5881
  del umap_dict[item]
5533
5882
 
5534
- from scipy.stats import zscore
5883
+ #from scipy.stats import zscore
5535
5884
 
5536
5885
  # Z-score normalize each marker (column)
5537
- for key in umap_dict:
5538
- umap_dict[key] = zscore(umap_dict[key])
5539
-
5886
+ #for key in umap_dict:
5887
+ #umap_dict[key] = zscore(umap_dict[key])
5540
5888
 
5541
5889
  from . import neighborhoods
5542
5890
 
5543
- neighborhoods.visualize_cluster_composition_umap(umap_dict, None, id_dictionary = neighbor_classes, graph_label = "Node ID", title = 'UMAP Visualization of Node Identities by Z-Score')
5891
+ if mode == 0:
5892
+ neighborhoods.visualize_cluster_composition_umap(umap_dict, None, id_dictionary = neighbor_classes, graph_label = "Node ID", title = 'UMAP Visualization of Node Identities by Z-Score')
5893
+ else:
5894
+ neighborhoods.visualize_cluster_composition_umap(umap_dict, None, id_dictionary = neighbor_classes, graph_label = "Node ID", title = 'UMAP Visualization of Node Identities by Z-Score', neighborhoods = self.communities, original_communities = self.communities)
5544
5895
 
5545
5896
  except Exception as e:
5546
5897
  import traceback
@@ -5659,7 +6010,7 @@ class Network_3D:
5659
6010
  neighbor_group[com] = neighbors[node]
5660
6011
  except:
5661
6012
  neighbor_group[com] = 0
5662
- neighborhoods.visualize_cluster_composition_umap(umap_dict, id_set, neighborhoods = neighbor_group)
6013
+ neighborhoods.visualize_cluster_composition_umap(umap_dict, id_set, neighborhoods = neighbor_group, original_communities = neighbors)
5663
6014
  elif label == 1:
5664
6015
  neighborhoods.visualize_cluster_composition_umap(umap_dict, id_set, label = True)
5665
6016
  else:
@@ -5671,6 +6022,19 @@ class Network_3D:
5671
6022
  return output, id_set
5672
6023
 
5673
6024
 
6025
+ def group_nodes_by_intensity(self, data, count = None):
6026
+
6027
+ from . import neighborhoods
6028
+
6029
+ clusters = neighborhoods.cluster_arrays(data, count, seed = 42)
6030
+
6031
+ coms = {}
6032
+
6033
+ for i, cluster in enumerate(clusters):
6034
+ coms[i + 1] = cluster
6035
+
6036
+ self.communities = revert_dict(coms)
6037
+
5674
6038
  def assign_neighborhoods(self, seed, count, limit = None, prev_coms = None, proportional = False, mode = 0):
5675
6039
 
5676
6040
  from . import neighborhoods
@@ -5821,13 +6185,6 @@ class Network_3D:
5821
6185
 
5822
6186
  def community_cells(self, size = 32, xy_scale = 1, z_scale = 1):
5823
6187
 
5824
- def revert_dict(d):
5825
- inverted = {}
5826
- for key, value_list in d.items():
5827
- for value in value_list:
5828
- inverted[value] = key
5829
- return inverted
5830
-
5831
6188
  size_x = int(size * xy_scale)
5832
6189
  size_z = int(size * z_scale)
5833
6190
 
@@ -5992,57 +6349,94 @@ class Network_3D:
5992
6349
  self.node_identities = modify_dict
5993
6350
 
5994
6351
 
5995
- 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):
5996
-
5997
- def distribute_points_uniformly(n, shape, z_scale, xy_scale, num, is_2d=False):
6352
+ 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, mask = None):
5998
6353
 
6354
+ def distribute_points_uniformly(n, shape, z_scale, xy_scale, num, is_2d=False, mask=None):
5999
6355
  from scipy.spatial import KDTree
6000
-
6001
6356
  if n <= 1:
6002
6357
  return 0
6003
-
6004
- # Calculate total positions and sampling step
6005
- total_positions = np.prod(shape)
6006
- if n >= total_positions:
6007
- # If we want more points than positions, just return scaled unit distance
6008
- return xy_scale if is_2d else min(z_scale, xy_scale)
6009
-
6010
- # Create uniformly spaced indices
6011
- indices = np.linspace(0, total_positions - 1, n, dtype=int)
6012
6358
 
6013
- # Convert flat indices to coordinates
6014
- coords = []
6015
- for idx in indices:
6016
- coord = np.unravel_index(idx, shape)
6359
+ if mask is not None:
6360
+ # Handle mask-based distribution
6361
+ # Find all valid positions where mask is True
6362
+ valid_positions = np.where(mask)
6363
+ total_valid_positions = len(valid_positions[0])
6364
+
6365
+ if total_valid_positions == 0:
6366
+ raise ValueError("No valid positions found in mask")
6367
+
6368
+ if n >= total_valid_positions:
6369
+ # If we want more points than valid positions, return scaled unit distance
6370
+ return xy_scale if is_2d else min(z_scale, xy_scale)
6371
+
6372
+ # Create uniformly spaced indices within valid positions
6373
+ valid_indices = np.linspace(0, total_valid_positions - 1, n, dtype=int)
6374
+
6375
+ # Convert to coordinates and apply scaling
6376
+ coords = []
6377
+ for idx in valid_indices:
6378
+ if len(shape) == 3:
6379
+ coord = (valid_positions[0][idx], valid_positions[1][idx], valid_positions[2][idx])
6380
+ scaled_coord = [coord[0] * z_scale, coord[1] * xy_scale, coord[2] * xy_scale]
6381
+ elif len(shape) == 2:
6382
+ coord = (valid_positions[0][idx], valid_positions[1][idx])
6383
+ scaled_coord = [coord[0] * xy_scale, coord[1] * xy_scale]
6384
+ coords.append(scaled_coord)
6385
+
6386
+ coords = np.array(coords)
6387
+
6388
+ # Find a good query point (closest to center of valid region)
6017
6389
  if len(shape) == 3:
6018
- # Apply scaling: [z, y, x] with respective scales
6019
- scaled_coord = [coord[0] * z_scale, coord[1] * xy_scale, coord[2] * xy_scale]
6020
- elif len(shape) == 2:
6021
- # Apply scaling: [y, x] with xy_scale
6022
- scaled_coord = [coord[0] * xy_scale, coord[1] * xy_scale]
6023
- coords.append(scaled_coord)
6024
-
6025
- coords = np.array(coords)
6390
+ center_pos = [np.mean(valid_positions[0]) * z_scale,
6391
+ np.mean(valid_positions[1]) * xy_scale,
6392
+ np.mean(valid_positions[2]) * xy_scale]
6393
+ else:
6394
+ center_pos = [np.mean(valid_positions[0]) * xy_scale,
6395
+ np.mean(valid_positions[1]) * xy_scale]
6396
+
6397
+ # Find point closest to center of valid region
6398
+ center_distances = np.sum((coords - center_pos)**2, axis=1)
6399
+ middle_idx = np.argmin(center_distances)
6400
+ query_point = coords[middle_idx]
6401
+
6402
+ else:
6403
+ # Original behavior when no mask is provided
6404
+ total_positions = np.prod(shape)
6405
+ if n >= total_positions:
6406
+ return xy_scale if is_2d else min(z_scale, xy_scale)
6407
+
6408
+ # Create uniformly spaced indices
6409
+ indices = np.linspace(0, total_positions - 1, n, dtype=int)
6410
+
6411
+ # Convert flat indices to coordinates
6412
+ coords = []
6413
+ for idx in indices:
6414
+ coord = np.unravel_index(idx, shape)
6415
+ if len(shape) == 3:
6416
+ scaled_coord = [coord[0] * z_scale, coord[1] * xy_scale, coord[2] * xy_scale]
6417
+ elif len(shape) == 2:
6418
+ scaled_coord = [coord[0] * xy_scale, coord[1] * xy_scale]
6419
+ coords.append(scaled_coord)
6420
+
6421
+ coords = np.array(coords)
6422
+
6423
+ # Pick a point near the middle of the array
6424
+ middle_idx = len(coords) // 2
6425
+ query_point = coords[middle_idx]
6026
6426
 
6027
6427
  # Build KDTree
6028
6428
  tree = KDTree(coords)
6029
6429
 
6030
- # Pick a point near the middle of the array
6031
- middle_idx = len(coords) // 2
6032
- query_point = coords[middle_idx]
6033
-
6034
6430
  # Find the num+1 nearest neighbors (including the point itself)
6035
6431
  distances, indices = tree.query(query_point, k=num+1)
6036
6432
 
6037
6433
  # Exclude the point itself (distance 0) and get the actual neighbors
6038
6434
  neighbor_distances = distances[1:num+1]
6039
-
6040
6435
  if num == n:
6041
6436
  neighbor_distances[-1] = neighbor_distances[-2]
6042
6437
 
6043
6438
  avg_distance = np.mean(neighbor_distances)
6044
6439
 
6045
-
6046
6440
  return avg_distance
6047
6441
 
6048
6442
  do_borders = not centroids
@@ -6064,14 +6458,25 @@ class Network_3D:
6064
6458
 
6065
6459
  for node, iden in self.node_identities.items():
6066
6460
 
6067
- if iden == root:
6461
+ if iden == root: # Standard behavior
6068
6462
 
6069
6463
  root_set.append(node)
6070
6464
 
6071
- elif (iden == targ) or (targ == 'All Others (Excluding Self)'):
6465
+ elif '[' in iden and root != "All (Excluding Targets)": # For multiple nodes
6466
+ if root in iden:
6467
+ root_set.append(node)
6468
+
6469
+ elif (iden == targ) or (targ == 'All Others (Excluding Self)'): # The other group
6072
6470
 
6073
6471
  compare_set.append(node)
6074
6472
 
6473
+ elif '[' in iden: # The other group, for multiple nodes
6474
+ if targ in iden:
6475
+ compare_set.append(node)
6476
+
6477
+ elif root == "All (Excluding Targets)": # If not assigned to the other group but the comprehensive root option is used
6478
+ root_set.append(node)
6479
+
6075
6480
  if root == targ:
6076
6481
 
6077
6482
  compare_set = root_set
@@ -6094,11 +6499,11 @@ class Network_3D:
6094
6499
  if heatmap:
6095
6500
  root_set = []
6096
6501
  compare_set = []
6097
- if root is None:
6098
-
6099
- root_set = list(self.node_centroids.keys())
6502
+ if root is None and not do_borders:
6100
6503
  compare_set = root_set
6101
- else:
6504
+ if not do_borders:
6505
+ root_set = list(self.node_centroids.keys())
6506
+ elif self.node_identities is not None:
6102
6507
  for node, iden in self.node_identities.items():
6103
6508
 
6104
6509
  if iden == root:
@@ -6126,7 +6531,8 @@ class Network_3D:
6126
6531
  targ = [targ]
6127
6532
 
6128
6533
  compare_set_neigh = approx_boundaries(self.nodes, targ, self.node_identities, keep_labels = False)
6129
- 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)
6534
+
6535
+ 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)
6130
6536
 
6131
6537
  if quant:
6132
6538
  try:
@@ -6168,7 +6574,15 @@ class Network_3D:
6168
6574
  else:
6169
6575
  is_2d = False
6170
6576
 
6171
- pred = distribute_points_uniformly(len(compare_set), bounds, self.z_scale, self.xy_scale, num = num, is_2d = is_2d)
6577
+ if root_set == []:
6578
+ avail_nodes = np.unique(self.nodes)
6579
+ compare_set = list(avail_nodes)
6580
+ if 0 in compare_set:
6581
+ del compare_set[0]
6582
+ root_set = compare_set
6583
+ elif compare_set == []:
6584
+ compare_set = root_set
6585
+ pred = distribute_points_uniformly(len(compare_set), bounds, self.z_scale, self.xy_scale, num = num, is_2d = is_2d, mask = mask)
6172
6586
 
6173
6587
  node_intensity = {}
6174
6588
  import math
@@ -6176,7 +6590,6 @@ class Network_3D:
6176
6590
 
6177
6591
  for node in root_set:
6178
6592
  node_intensity[node] = math.log(pred/output[node])
6179
- #print(output[node])
6180
6593
  node_centroids[node] = self.node_centroids[node]
6181
6594
 
6182
6595
  if numpy: