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.
@@ -4,7 +4,7 @@ from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QG
4
4
  QHBoxLayout, QSlider, QMenuBar, QMenu, QDialog,
5
5
  QFormLayout, QLineEdit, QPushButton, QFileDialog,
6
6
  QLabel, QComboBox, QMessageBox, QTableView, QInputDialog,
7
- QMenu, QTabWidget, QGroupBox)
7
+ QMenu, QTabWidget, QGroupBox, QCheckBox)
8
8
  from PyQt6.QtCore import (QPoint, Qt, QAbstractTableModel, QTimer, QThread, pyqtSignal, QObject, QCoreApplication, QEvent, QEventLoop)
9
9
  import numpy as np
10
10
  import time
@@ -36,6 +36,7 @@ from threading import Lock
36
36
  from scipy import ndimage
37
37
  import os
38
38
  from . import painting
39
+ from . import stats as net_stats
39
40
 
40
41
 
41
42
 
@@ -183,6 +184,12 @@ class ImageViewerWindow(QMainWindow):
183
184
  3: None
184
185
  }
185
186
 
187
+ self.branch_dict = {
188
+ 0: None,
189
+ 1: None
190
+
191
+ }
192
+
186
193
  self.original_shape = None #For undoing resamples
187
194
 
188
195
  # Create control panel
@@ -425,8 +432,6 @@ class ImageViewerWindow(QMainWindow):
425
432
  self.canvas.mpl_connect('button_release_event', self.on_mouse_release)
426
433
  self.canvas.mpl_connect('motion_notify_event', self.on_mouse_move)
427
434
 
428
- #self.canvas.mpl_connect('button_press_event', self.on_mouse_click)
429
-
430
435
  # Initialize measurement tracking
431
436
  self.measurement_points = [] # List to store point pairs
432
437
  self.angle_measurements = [] # NEW: List to store angle trios
@@ -462,9 +467,8 @@ class ImageViewerWindow(QMainWindow):
462
467
  self.last_paint_pos = None
463
468
 
464
469
  self.resume = False
465
-
466
- self.hold_update = False
467
470
  self._first_pan_done = False
471
+ self.thresh_window_ref = None
468
472
 
469
473
 
470
474
  def load_file(self):
@@ -511,12 +515,8 @@ class ImageViewerWindow(QMainWindow):
511
515
  data = df.iloc[:, 0].tolist() # First column as list
512
516
  value = None
513
517
 
514
- self.format_for_upperright_table(
515
- data=data,
516
- metric=metric,
517
- value=value,
518
- title=title
519
- )
518
+ df = self.format_for_upperright_table(data=data, metric=metric, value=value, title=title)
519
+ return df
520
520
  else:
521
521
  # Multiple columns: create dictionary as before
522
522
  # First column header (for metric parameter)
@@ -542,12 +542,8 @@ class ImageViewerWindow(QMainWindow):
542
542
  value = value[0]
543
543
 
544
544
  # Call the parent method
545
- self.format_for_upperright_table(
546
- data=data_dict,
547
- metric=metric,
548
- value=value,
549
- title=title
550
- )
545
+ df = self.format_for_upperright_table(data=data_dict, metric=metric, value=value, title=title)
546
+ return df
551
547
 
552
548
  QMessageBox.information(
553
549
  self,
@@ -887,6 +883,21 @@ class ImageViewerWindow(QMainWindow):
887
883
  elif self.scroll_direction > 0 and new_value <= self.slice_slider.maximum():
888
884
  self.slice_slider.setValue(new_value)
889
885
 
886
+
887
+ def confirm_mini_thresh(self):
888
+
889
+ if self.shape[0] * self.shape[1] * self.shape[2] > self.mini_thresh:
890
+ self.mini_overlay = True
891
+ return True
892
+ else:
893
+ return False
894
+
895
+ def evaluate_mini(self, mode = 'nodes'):
896
+ if self.confirm_mini_thresh():
897
+ self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
898
+ else:
899
+ self.create_highlight_overlay(node_indices=self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
900
+
890
901
  def create_highlight_overlay(self, node_indices=None, edge_indices=None, overlay1_indices = None, overlay2_indices = None, bounds = False):
891
902
  """
892
903
  Create a binary overlay highlighting specific nodes and/or edges using parallel processing.
@@ -1012,13 +1023,13 @@ class ImageViewerWindow(QMainWindow):
1012
1023
 
1013
1024
  # Combine results
1014
1025
  if node_overlay is not None:
1015
- self.highlight_overlay = np.maximum(self.highlight_overlay, node_overlay)
1026
+ self.highlight_overlay = np.maximum(self.highlight_overlay, node_overlay).astype(np.uint8)
1016
1027
  if edge_overlay is not None:
1017
- self.highlight_overlay = np.maximum(self.highlight_overlay, edge_overlay)
1028
+ self.highlight_overlay = np.maximum(self.highlight_overlay, edge_overlay).astype(np.uint8)
1018
1029
  if overlay1_overlay is not None:
1019
- self.highlight_overlay = np.maximum(self.highlight_overlay, overlay1_overlay)
1030
+ self.highlight_overlay = np.maximum(self.highlight_overlay, overlay1_overlay).astype(np.uint8)
1020
1031
  if overlay2_overlay is not None:
1021
- self.highlight_overlay = np.maximum(self.highlight_overlay, overlay2_overlay)
1032
+ self.highlight_overlay = np.maximum(self.highlight_overlay, overlay2_overlay).astype(np.uint8)
1022
1033
 
1023
1034
  # Update display
1024
1035
  self.update_display(preserve_zoom=(current_xlim, current_ylim))
@@ -1027,6 +1038,8 @@ class ImageViewerWindow(QMainWindow):
1027
1038
 
1028
1039
  """Highlight overlay generation method specific for the segmenter interactive mode"""
1029
1040
 
1041
+ self.mini_overlay_data = None
1042
+ self.highlight_overlay = None
1030
1043
 
1031
1044
  def process_chunk_bounds(chunk_data, indices_to_check):
1032
1045
  """Process a single chunk of the array to create highlight mask"""
@@ -1106,6 +1119,30 @@ class ImageViewerWindow(QMainWindow):
1106
1119
  current_ylim = self.ax.get_ylim()
1107
1120
  self.update_display_pan_mode(current_xlim, current_ylim)
1108
1121
 
1122
+ if my_network.network is not None:
1123
+ try:
1124
+ if self.active_channel == 0:
1125
+
1126
+ # Get the existing DataFrame from the model
1127
+ original_df = self.network_table.model()._data
1128
+
1129
+ # Create mask for rows where one column is any original node AND the other column is any neighbor
1130
+ mask = (
1131
+ (original_df.iloc[:, 0].isin(indices)) &
1132
+ (original_df.iloc[:, 1].isin(indices)))
1133
+
1134
+ # Filter the DataFrame to only include direct connections
1135
+ filtered_df = original_df[mask].copy()
1136
+
1137
+ # Create new model with filtered DataFrame and update selection table
1138
+ new_model = PandasModel(filtered_df)
1139
+ self.selection_table.setModel(new_model)
1140
+
1141
+ # Switch to selection table
1142
+ self.selection_button.click()
1143
+ except:
1144
+ pass
1145
+
1109
1146
 
1110
1147
 
1111
1148
  def create_mini_overlay(self, node_indices = None, edge_indices = None):
@@ -1265,7 +1302,9 @@ class ImageViewerWindow(QMainWindow):
1265
1302
 
1266
1303
  if my_network.node_identities is not None:
1267
1304
  identity_menu = QMenu("Show Identity", self)
1268
- for item in set(my_network.node_identities.values()):
1305
+ idens = list(set(my_network.node_identities.values()))
1306
+ idens.sort()
1307
+ for item in idens:
1269
1308
  show_identity = identity_menu.addAction(f"ID: {item}")
1270
1309
  show_identity.triggered.connect(lambda checked, item=item: self.handle_show_identities(sort=item))
1271
1310
  context_menu.addMenu(identity_menu)
@@ -1274,6 +1313,9 @@ class ImageViewerWindow(QMainWindow):
1274
1313
  select_nodes = select_all_menu.addAction("Nodes")
1275
1314
  select_both = select_all_menu.addAction("Nodes + Edges")
1276
1315
  select_edges = select_all_menu.addAction("Edges")
1316
+ select_net_nodes = select_all_menu.addAction("Nodes in Network")
1317
+ select_net_both = select_all_menu.addAction("Nodes + Edges in Network")
1318
+ select_net_edges = select_all_menu.addAction("Edges in Network")
1277
1319
  context_menu.addMenu(select_all_menu)
1278
1320
 
1279
1321
  if len(self.clicked_values['nodes']) > 0 or len(self.clicked_values['edges']) > 0:
@@ -1297,32 +1339,40 @@ class ImageViewerWindow(QMainWindow):
1297
1339
  # Create measurement submenu
1298
1340
  measure_menu = context_menu.addMenu("Measurements")
1299
1341
 
1300
- # Distance measurement options
1301
1342
  distance_menu = measure_menu.addMenu("Distance")
1302
1343
  if self.current_point is None:
1303
1344
  show_point_menu = distance_menu.addAction("Place First Point")
1304
1345
  show_point_menu.triggered.connect(
1305
1346
  lambda: self.place_distance_point(x_idx, y_idx, self.current_slice))
1306
- else:
1347
+ elif (self.current_point is not None and
1348
+ hasattr(self, 'measurement_mode') and
1349
+ self.measurement_mode == "distance"):
1307
1350
  show_point_menu = distance_menu.addAction("Place Second Point")
1308
1351
  show_point_menu.triggered.connect(
1309
1352
  lambda: self.place_distance_point(x_idx, y_idx, self.current_slice))
1310
-
1353
+
1311
1354
  # Angle measurement options
1312
1355
  angle_menu = measure_menu.addMenu("Angle")
1313
1356
  if self.current_point is None:
1314
1357
  angle_first = angle_menu.addAction("Place First Point (A)")
1315
1358
  angle_first.triggered.connect(
1316
1359
  lambda: self.place_angle_point(x_idx, y_idx, self.current_slice))
1317
- elif self.current_second_point is None:
1360
+ elif (self.current_point is not None and
1361
+ self.current_second_point is None and
1362
+ hasattr(self, 'measurement_mode') and
1363
+ self.measurement_mode == "angle"):
1318
1364
  angle_second = angle_menu.addAction("Place Second Point (B - Vertex)")
1319
1365
  angle_second.triggered.connect(
1320
1366
  lambda: self.place_angle_point(x_idx, y_idx, self.current_slice))
1321
- else:
1367
+ elif (self.current_point is not None and
1368
+ self.current_second_point is not None and
1369
+ hasattr(self, 'measurement_mode') and
1370
+ self.measurement_mode == "angle"):
1322
1371
  angle_third = angle_menu.addAction("Place Third Point (C)")
1323
1372
  angle_third.triggered.connect(
1324
1373
  lambda: self.place_angle_point(x_idx, y_idx, self.current_slice))
1325
1374
 
1375
+
1326
1376
  show_remove_menu = measure_menu.addAction("Remove All Measurements")
1327
1377
  show_remove_menu.triggered.connect(self.handle_remove_all_measurements)
1328
1378
 
@@ -1340,6 +1390,9 @@ class ImageViewerWindow(QMainWindow):
1340
1390
  select_nodes.triggered.connect(lambda: self.handle_select_all(edges = False, nodes = True))
1341
1391
  select_both.triggered.connect(lambda: self.handle_select_all(edges = True))
1342
1392
  select_edges.triggered.connect(lambda: self.handle_select_all(edges = True, nodes = False))
1393
+ select_net_nodes.triggered.connect(lambda: self.handle_select_all(edges = False, nodes = True, network = True))
1394
+ select_net_both.triggered.connect(lambda: self.handle_select_all(edges = True, network = True))
1395
+ select_net_edges.triggered.connect(lambda: self.handle_select_all(edges = True, nodes = False, network = True))
1343
1396
  if self.highlight_overlay is not None or self.mini_overlay_data is not None:
1344
1397
  highlight_select = context_menu.addAction("Add highlight in network selection")
1345
1398
  highlight_select.triggered.connect(self.handle_highlight_select)
@@ -1350,15 +1403,22 @@ class ImageViewerWindow(QMainWindow):
1350
1403
  except IndexError:
1351
1404
  pass
1352
1405
 
1353
-
1354
1406
  def place_distance_point(self, x, y, z):
1355
1407
  """Place a measurement point for distance measurement."""
1356
1408
  if self.current_point is None:
1357
1409
  # This is the first point
1358
1410
  self.current_point = (x, y, z)
1359
- self.ax.plot(x, y, 'yo', markersize=8)
1360
- self.ax.text(x, y+5, f"D{self.current_pair_index}",
1411
+
1412
+ # Create and store the artists
1413
+ pt = self.ax.plot(x, y, 'yo', markersize=8)[0]
1414
+ txt = self.ax.text(x, y+5, f"D{self.current_pair_index}",
1361
1415
  color='yellow', ha='center', va='bottom')
1416
+
1417
+ # Add to measurement_artists so they can be managed by update_display
1418
+ if not hasattr(self, 'measurement_artists'):
1419
+ self.measurement_artists = []
1420
+ self.measurement_artists.extend([pt, txt])
1421
+
1362
1422
  self.canvas.draw()
1363
1423
  self.measurement_mode = "distance"
1364
1424
  else:
@@ -1372,21 +1432,28 @@ class ImageViewerWindow(QMainWindow):
1372
1432
  ((z2-z1)*my_network.z_scale)**2)
1373
1433
  distance2 = np.sqrt(((x2-x1))**2 + ((y2-y1))**2 + ((z2-z1))**2)
1374
1434
 
1375
- # Store the point pair
1435
+ # Store the point pair with type indicator
1376
1436
  self.measurement_points.append({
1377
1437
  'pair_index': self.current_pair_index,
1378
1438
  'point1': self.current_point,
1379
1439
  'point2': (x2, y2, z2),
1380
1440
  'distance': distance,
1381
- 'distance2': distance2
1441
+ 'distance2': distance2,
1442
+ 'type': 'distance' # Added type tracking
1382
1443
  })
1383
1444
 
1384
- # Draw second point and line
1385
- self.ax.plot(x2, y2, 'yo', markersize=8)
1386
- self.ax.text(x2, y2+5, f"D{self.current_pair_index}",
1445
+ # Draw second point and line, storing the artists
1446
+ pt2 = self.ax.plot(x2, y2, 'yo', markersize=8)[0]
1447
+ txt2 = self.ax.text(x2, y2+5, f"D{self.current_pair_index}",
1387
1448
  color='yellow', ha='center', va='bottom')
1449
+
1450
+ # Add to measurement_artists
1451
+ self.measurement_artists.extend([pt2, txt2])
1452
+
1388
1453
  if z1 == z2: # Only draw line if points are on same slice
1389
- self.ax.plot([x1, x2], [y1, y2], 'r--', alpha=0.5)
1454
+ line = self.ax.plot([x1, x2], [y1, y2], 'r--', alpha=0.5)[0]
1455
+ self.measurement_artists.append(line)
1456
+
1390
1457
  self.canvas.draw()
1391
1458
 
1392
1459
  # Update measurement display
@@ -1399,12 +1466,19 @@ class ImageViewerWindow(QMainWindow):
1399
1466
 
1400
1467
  def place_angle_point(self, x, y, z):
1401
1468
  """Place a measurement point for angle measurement."""
1469
+ if not hasattr(self, 'measurement_artists'):
1470
+ self.measurement_artists = []
1471
+
1402
1472
  if self.current_point is None:
1403
1473
  # First point (A)
1404
1474
  self.current_point = (x, y, z)
1405
- self.ax.plot(x, y, 'go', markersize=8)
1406
- self.ax.text(x, y+5, f"A{self.current_trio_index}",
1475
+
1476
+ # Create and store artists
1477
+ pt = self.ax.plot(x, y, 'go', markersize=8)[0]
1478
+ txt = self.ax.text(x, y+5, f"A{self.current_trio_index}",
1407
1479
  color='green', ha='center', va='bottom')
1480
+ self.measurement_artists.extend([pt, txt])
1481
+
1408
1482
  self.canvas.draw()
1409
1483
  self.measurement_mode = "angle"
1410
1484
 
@@ -1413,13 +1487,16 @@ class ImageViewerWindow(QMainWindow):
1413
1487
  self.current_second_point = (x, y, z)
1414
1488
  x1, y1, z1 = self.current_point
1415
1489
 
1416
- self.ax.plot(x, y, 'go', markersize=8)
1417
- self.ax.text(x, y+5, f"B{self.current_trio_index}",
1490
+ # Create and store artists
1491
+ pt = self.ax.plot(x, y, 'go', markersize=8)[0]
1492
+ txt = self.ax.text(x, y+5, f"B{self.current_trio_index}",
1418
1493
  color='green', ha='center', va='bottom')
1494
+ self.measurement_artists.extend([pt, txt])
1419
1495
 
1420
1496
  # Draw line from A to B
1421
1497
  if z1 == z:
1422
- self.ax.plot([x1, x], [y1, y], 'g--', alpha=0.7)
1498
+ line = self.ax.plot([x1, x], [y1, y], 'g--', alpha=0.7)[0]
1499
+ self.measurement_artists.append(line)
1423
1500
  self.canvas.draw()
1424
1501
 
1425
1502
  else:
@@ -1442,7 +1519,7 @@ class ImageViewerWindow(QMainWindow):
1442
1519
  **angle_data
1443
1520
  })
1444
1521
 
1445
- # Also add the two distances as separate pairs
1522
+ # Also add the two distances as separate pairs with type indicator
1446
1523
  dist_ab = np.sqrt(((x2-x1)*my_network.xy_scale)**2 +
1447
1524
  ((y2-y1)*my_network.xy_scale)**2 +
1448
1525
  ((z2-z1)*my_network.z_scale)**2)
@@ -1459,24 +1536,28 @@ class ImageViewerWindow(QMainWindow):
1459
1536
  'point1': (x1, y1, z1),
1460
1537
  'point2': (x2, y2, z2),
1461
1538
  'distance': dist_ab,
1462
- 'distance2': dist_ab_voxel
1539
+ 'distance2': dist_ab_voxel,
1540
+ 'type': 'angle' # Added type tracking
1463
1541
  },
1464
1542
  {
1465
1543
  'pair_index': f"B{self.current_trio_index}-C{self.current_trio_index}",
1466
1544
  'point1': (x2, y2, z2),
1467
1545
  'point2': (x3, y3, z3),
1468
1546
  'distance': dist_bc,
1469
- 'distance2': dist_bc_voxel
1547
+ 'distance2': dist_bc_voxel,
1548
+ 'type': 'angle' # Added type tracking
1470
1549
  }
1471
1550
  ])
1472
1551
 
1473
- # Draw third point and line
1474
- self.ax.plot(x3, y3, 'go', markersize=8)
1475
- self.ax.text(x3, y3+5, f"C{self.current_trio_index}",
1552
+ # Draw third point and line, storing artists
1553
+ pt3 = self.ax.plot(x3, y3, 'go', markersize=8)[0]
1554
+ txt3 = self.ax.text(x3, y3+5, f"C{self.current_trio_index}",
1476
1555
  color='green', ha='center', va='bottom')
1556
+ self.measurement_artists.extend([pt3, txt3])
1477
1557
 
1478
1558
  if z2 == z3: # Draw line from B to C if on same slice
1479
- self.ax.plot([x2, x3], [y2, y3], 'g--', alpha=0.7)
1559
+ line = self.ax.plot([x2, x3], [y2, y3], 'g--', alpha=0.7)[0]
1560
+ self.measurement_artists.append(line)
1480
1561
  self.canvas.draw()
1481
1562
 
1482
1563
  # Update measurement display
@@ -1488,6 +1569,7 @@ class ImageViewerWindow(QMainWindow):
1488
1569
  self.current_trio_index += 1
1489
1570
  self.measurement_mode = "angle"
1490
1571
 
1572
+
1491
1573
  def calculate_3d_angle(self, point_a, point_b, point_c):
1492
1574
  """Calculate 3D angle at vertex B between points A-B-C."""
1493
1575
  x1, y1, z1 = point_a
@@ -1651,28 +1733,12 @@ class ImageViewerWindow(QMainWindow):
1651
1733
  if edges:
1652
1734
  edge_indices = filtered_df.iloc[:, 2].unique().tolist()
1653
1735
  self.clicked_values['edges'] = edge_indices
1654
-
1655
- if self.channel_data[1].shape[0] * self.channel_data[1].shape[1] * self.channel_data[1].shape[2] > self.mini_thresh:
1656
- self.mini_overlay = True
1657
- self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
1658
- else:
1659
- self.create_highlight_overlay(
1660
- node_indices=self.clicked_values['nodes'],
1661
- edge_indices=self.clicked_values['edges']
1662
- )
1736
+ self.evaluate_mini(mode = 'edges')
1663
1737
  else:
1664
- if self.channel_data[0].shape[0] * self.channel_data[0].shape[1] * self.channel_data[0].shape[2] > self.mini_thresh:
1665
- self.mini_overlay = True
1666
- self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
1667
- else:
1668
- self.create_highlight_overlay(
1669
- node_indices=self.clicked_values['nodes'],
1670
- edge_indices = self.clicked_values['edges']
1671
- )
1672
-
1738
+ self.evaluate_mini()
1673
1739
 
1674
1740
  except Exception as e:
1675
- print(f"Error processing neighbors: {e}")
1741
+ print(f"Error showing neighbors: {e}")
1676
1742
 
1677
1743
 
1678
1744
  def handle_show_component(self, edges = False, nodes = True):
@@ -1743,23 +1809,10 @@ class ImageViewerWindow(QMainWindow):
1743
1809
  if edges:
1744
1810
  edge_indices = filtered_df.iloc[:, 2].unique().tolist()
1745
1811
  self.clicked_values['edges'] = edge_indices
1746
- if self.channel_data[1].shape[0] * self.channel_data[1].shape[1] * self.channel_data[1].shape[2] > self.mini_thresh:
1747
- self.mini_overlay = True
1748
- self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
1749
- else:
1750
- self.create_highlight_overlay(
1751
- node_indices=self.clicked_values['nodes'],
1752
- edge_indices=edge_indices
1753
- )
1812
+ self.evaluate_mini(mode = 'edges')
1754
1813
  else:
1755
- if self.channel_data[0].shape[0] * self.channel_data[0].shape[1] * self.channel_data[0].shape[2] > self.mini_thresh:
1756
- self.mini_overlay = True
1757
- self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
1758
- else:
1759
- self.create_highlight_overlay(
1760
- node_indices = self.clicked_values['nodes'],
1761
- edge_indices = self.clicked_values['edges']
1762
- )
1814
+ self.evaluate_mini()
1815
+
1763
1816
 
1764
1817
  except Exception as e:
1765
1818
 
@@ -1802,23 +1855,27 @@ class ImageViewerWindow(QMainWindow):
1802
1855
 
1803
1856
  nodes = list(set(nodes))
1804
1857
 
1805
- # Get the existing DataFrame from the model
1806
- original_df = self.network_table.model()._data
1858
+ try:
1807
1859
 
1808
- # Create mask for rows for nodes in question
1809
- mask = (
1810
- (original_df.iloc[:, 0].isin(nodes) & original_df.iloc[:, 1].isin(nodes))
1811
- )
1812
-
1813
- # Filter the DataFrame to only include direct connections
1814
- filtered_df = original_df[mask].copy()
1815
-
1816
- # Create new model with filtered DataFrame and update selection table
1817
- new_model = PandasModel(filtered_df)
1818
- self.selection_table.setModel(new_model)
1819
-
1820
- # Switch to selection table
1821
- self.selection_button.click()
1860
+ # Get the existing DataFrame from the model
1861
+ original_df = self.network_table.model()._data
1862
+
1863
+ # Create mask for rows for nodes in question
1864
+ mask = (
1865
+ (original_df.iloc[:, 0].isin(nodes) & original_df.iloc[:, 1].isin(nodes))
1866
+ )
1867
+
1868
+ # Filter the DataFrame to only include direct connections
1869
+ filtered_df = original_df[mask].copy()
1870
+
1871
+ # Create new model with filtered DataFrame and update selection table
1872
+ new_model = PandasModel(filtered_df)
1873
+ self.selection_table.setModel(new_model)
1874
+
1875
+ # Switch to selection table
1876
+ self.selection_button.click()
1877
+ except:
1878
+ pass
1822
1879
 
1823
1880
  if edges:
1824
1881
  edge_indices = filtered_df.iloc[:, 2].unique().tolist()
@@ -1951,7 +2008,7 @@ class ImageViewerWindow(QMainWindow):
1951
2008
  self.parent().toggle_channel(1)
1952
2009
  # Navigate to the Z-slice
1953
2010
  self.parent().slice_slider.setValue(int(centroid[0]))
1954
- print(f"Found edge {value} at Z-slice {centroid[0]}")
2011
+ print(f"Found edge {value} at [Z,Y,X] -> {centroid}")
1955
2012
 
1956
2013
  else:
1957
2014
  print(f"Edge {value} not found in centroids dictionary")
@@ -1987,9 +2044,9 @@ class ImageViewerWindow(QMainWindow):
1987
2044
  # Navigate to the Z-slice
1988
2045
  self.parent().slice_slider.setValue(int(centroid[0]))
1989
2046
  if mode == 0:
1990
- print(f"Found node {value} at Z-slice {centroid[0]}")
2047
+ print(f"Found node {value} at [Z,Y,X] -> {centroid}")
1991
2048
  elif mode == 2:
1992
- print(f"Found node {value} from community {com} at Z-slice {centroid[0]}")
2049
+ print(f"Found node {value} from community {com} at [Z,Y,X] -> {centroid}")
1993
2050
 
1994
2051
 
1995
2052
  else:
@@ -2020,12 +2077,15 @@ class ImageViewerWindow(QMainWindow):
2020
2077
 
2021
2078
 
2022
2079
 
2023
- def handle_select_all(self, nodes = True, edges = False):
2080
+ def handle_select_all(self, nodes = True, edges = False, network = False):
2024
2081
 
2025
2082
  try:
2026
2083
 
2027
2084
  if nodes:
2028
- nodes = list(np.unique(my_network.nodes))
2085
+ if not network:
2086
+ nodes = list(np.unique(my_network.nodes))
2087
+ else:
2088
+ nodes = list(set(my_network.network_lists[0] + my_network.network_lists[1]))
2029
2089
  if nodes[0] == 0:
2030
2090
  del nodes[0]
2031
2091
  num = (self.channel_data[0].shape[0] * self.channel_data[0].shape[1] * self.channel_data[0].shape[2])
@@ -2033,7 +2093,10 @@ class ImageViewerWindow(QMainWindow):
2033
2093
  else:
2034
2094
  nodes = []
2035
2095
  if edges:
2036
- edges = list(np.unique(my_network.edges))
2096
+ if not network:
2097
+ edges = list(np.unique(my_network.edges))
2098
+ else:
2099
+ edges = my_network.network_lists[2]
2037
2100
  num = (self.channel_data[1].shape[0] * self.channel_data[1].shape[1] * self.channel_data[1].shape[2])
2038
2101
  if edges[0] == 0:
2039
2102
  del edges[0]
@@ -2111,6 +2174,16 @@ class ImageViewerWindow(QMainWindow):
2111
2174
  except:
2112
2175
  pass
2113
2176
 
2177
+ if self.branch_dict[0] is not None:
2178
+ try:
2179
+ info_dict['Branch Length'] = self.branch_dict[0][0][label]
2180
+ except:
2181
+ pass
2182
+ try:
2183
+ info_dict['Branch Tortuosity'] = self.branch_dict[0][1][label]
2184
+ except:
2185
+ pass
2186
+
2114
2187
 
2115
2188
  elif sort == 'edge':
2116
2189
 
@@ -2156,7 +2229,17 @@ class ImageViewerWindow(QMainWindow):
2156
2229
  except:
2157
2230
  pass
2158
2231
 
2159
- self.format_for_upperright_table(info_dict, title = f'Info on Object')
2232
+ if self.branch_dict[1] is not None:
2233
+ try:
2234
+ info_dict['Branch Length'] = self.branch_dict[1][0][label]
2235
+ except:
2236
+ pass
2237
+ try:
2238
+ info_dict['Branch Tortuosity'] = self.branch_dict[1][1][label]
2239
+ except:
2240
+ pass
2241
+
2242
+ self.format_for_upperright_table(info_dict, title = f'Info on Object', sort = False)
2160
2243
 
2161
2244
  except:
2162
2245
  pass
@@ -2274,7 +2357,7 @@ class ImageViewerWindow(QMainWindow):
2274
2357
  unique_labels = np.unique(input_array[binary_mask])
2275
2358
  print(f"Processing {len(unique_labels)} unique labels")
2276
2359
 
2277
- # Get all bounding boxes at once - this is very fast
2360
+ # Get all bounding boxes at once
2278
2361
  bounding_boxes = ndimage.find_objects(input_array)
2279
2362
 
2280
2363
  # Prepare work items - just check if bounding box exists for each label
@@ -2290,7 +2373,7 @@ class ImageViewerWindow(QMainWindow):
2290
2373
  bbox = bounding_boxes[bbox_index]
2291
2374
  work_items.append((orig_label, bbox))
2292
2375
 
2293
- print(f"Created {len(work_items)} work items")
2376
+ #print(f"Created {len(work_items)} work items")
2294
2377
 
2295
2378
  # If we have work items, process them
2296
2379
  if len(work_items) == 0:
@@ -2310,7 +2393,7 @@ class ImageViewerWindow(QMainWindow):
2310
2393
  return orig_label, bbox, labeled_sub, num_cc
2311
2394
 
2312
2395
  except Exception as e:
2313
- print(f"Error processing label {orig_label}: {e}")
2396
+ #print(f"Error processing label {orig_label}: {e}")
2314
2397
  return orig_label, bbox, None, 0
2315
2398
 
2316
2399
  # Execute in parallel
@@ -2326,7 +2409,7 @@ class ImageViewerWindow(QMainWindow):
2326
2409
 
2327
2410
  for orig_label, bbox, labeled_sub, num_cc in results:
2328
2411
  if num_cc > 0 and labeled_sub is not None:
2329
- print(f"Label {orig_label}: {num_cc} components")
2412
+ #print(f"Label {orig_label}: {num_cc} components")
2330
2413
  # Remap labels and place in output
2331
2414
  for cc_id in range(1, num_cc + 1):
2332
2415
  mask = labeled_sub == cc_id
@@ -2339,7 +2422,7 @@ class ImageViewerWindow(QMainWindow):
2339
2422
 
2340
2423
  def handle_seperate(self):
2341
2424
  """
2342
- Fixed version with proper mask handling and debugging
2425
+ Seperate objects in an array that share a label but do not touch
2343
2426
  """
2344
2427
  try:
2345
2428
  # Handle nodes
@@ -2348,7 +2431,6 @@ class ImageViewerWindow(QMainWindow):
2348
2431
  # Create highlight overlay (this should preserve original label values)
2349
2432
  self.create_highlight_overlay(node_indices=self.clicked_values['nodes'])
2350
2433
 
2351
- # DON'T convert to boolean yet - we need the original labels!
2352
2434
  # Create a boolean mask for where we have highlighted values
2353
2435
  highlight_mask = self.highlight_overlay != 0
2354
2436
 
@@ -2358,7 +2440,7 @@ class ImageViewerWindow(QMainWindow):
2358
2440
  # Get non-highlighted part of the array
2359
2441
  non_highlighted = np.where(highlight_mask, 0, my_network.nodes)
2360
2442
 
2361
- # Calculate max_val properly
2443
+ # Calculate max_val
2362
2444
  max_val = np.max(non_highlighted) if np.any(non_highlighted) else 0
2363
2445
 
2364
2446
  # Process highlighted part
@@ -2583,9 +2665,9 @@ class ImageViewerWindow(QMainWindow):
2583
2665
  self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
2584
2666
  self.needs_mini = False
2585
2667
  else:
2586
- self.create_highlight_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
2668
+ self.evaluate_mini()
2587
2669
  else:
2588
- self.create_highlight_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
2670
+ self.evaluate_mini()
2589
2671
 
2590
2672
 
2591
2673
  self.update_display(preserve_zoom=(current_xlim, current_ylim))
@@ -3650,17 +3732,23 @@ class ImageViewerWindow(QMainWindow):
3650
3732
 
3651
3733
  # Add highlight overlays if they exist (with downsampling)
3652
3734
  if self.mini_overlay and self.highlight and self.machine_window is None:
3653
- display_overlay = crop_and_downsample_image(self.mini_overlay_data, y_min_padded, y_max_padded, x_min_padded, x_max_padded, downsample_factor)
3654
- highlight_rgba = self.create_highlight_rgba(display_overlay, yellow=True)
3655
- composite = self.blend_layers(composite, highlight_rgba)
3735
+ try:
3736
+ display_overlay = crop_and_downsample_image(self.mini_overlay_data, y_min_padded, y_max_padded, x_min_padded, x_max_padded, downsample_factor)
3737
+ highlight_rgba = self.create_highlight_rgba(display_overlay, yellow=True)
3738
+ composite = self.blend_layers(composite, highlight_rgba)
3739
+ except:
3740
+ pass
3656
3741
  elif self.highlight_overlay is not None and self.highlight:
3657
- highlight_slice = self.highlight_overlay[self.current_slice]
3658
- display_highlight = crop_and_downsample_image(highlight_slice, y_min_padded, y_max_padded, x_min_padded, x_max_padded, downsample_factor)
3659
- if self.machine_window is None:
3660
- highlight_rgba = self.create_highlight_rgba(display_highlight, yellow=True)
3661
- else:
3662
- highlight_rgba = self.create_highlight_rgba(display_highlight, yellow=False)
3663
- composite = self.blend_layers(composite, highlight_rgba)
3742
+ try:
3743
+ highlight_slice = self.highlight_overlay[self.current_slice]
3744
+ display_highlight = crop_and_downsample_image(highlight_slice, y_min_padded, y_max_padded, x_min_padded, x_max_padded, downsample_factor)
3745
+ if self.machine_window is None:
3746
+ highlight_rgba = self.create_highlight_rgba(display_highlight, yellow=True)
3747
+ else:
3748
+ highlight_rgba = self.create_highlight_rgba(display_highlight, yellow=False)
3749
+ composite = self.blend_layers(composite, highlight_rgba)
3750
+ except:
3751
+ pass
3664
3752
 
3665
3753
  # Convert to 0-255 range for display
3666
3754
  return (composite * 255).astype(np.uint8)
@@ -3755,6 +3843,12 @@ class ImageViewerWindow(QMainWindow):
3755
3843
  self.ax.clear()
3756
3844
  self.ax.set_facecolor('black')
3757
3845
 
3846
+ # Reset measurement artists since we cleared the axes
3847
+ if not hasattr(self, 'measurement_artists'):
3848
+ self.measurement_artists = []
3849
+ else:
3850
+ self.measurement_artists = [] # Reset since ax.clear() removed all artists
3851
+
3758
3852
  # Get original dimensions (before downsampling)
3759
3853
  if hasattr(self, 'original_dims') and self.original_dims:
3760
3854
  height, width = self.original_dims
@@ -3836,23 +3930,129 @@ class ImageViewerWindow(QMainWindow):
3836
3930
  for spine in self.ax.spines.values():
3837
3931
  spine.set_color('black')
3838
3932
 
3839
- # Add measurement points if they exist (coordinates remain in original space)
3840
- for point in self.measurement_points:
3841
- x1, y1, z1 = point['point1']
3842
- x2, y2, z2 = point['point2']
3843
- pair_idx = point['pair_index']
3844
-
3845
- if z1 == self.current_slice:
3846
- self.ax.plot(x1, y1, 'yo', markersize=8)
3847
- self.ax.text(x1, y1+5, str(pair_idx),
3848
- color='white', ha='center', va='bottom')
3849
- if z2 == self.current_slice:
3850
- self.ax.plot(x2, y2, 'yo', markersize=8)
3851
- self.ax.text(x2, y2+5, str(pair_idx),
3852
- color='white', ha='center', va='bottom')
3933
+ # Add measurement points if they exist (using the same logic as main update_display)
3934
+ if hasattr(self, 'measurement_points') and self.measurement_points:
3935
+ for point in self.measurement_points:
3936
+ x1, y1, z1 = point['point1']
3937
+ x2, y2, z2 = point['point2']
3938
+ pair_idx = point['pair_index']
3939
+ point_type = point.get('type', 'distance') # Default to distance for backward compatibility
3940
+
3941
+ # Determine colors based on type
3942
+ if point_type == 'angle':
3943
+ marker_color = 'go'
3944
+ text_color = 'green'
3945
+ line_color = 'g--'
3946
+ else: # distance
3947
+ marker_color = 'yo'
3948
+ text_color = 'yellow'
3949
+ line_color = 'r--'
3950
+
3951
+ # Check if points are in visible region and on current slice
3952
+ point1_visible = (z1 == self.current_slice and
3953
+ current_xlim[0] <= x1 <= current_xlim[1] and
3954
+ current_ylim[1] <= y1 <= current_ylim[0])
3955
+ point2_visible = (z2 == self.current_slice and
3956
+ current_xlim[0] <= x2 <= current_xlim[1] and
3957
+ current_ylim[1] <= y2 <= current_ylim[0])
3958
+
3959
+ # Draw individual points if they're on the current slice
3960
+ if point1_visible:
3961
+ pt1 = self.ax.plot(x1, y1, marker_color, markersize=8)[0]
3962
+ txt1 = self.ax.text(x1, y1+5, str(pair_idx), color=text_color, ha='center', va='bottom')
3963
+ self.measurement_artists.extend([pt1, txt1])
3964
+
3965
+ if point2_visible:
3966
+ pt2 = self.ax.plot(x2, y2, marker_color, markersize=8)[0]
3967
+ txt2 = self.ax.text(x2, y2+5, str(pair_idx), color=text_color, ha='center', va='bottom')
3968
+ self.measurement_artists.extend([pt2, txt2])
3969
+
3970
+ # Draw connecting line if both points are on the same slice
3971
+ if z1 == z2 == self.current_slice and (point1_visible or point2_visible):
3972
+ line = self.ax.plot([x1, x2], [y1, y2], line_color, alpha=0.5)[0]
3973
+ self.measurement_artists.append(line)
3974
+
3975
+ # Handle angle measurements if they exist
3976
+ if hasattr(self, 'angle_measurements') and self.angle_measurements:
3977
+ for angle in self.angle_measurements:
3978
+ xa, ya, za = angle['point_a']
3979
+ xb, yb, zb = angle['point_b'] # vertex
3980
+ xc, yc, zc = angle['point_c']
3981
+ trio_idx = angle['trio_index']
3982
+
3983
+ # Check if points are on current slice and visible
3984
+ point_a_visible = (za == self.current_slice and
3985
+ current_xlim[0] <= xa <= current_xlim[1] and
3986
+ current_ylim[1] <= ya <= current_ylim[0])
3987
+ point_b_visible = (zb == self.current_slice and
3988
+ current_xlim[0] <= xb <= current_xlim[1] and
3989
+ current_ylim[1] <= yb <= current_ylim[0])
3990
+ point_c_visible = (zc == self.current_slice and
3991
+ current_xlim[0] <= xc <= current_xlim[1] and
3992
+ current_ylim[1] <= yc <= current_ylim[0])
3993
+
3994
+ # Draw points
3995
+ if point_a_visible:
3996
+ pt_a = self.ax.plot(xa, ya, 'go', markersize=8)[0]
3997
+ txt_a = self.ax.text(xa, ya+5, f"A{trio_idx}", color='green', ha='center', va='bottom')
3998
+ self.measurement_artists.extend([pt_a, txt_a])
3853
3999
 
3854
- if z1 == z2 == self.current_slice:
3855
- self.ax.plot([x1, x2], [y1, y2], 'r--', alpha=0.5)
4000
+ if point_b_visible:
4001
+ pt_b = self.ax.plot(xb, yb, 'go', markersize=8)[0]
4002
+ txt_b = self.ax.text(xb, yb+5, f"B{trio_idx}", color='green', ha='center', va='bottom')
4003
+ self.measurement_artists.extend([pt_b, txt_b])
4004
+
4005
+ if point_c_visible:
4006
+ pt_c = self.ax.plot(xc, yc, 'go', markersize=8)[0]
4007
+ txt_c = self.ax.text(xc, yc+5, f"C{trio_idx}", color='green', ha='center', va='bottom')
4008
+ self.measurement_artists.extend([pt_c, txt_c])
4009
+
4010
+ # Draw lines only if points are on current slice
4011
+ if za == zb == self.current_slice and (point_a_visible or point_b_visible):
4012
+ line_ab = self.ax.plot([xa, xb], [ya, yb], 'g--', alpha=0.7)[0]
4013
+ self.measurement_artists.append(line_ab)
4014
+
4015
+ if zb == zc == self.current_slice and (point_b_visible or point_c_visible):
4016
+ line_bc = self.ax.plot([xb, xc], [yb, yc], 'g--', alpha=0.7)[0]
4017
+ self.measurement_artists.append(line_bc)
4018
+
4019
+ # Handle any partial measurements in progress (individual points without pairs yet)
4020
+ if hasattr(self, 'current_point') and self.current_point is not None:
4021
+ x, y, z = self.current_point
4022
+ if z == self.current_slice:
4023
+ if hasattr(self, 'measurement_mode') and self.measurement_mode == "angle":
4024
+ # Show green for angle mode
4025
+ pt = self.ax.plot(x, y, 'go', markersize=8)[0]
4026
+ if hasattr(self, 'current_trio_index'):
4027
+ txt = self.ax.text(x, y+5, f"A{self.current_trio_index}", color='green', ha='center', va='bottom')
4028
+ else:
4029
+ txt = self.ax.text(x, y+5, "A", color='green', ha='center', va='bottom')
4030
+ else:
4031
+ # Show yellow for distance mode (default)
4032
+ pt = self.ax.plot(x, y, 'yo', markersize=8)[0]
4033
+ if hasattr(self, 'current_pair_index'):
4034
+ txt = self.ax.text(x, y+5, f"D{self.current_pair_index}", color='yellow', ha='center', va='bottom')
4035
+ else:
4036
+ txt = self.ax.text(x, y+5, "D", color='yellow', ha='center', va='bottom')
4037
+ self.measurement_artists.extend([pt, txt])
4038
+
4039
+ # Handle second point in angle measurements
4040
+ if hasattr(self, 'current_second_point') and self.current_second_point is not None:
4041
+ x, y, z = self.current_second_point
4042
+ if z == self.current_slice:
4043
+ pt = self.ax.plot(x, y, 'go', markersize=8)[0]
4044
+ if hasattr(self, 'current_trio_index'):
4045
+ txt = self.ax.text(x, y+5, f"B{self.current_trio_index}", color='green', ha='center', va='bottom')
4046
+ else:
4047
+ txt = self.ax.text(x, y+5, "B", color='green', ha='center', va='bottom')
4048
+ self.measurement_artists.extend([pt, txt])
4049
+
4050
+ # Draw line from A to B if both are on current slice
4051
+ if (hasattr(self, 'current_point') and self.current_point is not None and
4052
+ self.current_point[2] == self.current_slice):
4053
+ x1, y1, z1 = self.current_point
4054
+ line = self.ax.plot([x1, x], [y1, y], 'g--', alpha=0.7)[0]
4055
+ self.measurement_artists.append(line)
3856
4056
 
3857
4057
  #self.canvas.setCursor(Qt.CursorShape.ClosedHandCursor)
3858
4058
 
@@ -3968,7 +4168,34 @@ class ImageViewerWindow(QMainWindow):
3968
4168
  if len(self.clicked_values['edges']):
3969
4169
  self.highlight_value_in_tables(self.clicked_values['edges'][-1])
3970
4170
  self.handle_info('edge')
3971
-
4171
+
4172
+ try:
4173
+ if len(self.clicked_values['nodes']) > 0 or len(self.clicked_values['edges']) > 0: # Check if we have any nodes selected
4174
+
4175
+ old_nodes = copy.deepcopy(self.clicked_values['nodes'])
4176
+
4177
+ # Get the existing DataFrame from the model
4178
+ original_df = self.network_table.model()._data
4179
+
4180
+ # Create mask for rows where one column is any original node AND the other column is any neighbor
4181
+ mask = (
4182
+ ((original_df.iloc[:, 0].isin(self.clicked_values['nodes'])) &
4183
+ (original_df.iloc[:, 1].isin(self.clicked_values['nodes']))) |
4184
+ (original_df.iloc[:, 2].isin(self.clicked_values['edges']))
4185
+ )
4186
+
4187
+ # Filter the DataFrame to only include direct connections
4188
+ filtered_df = original_df[mask].copy()
4189
+
4190
+ # Create new model with filtered DataFrame and update selection table
4191
+ new_model = PandasModel(filtered_df)
4192
+ self.selection_table.setModel(new_model)
4193
+
4194
+ # Switch to selection table
4195
+ self.selection_button.click()
4196
+ except:
4197
+ pass
4198
+
3972
4199
  elif not self.selecting and self.selection_start: # If we had a click but never started selection
3973
4200
  # Handle as a normal click
3974
4201
  self.on_mouse_click(event)
@@ -3991,10 +4218,8 @@ class ImageViewerWindow(QMainWindow):
3991
4218
  elif self.zoom_mode:
3992
4219
  # Handle zoom mode press
3993
4220
  if self.original_xlim is None:
3994
- self.original_xlim = self.ax.get_xlim()
3995
- #print(self.original_xlim)
3996
- self.original_ylim = self.ax.get_ylim()
3997
- #print(self.original_ylim)
4221
+ self.original_xlim = (-0.5, self.shape[2] - 0.5)
4222
+ self.original_ylim = (self.shape[1] + 0.5, -0.5)
3998
4223
 
3999
4224
  current_xlim = self.ax.get_xlim()
4000
4225
  current_ylim = self.ax.get_ylim()
@@ -4156,8 +4381,8 @@ class ImageViewerWindow(QMainWindow):
4156
4381
  if self.zoom_mode:
4157
4382
  # Existing zoom functionality
4158
4383
  if self.original_xlim is None:
4159
- self.original_xlim = self.ax.get_xlim()
4160
- self.original_ylim = self.ax.get_ylim()
4384
+ self.original_xlim = (-0.5, self.shape[2] - 0.5)
4385
+ self.original_ylim = (self.shape[1] + 0.5, -0.5)
4161
4386
 
4162
4387
  current_xlim = self.ax.get_xlim()
4163
4388
  current_ylim = self.ax.get_ylim()
@@ -4333,6 +4558,8 @@ class ImageViewerWindow(QMainWindow):
4333
4558
  for i in range(4):
4334
4559
  load_action = load_menu.addAction(f"Load {self.channel_names[i]}")
4335
4560
  load_action.triggered.connect(lambda checked, ch=i: self.load_channel(ch))
4561
+ load_action = load_menu.addAction("Load Full-Sized Highlight Overlay")
4562
+ load_action.triggered.connect(lambda: self.load_channel(channel_index = 4, load_highlight = True))
4336
4563
  load_action = load_menu.addAction("Load Network")
4337
4564
  load_action.triggered.connect(self.load_network)
4338
4565
  load_action = load_menu.addAction("Load From Excel Helper")
@@ -4373,6 +4600,8 @@ class ImageViewerWindow(QMainWindow):
4373
4600
  allstats_action.triggered.connect(self.stats)
4374
4601
  histos_action = stats_menu.addAction("Network Statistic Histograms")
4375
4602
  histos_action.triggered.connect(self.histos)
4603
+ sig_action = stats_menu.addAction("Significance Testing")
4604
+ sig_action.triggered.connect(self.sig_test)
4376
4605
  radial_action = stats_menu.addAction("Radial Distribution Analysis")
4377
4606
  radial_action.triggered.connect(self.show_radial_dialog)
4378
4607
  neighbor_id_action = stats_menu.addAction("Identity Distribution of Neighbors")
@@ -4387,8 +4616,12 @@ class ImageViewerWindow(QMainWindow):
4387
4616
  vol_action.triggered.connect(self.volumes)
4388
4617
  rad_action = stats_menu.addAction("Calculate Radii")
4389
4618
  rad_action.triggered.connect(self.show_rad_dialog)
4619
+ branch_stats = stats_menu.addAction("Calculate Branch Stats (Lengths, Tortuosities)")
4620
+ branch_stats.triggered.connect(self.show_branchstat_dialog)
4390
4621
  inter_action = stats_menu.addAction("Calculate Node < > Edge Interaction")
4391
4622
  inter_action.triggered.connect(self.show_interaction_dialog)
4623
+ violin_action = stats_menu.addAction("Show Identity Violins/UMAP/Assign Intensity Neighborhoods")
4624
+ violin_action.triggered.connect(self.show_violin_dialog)
4392
4625
  overlay_menu = analysis_menu.addMenu("Data/Overlays")
4393
4626
  degree_action = overlay_menu.addAction("Get Degree Information")
4394
4627
  degree_action.triggered.connect(self.show_degree_dialog)
@@ -4422,12 +4655,16 @@ class ImageViewerWindow(QMainWindow):
4422
4655
  calc_branch_action.triggered.connect(self.handle_calc_branch)
4423
4656
  calc_branchprox_action = calculate_menu.addAction("Calculate Branch Adjacency Network (Of Edges)")
4424
4657
  calc_branchprox_action.triggered.connect(self.handle_branchprox_calc)
4658
+ #calc_id_net_action = calculate_menu.addAction("Calculate Identity Network (beta)")
4659
+ #calc_id_net_action.triggered.connect(self.handle_identity_net_calc)
4425
4660
  centroid_action = calculate_menu.addAction("Calculate Centroids (Active Image)")
4426
4661
  centroid_action.triggered.connect(self.show_centroid_dialog)
4427
4662
 
4428
4663
  image_menu = process_menu.addMenu("Image")
4429
4664
  resize_action = image_menu.addAction("Resize (Up/Downsample)")
4430
4665
  resize_action.triggered.connect(self.show_resize_dialog)
4666
+ clean_action = image_menu.addAction("Clean Segmentation")
4667
+ clean_action.triggered.connect(self.show_clean_dialog)
4431
4668
  dilate_action = image_menu.addAction("Dilate")
4432
4669
  dilate_action.triggered.connect(self.show_dilate_dialog)
4433
4670
  erode_action = image_menu.addAction("Erode")
@@ -4438,7 +4675,7 @@ class ImageViewerWindow(QMainWindow):
4438
4675
  binarize_action.triggered.connect(self.show_binarize_dialog)
4439
4676
  label_action = image_menu.addAction("Label Objects")
4440
4677
  label_action.triggered.connect(self.show_label_dialog)
4441
- slabel_action = image_menu.addAction("Neighborhood Labels")
4678
+ slabel_action = image_menu.addAction("Neighbor Labels")
4442
4679
  slabel_action.triggered.connect(self.show_slabel_dialog)
4443
4680
  thresh_action = image_menu.addAction("Threshold/Segment")
4444
4681
  thresh_action.triggered.connect(self.show_thresh_dialog)
@@ -4514,7 +4751,7 @@ class ImageViewerWindow(QMainWindow):
4514
4751
 
4515
4752
 
4516
4753
  # Add after your other buttons
4517
- self.popup_button = QPushButton("⤴") # or "🔗" or "⤴"
4754
+ self.popup_button = QPushButton("⤴")
4518
4755
  self.popup_button.setFixedSize(40, 40)
4519
4756
  self.popup_button.setToolTip("Pop out canvas")
4520
4757
  self.popup_button.clicked.connect(self.popup_canvas)
@@ -4582,7 +4819,10 @@ class ImageViewerWindow(QMainWindow):
4582
4819
  # Invalid input - reset to default
4583
4820
  self.downsample_factor = 1
4584
4821
 
4585
- self.throttle = self.shape[1] * self.shape[2] > 3000 * 3000 * self.downsample_factor
4822
+ try:
4823
+ self.throttle = self.shape[1] * self.shape[2] > 3000 * 3000 * self.downsample_factor
4824
+ except:
4825
+ self.throttle = False
4586
4826
 
4587
4827
  # Optional: Trigger display update if you want immediate effect
4588
4828
  if update:
@@ -4667,8 +4907,11 @@ class ImageViewerWindow(QMainWindow):
4667
4907
  self.cellpose_launcher.launch_cellpose_gui(use_3d = use_3d)
4668
4908
 
4669
4909
  except:
4670
- import traceback
4671
- print(traceback.format_exc())
4910
+ QMessageBox.critical(
4911
+ self,
4912
+ "Error",
4913
+ f"Error starting cellpose: {str(e)}\nNote: You may need to install cellpose with corresponding torch first - in your environment, please call 'pip install cellpose'. Please see: 'https://pytorch.org/get-started/locally/' to see what torch install command corresponds to your NVIDIA GPU"
4914
+ )
4672
4915
  pass
4673
4916
 
4674
4917
 
@@ -4715,6 +4958,16 @@ class ImageViewerWindow(QMainWindow):
4715
4958
  except Exception as e:
4716
4959
  print(f"Error creating histogram selector: {e}")
4717
4960
 
4961
+ def sig_test(self):
4962
+ # Get the existing QApplication instance
4963
+ app = QApplication.instance()
4964
+
4965
+ # Create the statistical GUI window without starting a new event loop
4966
+ stats_window = net_stats.main(app)
4967
+
4968
+ # Keep a reference so it doesn't get garbage collected
4969
+ self.stats_window = stats_window
4970
+
4718
4971
  def volumes(self):
4719
4972
 
4720
4973
 
@@ -4740,7 +4993,7 @@ class ImageViewerWindow(QMainWindow):
4740
4993
 
4741
4994
 
4742
4995
 
4743
- def format_for_upperright_table(self, data, metric='Metric', value='Value', title=None):
4996
+ def format_for_upperright_table(self, data, metric='Metric', value='Value', title=None, sort = True):
4744
4997
  """
4745
4998
  Format dictionary or list data for display in upper right table.
4746
4999
 
@@ -4826,11 +5079,12 @@ class ImageViewerWindow(QMainWindow):
4826
5079
  table = CustomTableView(self)
4827
5080
  table.setModel(PandasModel(df))
4828
5081
 
4829
- try:
4830
- first_column_name = table.model()._data.columns[0]
4831
- table.sort_table(first_column_name, ascending=True)
4832
- except:
4833
- pass
5082
+ if sort:
5083
+ try:
5084
+ first_column_name = table.model()._data.columns[0]
5085
+ table.sort_table(first_column_name, ascending=True)
5086
+ except:
5087
+ pass
4834
5088
 
4835
5089
  # Add to tabbed widget
4836
5090
  if title is None:
@@ -4844,6 +5098,8 @@ class ImageViewerWindow(QMainWindow):
4844
5098
  for column in range(table.model().columnCount(None)):
4845
5099
  table.resizeColumnToContents(column)
4846
5100
 
5101
+ return df
5102
+
4847
5103
  except:
4848
5104
  pass
4849
5105
 
@@ -4860,6 +5116,10 @@ class ImageViewerWindow(QMainWindow):
4860
5116
  dialog = MergeNodeIdDialog(self)
4861
5117
  dialog.exec()
4862
5118
 
5119
+ def show_multichan_dialog(self, data):
5120
+ dialog = MultiChanDialog(self, data)
5121
+ dialog.show()
5122
+
4863
5123
  def show_gray_water_dialog(self):
4864
5124
  """Show the gray watershed parameter dialog."""
4865
5125
  dialog = GrayWaterDialog(self)
@@ -4962,7 +5222,7 @@ class ImageViewerWindow(QMainWindow):
4962
5222
 
4963
5223
  my_network.edges = (my_network.nodes == 0) * my_network.edges
4964
5224
 
4965
- my_network.calculate_all(my_network.nodes, my_network.edges, xy_scale = my_network.xy_scale, z_scale = my_network.z_scale, search = None, diledge = None, inners = False, hash_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)
5225
+ my_network.calculate_all(my_network.nodes, my_network.edges, xy_scale = my_network.xy_scale, z_scale = my_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)
4966
5226
 
4967
5227
  self.load_channel(1, my_network.edges, data = True)
4968
5228
  self.load_channel(0, my_network.nodes, data = True)
@@ -4996,6 +5256,12 @@ class ImageViewerWindow(QMainWindow):
4996
5256
 
4997
5257
  self.load_channel(0, my_network.edges, data = True)
4998
5258
 
5259
+ try:
5260
+ self.branch_dict[0] = self.branch_dict[1]
5261
+ self.branch_dict[1] = None
5262
+ except:
5263
+ pass
5264
+
4999
5265
  self.delete_channel(1, False)
5000
5266
 
5001
5267
  my_network.morph_proximity(search = [3,3], fastdil = True)
@@ -5012,14 +5278,51 @@ class ImageViewerWindow(QMainWindow):
5012
5278
  dialog = CentroidDialog(self)
5013
5279
  dialog.exec()
5014
5280
 
5015
- def show_dilate_dialog(self):
5281
+ def handle_identity_net_calc(self):
5282
+
5283
+ try:
5284
+
5285
+ def confirm_dialog():
5286
+ """Shows a dialog asking user to confirm and input connection limit"""
5287
+ from PyQt6.QtWidgets import QInputDialog
5288
+
5289
+ value, ok = QInputDialog.getInt(
5290
+ None, # parent widget
5291
+ "Confirm", # window title
5292
+ "Calculate Identity Network\n\n"
5293
+ "Connect nodes that share an identity - useful for nodes that\n"
5294
+ "overlap in identity to some degree.\n\n"
5295
+ "Enter maximum connections per node within same identity:",
5296
+ 5, # default value
5297
+ 1, # minimum value
5298
+ 1000, # maximum value
5299
+ 1 # step
5300
+ )
5301
+
5302
+ if ok:
5303
+ return True, value
5304
+ else:
5305
+ return False, None
5306
+
5307
+ confirm, val = confirm_dialog()
5308
+
5309
+ if confirm:
5310
+ my_network.create_id_network(val)
5311
+ self.table_load_attrs()
5312
+ else:
5313
+ return
5314
+
5315
+ except:
5316
+ pass
5317
+
5318
+ def show_dilate_dialog(self, args = None):
5016
5319
  """show the dilate dialog"""
5017
- dialog = DilateDialog(self)
5320
+ dialog = DilateDialog(self, args)
5018
5321
  dialog.exec()
5019
5322
 
5020
- def show_erode_dialog(self):
5323
+ def show_erode_dialog(self, args = None):
5021
5324
  """show the erode dialog"""
5022
- dialog = ErodeDialog(self)
5325
+ dialog = ErodeDialog(self, args)
5023
5326
  dialog.exec()
5024
5327
 
5025
5328
  def show_hole_dialog(self):
@@ -5118,6 +5421,9 @@ class ImageViewerWindow(QMainWindow):
5118
5421
  dialog = ResizeDialog(self)
5119
5422
  dialog.exec()
5120
5423
 
5424
+ def show_clean_dialog(self):
5425
+ dialog = CleanDialog(self)
5426
+ dialog.show()
5121
5427
 
5122
5428
  def show_properties_dialog(self):
5123
5429
  """Show the properties dialog"""
@@ -5272,7 +5578,6 @@ class ImageViewerWindow(QMainWindow):
5272
5578
 
5273
5579
  elif sort == 'Merge Nodes':
5274
5580
  try:
5275
-
5276
5581
  if my_network.nodes is None:
5277
5582
  QMessageBox.critical(
5278
5583
  self,
@@ -5280,72 +5585,118 @@ class ImageViewerWindow(QMainWindow):
5280
5585
  "Please load your first set of nodes into the 'Nodes' channel first"
5281
5586
  )
5282
5587
  return
5283
-
5284
5588
  if len(np.unique(my_network.nodes)) < 3:
5285
5589
  self.show_label_dialog()
5286
-
5287
- # First ask user what they want to select
5288
- msg = QMessageBox()
5289
- msg.setWindowTitle("Selection Type")
5290
- msg.setText("Would you like to select a TIFF file or a directory?")
5291
- tiff_button = msg.addButton("TIFF File", QMessageBox.ButtonRole.AcceptRole)
5292
- dir_button = msg.addButton("Directory", QMessageBox.ButtonRole.AcceptRole)
5293
- msg.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
5294
-
5295
- msg.exec()
5296
-
5297
- # Also if they want centroids:
5298
- msg2 = QMessageBox()
5299
- msg2.setWindowTitle("Selection Type")
5300
- msg2.setText("Would you like to compute node centroids for each image prior to merging?")
5301
- yes_button = msg2.addButton("Yes", QMessageBox.ButtonRole.AcceptRole)
5302
- no_button = msg2.addButton("No", QMessageBox.ButtonRole.AcceptRole)
5303
- msg2.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
5304
-
5305
- msg2.exec()
5306
-
5307
- if msg2.clickedButton() == yes_button:
5308
- centroids = True
5309
- else:
5310
- centroids = False
5311
-
5312
- if msg.clickedButton() == tiff_button:
5313
- # Code for selecting TIFF files
5314
- filename, _ = QFileDialog.getOpenFileName(
5315
- self,
5316
- "Select TIFF file",
5317
- "",
5318
- "TIFF files (*.tiff *.tif)"
5319
- )
5320
- if filename:
5321
- selected_path = filename
5322
-
5323
- elif msg.clickedButton() == dir_button:
5324
- # Code for selecting directories
5325
- dialog = QFileDialog(self)
5326
- dialog.setOption(QFileDialog.Option.DontUseNativeDialog)
5327
- dialog.setOption(QFileDialog.Option.ReadOnly)
5328
- dialog.setFileMode(QFileDialog.FileMode.Directory)
5329
- dialog.setViewMode(QFileDialog.ViewMode.Detail)
5330
-
5331
- if dialog.exec() == QFileDialog.DialogCode.Accepted:
5332
- selected_path = dialog.directory().absolutePath()
5333
-
5334
- my_network.merge_nodes(selected_path, root_id = self.node_name, centroids = centroids)
5335
- self.load_channel(0, my_network.nodes, True)
5336
-
5337
-
5338
- if hasattr(my_network, 'node_identities') and my_network.node_identities is not None:
5590
+
5591
+ # Create custom dialog
5592
+ dialog = QDialog(self)
5593
+ dialog.setWindowTitle("Merge Nodes Configuration")
5594
+ dialog.setModal(True)
5595
+ dialog.resize(400, 200)
5596
+
5597
+ layout = QVBoxLayout(dialog)
5598
+
5599
+ # Selection type
5600
+ type_layout = QHBoxLayout()
5601
+ type_label = QLabel("Selection Type:")
5602
+ type_combo = QComboBox()
5603
+ type_combo.addItems(["TIFF File", "Directory"])
5604
+ type_layout.addWidget(type_label)
5605
+ type_layout.addWidget(type_combo)
5606
+ layout.addLayout(type_layout)
5607
+
5608
+ # Centroids checkbox
5609
+ centroids_layout = QHBoxLayout()
5610
+ centroids_check = QCheckBox("Compute node centroids for each image prior to merging")
5611
+ centroids_layout.addWidget(centroids_check)
5612
+ layout.addLayout(centroids_layout)
5613
+
5614
+ # Down factor for centroid calculation
5615
+ down_factor_layout = QHBoxLayout()
5616
+ down_factor_label = QLabel("Down Factor (for centroid calculation downsampling):")
5617
+ down_factor_edit = QLineEdit()
5618
+ down_factor_edit.setText("1") # Default value
5619
+ down_factor_edit.setPlaceholderText("Enter down factor (e.g., 1, 2, 4)")
5620
+ down_factor_layout.addWidget(down_factor_label)
5621
+ down_factor_layout.addWidget(down_factor_edit)
5622
+ layout.addLayout(down_factor_layout)
5623
+
5624
+ # Buttons
5625
+ button_layout = QHBoxLayout()
5626
+ accept_button = QPushButton("Accept")
5627
+ cancel_button = QPushButton("Cancel")
5628
+ button_layout.addWidget(accept_button)
5629
+ button_layout.addWidget(cancel_button)
5630
+ layout.addLayout(button_layout)
5631
+
5632
+ # Connect buttons
5633
+ accept_button.clicked.connect(dialog.accept)
5634
+ cancel_button.clicked.connect(dialog.reject)
5635
+
5636
+ # Execute dialog
5637
+ if dialog.exec() == QDialog.DialogCode.Accepted:
5638
+ # Get values from dialog
5639
+ selection_type = type_combo.currentText()
5640
+ centroids = centroids_check.isChecked()
5641
+
5642
+ # Validate and get down_factor
5339
5643
  try:
5340
- self.format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity', 'Node Identities')
5341
- except Exception as e:
5342
- print(f"Error loading node identity table: {e}")
5343
- if centroids:
5344
- self.format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
5345
-
5346
-
5644
+ down_factor = int(down_factor_edit.text())
5645
+ if down_factor <= 0:
5646
+ raise ValueError("Down factor must be positive")
5647
+ except ValueError as e:
5648
+ QMessageBox.critical(
5649
+ self,
5650
+ "Invalid Input",
5651
+ f"Invalid down factor: {str(e)}"
5652
+ )
5653
+ return
5654
+
5655
+ # Handle file/directory selection based on combo box choice
5656
+ if selection_type == "TIFF File":
5657
+ filename, _ = QFileDialog.getOpenFileName(
5658
+ self,
5659
+ "Select TIFF file",
5660
+ "",
5661
+ "TIFF files (*.tiff *.tif)"
5662
+ )
5663
+ if filename:
5664
+ selected_path = filename
5665
+ else:
5666
+ return # User cancelled file selection
5667
+ else: # Directory
5668
+ file_dialog = QFileDialog(self)
5669
+ file_dialog.setOption(QFileDialog.Option.DontUseNativeDialog)
5670
+ file_dialog.setOption(QFileDialog.Option.ReadOnly)
5671
+ file_dialog.setFileMode(QFileDialog.FileMode.Directory)
5672
+ file_dialog.setViewMode(QFileDialog.ViewMode.Detail)
5673
+ if file_dialog.exec() == QFileDialog.DialogCode.Accepted:
5674
+ selected_path = file_dialog.directory().absolutePath()
5675
+ else:
5676
+ return # User cancelled directory selection
5677
+
5678
+ if down_factor == 1:
5679
+ down_factor = None
5680
+ # Call merge_nodes with all parameters
5681
+ my_network.merge_nodes(
5682
+ selected_path,
5683
+ root_id=self.node_name,
5684
+ centroids=centroids,
5685
+ down_factor=down_factor
5686
+ )
5687
+
5688
+ self.load_channel(0, my_network.nodes, True)
5689
+
5690
+ if hasattr(my_network, 'node_identities') and my_network.node_identities is not None:
5691
+ try:
5692
+ self.format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity', 'Node Identities')
5693
+ except Exception as e:
5694
+ print(f"Error loading node identity table: {e}")
5695
+
5696
+ if centroids:
5697
+ self.format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
5698
+
5347
5699
  except Exception as e:
5348
-
5349
5700
  QMessageBox.critical(
5350
5701
  self,
5351
5702
  "Error Merging",
@@ -5368,8 +5719,10 @@ class ImageViewerWindow(QMainWindow):
5368
5719
  )
5369
5720
 
5370
5721
  self.last_load = directory
5371
-
5722
+ self.last_saved = os.path.dirname(directory)
5723
+ self.last_save_name = directory
5372
5724
 
5725
+ self.channel_data = [None] * 5
5373
5726
  if directory != "":
5374
5727
 
5375
5728
  self.reset(network = True, xy_scale = 1, z_scale = 1, nodes = True, edges = True, network_overlay = True, id_overlay = True, update = False)
@@ -5672,6 +6025,16 @@ class ImageViewerWindow(QMainWindow):
5672
6025
  msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
5673
6026
  return msg.exec() == QMessageBox.StandardButton.Yes
5674
6027
 
6028
+ def confirm_multichan_dialog(self):
6029
+ """Shows a dialog asking user to confirm if image is multichan"""
6030
+ msg = QMessageBox()
6031
+ msg.setIcon(QMessageBox.Icon.Question)
6032
+ msg.setText("Image Format Alert")
6033
+ msg.setInformativeText("Is this a Multi-Channel (4D) image?")
6034
+ msg.setWindowTitle("Confirm Image Format")
6035
+ msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
6036
+ return msg.exec() == QMessageBox.StandardButton.Yes
6037
+
5675
6038
  def confirm_resize_dialog(self):
5676
6039
  """Shows a dialog asking user to resize image"""
5677
6040
  msg = QMessageBox()
@@ -5682,12 +6045,41 @@ class ImageViewerWindow(QMainWindow):
5682
6045
  msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
5683
6046
  return msg.exec() == QMessageBox.StandardButton.Yes
5684
6047
 
5685
- def load_channel(self, channel_index, channel_data=None, data=False, assign_shape = True, preserve_zoom = None, end_paint = False, begin_paint = False, color = False):
6048
+ def get_scaling_metadata_only(self, filename):
6049
+ # This only reads headers/metadata, not image data
6050
+ with tifffile.TiffFile(filename) as tif:
6051
+ x_scale = y_scale = z_scale = unit = None
6052
+
6053
+ # ImageJ metadata (very lightweight)
6054
+ if hasattr(tif, 'imagej_metadata') and tif.imagej_metadata:
6055
+ metadata = tif.imagej_metadata
6056
+ z_scale = metadata.get('spacing')
6057
+ unit = metadata.get('unit')
6058
+
6059
+ # TIFF tags (also lightweight - just header info)
6060
+ page = tif.pages[0] # This doesn't load image data
6061
+ tags = page.tags
6062
+
6063
+ if 'XResolution' in tags:
6064
+ x_res = tags['XResolution'].value
6065
+ x_scale = x_res[1] / x_res[0] if isinstance(x_res, tuple) else 1.0 / x_res
6066
+
6067
+ if 'YResolution' in tags:
6068
+ y_res = tags['YResolution'].value
6069
+ y_scale = y_res[1] / y_res[0] if isinstance(y_res, tuple) else 1.0 / y_res
6070
+
6071
+ if x_scale is None:
6072
+ x_scale = 1
6073
+ if z_scale is None:
6074
+ z_scale = 1
6075
+
6076
+ return x_scale, z_scale
6077
+
6078
+ def load_channel(self, channel_index, channel_data=None, data=False, assign_shape = True, preserve_zoom = None, end_paint = False, begin_paint = False, color = False, load_highlight = False):
5686
6079
  """Load a channel and enable active channel selection if needed."""
5687
6080
 
5688
6081
  try:
5689
6082
 
5690
- self.hold_update = True
5691
6083
  if not data: # For solo loading
5692
6084
  filename, _ = QFileDialog.getOpenFileName(
5693
6085
  self,
@@ -5707,8 +6099,19 @@ class ImageViewerWindow(QMainWindow):
5707
6099
  try:
5708
6100
  if file_extension in ['tif', 'tiff']:
5709
6101
  import tifffile
5710
- self.channel_data[channel_index] = tifffile.imread(filename)
5711
-
6102
+ self.channel_data[channel_index] = None
6103
+ if (self.channel_data[0] is None and self.channel_data[1] is None) and (channel_index == 0 or channel_index == 1):
6104
+ try:
6105
+ my_network.xy_scale, my_network.z_scale = self.get_scaling_metadata_only(filename)
6106
+ print(f"xy_scale property set to {my_network.xy_scale}; z_scale property set to {my_network.z_scale}")
6107
+ except:
6108
+ pass
6109
+ test_channel_data = tifffile.imread(filename)
6110
+ if len(test_channel_data.shape) not in (2, 3, 4):
6111
+ print("Invalid Shape")
6112
+ return
6113
+ self.channel_data[channel_index] = test_channel_data
6114
+
5712
6115
  elif file_extension == 'nii':
5713
6116
  import nibabel as nib
5714
6117
  nii_img = nib.load(filename)
@@ -5755,7 +6158,7 @@ class ImageViewerWindow(QMainWindow):
5755
6158
  try:
5756
6159
  if len(self.channel_data[channel_index].shape) == 3: # potentially 2D RGB
5757
6160
  if self.channel_data[channel_index].shape[-1] in (3, 4): # last dim is 3 or 4
5758
- if not data and self.shape is None:
6161
+ if not data:
5759
6162
  if self.confirm_rgb_dialog():
5760
6163
  # User confirmed it's 2D RGB, expand to 4D
5761
6164
  self.channel_data[channel_index] = np.expand_dims(self.channel_data[channel_index], axis=0)
@@ -5765,12 +6168,18 @@ class ImageViewerWindow(QMainWindow):
5765
6168
  except:
5766
6169
  pass
5767
6170
 
5768
- if not color:
5769
- try:
5770
- if len(self.channel_data[channel_index].shape) == 4 and (channel_index == 0 or channel_index == 1):
6171
+ if len(self.channel_data[channel_index].shape) == 4:
6172
+ if not self.channel_data[channel_index].shape[-1] in (3, 4):
6173
+ if self.confirm_multichan_dialog(): # User is trying to load 4D channel stack:
6174
+ my_data = copy.deepcopy(self.channel_data[channel_index])
6175
+ self.channel_data[channel_index] = None
6176
+ self.show_multichan_dialog(data = my_data)
6177
+ return
6178
+ elif not color and (channel_index == 0 or channel_index == 1):
6179
+ try:
5771
6180
  self.channel_data[channel_index] = self.reduce_rgb_dimension(self.channel_data[channel_index], 'weight')
5772
- except:
5773
- pass
6181
+ except:
6182
+ pass
5774
6183
 
5775
6184
  reset_resize = False
5776
6185
 
@@ -5779,7 +6188,6 @@ class ImageViewerWindow(QMainWindow):
5779
6188
  if self.highlight_overlay is not None: #Make sure highlight overlay is always the same shape as new images
5780
6189
  try:
5781
6190
  if self.channel_data[i].shape[:3] != self.highlight_overlay.shape:
5782
- self.resizing = True
5783
6191
  reset_resize = True
5784
6192
  self.highlight_overlay = None
5785
6193
  except:
@@ -5806,51 +6214,52 @@ class ImageViewerWindow(QMainWindow):
5806
6214
  my_network.id_overlay = self.channel_data[channel_index]
5807
6215
 
5808
6216
  # Enable the channel button
5809
- self.channel_buttons[channel_index].setEnabled(True)
5810
- self.delete_buttons[channel_index].setEnabled(True)
6217
+ if channel_index != 4:
6218
+ self.channel_buttons[channel_index].setEnabled(True)
6219
+ self.delete_buttons[channel_index].setEnabled(True)
5811
6220
 
5812
6221
 
5813
- # Enable active channel selector if this is the first channel loaded
5814
- if not self.active_channel_combo.isEnabled():
5815
- self.active_channel_combo.setEnabled(True)
6222
+ # Enable active channel selector if this is the first channel loaded
6223
+ if not self.active_channel_combo.isEnabled():
6224
+ self.active_channel_combo.setEnabled(True)
5816
6225
 
5817
- # Update slider range if this is the first channel loaded
5818
- try:
5819
- if len(self.channel_data[channel_index].shape) == 3 or len(self.channel_data[channel_index].shape) == 4:
5820
- if not self.slice_slider.isEnabled():
5821
- self.slice_slider.setEnabled(True)
5822
- self.slice_slider.setMinimum(0)
5823
- self.slice_slider.setMaximum(self.channel_data[channel_index].shape[0] - 1)
5824
- if self.slice_slider.value() < self.channel_data[channel_index].shape[0] - 1:
5825
- self.current_slice = self.slice_slider.value()
6226
+ # Update slider range if this is the first channel loaded
6227
+ try:
6228
+ if len(self.channel_data[channel_index].shape) == 3 or len(self.channel_data[channel_index].shape) == 4:
6229
+ if not self.slice_slider.isEnabled():
6230
+ self.slice_slider.setEnabled(True)
6231
+ self.slice_slider.setMinimum(0)
6232
+ self.slice_slider.setMaximum(self.channel_data[channel_index].shape[0] - 1)
6233
+ if self.slice_slider.value() < self.channel_data[channel_index].shape[0] - 1:
6234
+ self.current_slice = self.slice_slider.value()
6235
+ else:
6236
+ self.slice_slider.setValue(0)
6237
+ self.current_slice = 0
5826
6238
  else:
5827
- self.slice_slider.setValue(0)
5828
- self.current_slice = 0
6239
+ self.slice_slider.setEnabled(True)
6240
+ self.slice_slider.setMinimum(0)
6241
+ self.slice_slider.setMaximum(self.channel_data[channel_index].shape[0] - 1)
6242
+ if self.slice_slider.value() < self.channel_data[channel_index].shape[0] - 1:
6243
+ self.current_slice = self.slice_slider.value()
6244
+ else:
6245
+ self.current_slice = 0
6246
+ self.slice_slider.setValue(0)
5829
6247
  else:
5830
- self.slice_slider.setEnabled(True)
5831
- self.slice_slider.setMinimum(0)
5832
- self.slice_slider.setMaximum(self.channel_data[channel_index].shape[0] - 1)
5833
- if self.slice_slider.value() < self.channel_data[channel_index].shape[0] - 1:
5834
- self.current_slice = self.slice_slider.value()
5835
- else:
5836
- self.current_slice = 0
5837
- self.slice_slider.setValue(0)
5838
- else:
5839
- self.slice_slider.setEnabled(False)
5840
- except:
5841
- pass
6248
+ self.slice_slider.setEnabled(False)
6249
+ except:
6250
+ pass
5842
6251
 
5843
-
5844
- # If this is the first channel loaded, make it active
5845
- if all(not btn.isEnabled() for btn in self.channel_buttons[:channel_index]):
5846
- self.set_active_channel(channel_index)
6252
+
6253
+ # If this is the first channel loaded, make it active
6254
+ if all(not btn.isEnabled() for btn in self.channel_buttons[:channel_index]):
6255
+ self.set_active_channel(channel_index)
5847
6256
 
5848
- if not self.channel_buttons[channel_index].isChecked():
5849
- self.channel_buttons[channel_index].click()
6257
+ if not self.channel_buttons[channel_index].isChecked():
6258
+ self.channel_buttons[channel_index].click()
5850
6259
 
5851
- self.min_max[channel_index][0] = np.min(self.channel_data[channel_index])
5852
- self.min_max[channel_index][1] = np.max(self.channel_data[channel_index])
5853
- self.volume_dict[channel_index] = None #reset volumes
6260
+ self.min_max[channel_index][0] = np.min(self.channel_data[channel_index])
6261
+ self.min_max[channel_index][1] = np.max(self.channel_data[channel_index])
6262
+ self.volume_dict[channel_index] = None #reset volumes
5854
6263
 
5855
6264
  try:
5856
6265
  if assign_shape: #keep original shape tracked to undo resampling.
@@ -5865,7 +6274,13 @@ class ImageViewerWindow(QMainWindow):
5865
6274
 
5866
6275
  if self.shape == self.channel_data[channel_index].shape:
5867
6276
  preserve_zoom = (self.ax.get_xlim(), self.ax.get_ylim())
5868
- self.shape = self.channel_data[channel_index].shape
6277
+ self.shape = (self.channel_data[channel_index].shape[0], self.channel_data[channel_index].shape[1], self.channel_data[channel_index].shape[2])
6278
+ else:
6279
+ if self.shape is not None:
6280
+ self.resizing = True
6281
+ self.shape = (self.channel_data[channel_index].shape[0], self.channel_data[channel_index].shape[1], self.channel_data[channel_index].shape[2])
6282
+ ylim, xlim = (self.shape[1] + 0.5, -0.5), (-0.5, self.shape[2] - 0.5)
6283
+ preserve_zoom = (xlim, ylim)
5869
6284
  if self.shape[1] * self.shape[2] > 3000 * 3000 * self.downsample_factor:
5870
6285
  self.throttle = True
5871
6286
  else:
@@ -5873,8 +6288,6 @@ class ImageViewerWindow(QMainWindow):
5873
6288
 
5874
6289
 
5875
6290
  self.img_height, self.img_width = self.shape[1], self.shape[2]
5876
- self.original_ylim, self.original_xlim = (self.shape[1] + 0.5, -0.5), (-0.5, self.shape[2] - 0.5)
5877
- #print(self.original_xlim)
5878
6291
 
5879
6292
  self.completed_paint_strokes = [] #Reset pending paint operations
5880
6293
  self.current_stroke_points = []
@@ -5884,6 +6297,12 @@ class ImageViewerWindow(QMainWindow):
5884
6297
  self.current_operation = []
5885
6298
  self.current_operation_type = None
5886
6299
 
6300
+ if load_highlight:
6301
+ self.highlight_overlay = n3d.binarize(self.channel_data[4].astype(np.uint8))
6302
+ self.mini_overlay_data = None
6303
+ self.mini_overlay = False
6304
+ self.channel_data[4] = None
6305
+
5887
6306
  if self.pan_mode:
5888
6307
  self.pan_button.click()
5889
6308
  if self.show_channels:
@@ -5892,7 +6311,6 @@ class ImageViewerWindow(QMainWindow):
5892
6311
  elif not end_paint:
5893
6312
 
5894
6313
  self.update_display(reset_resize = reset_resize, preserve_zoom = preserve_zoom)
5895
-
5896
6314
 
5897
6315
  except Exception as e:
5898
6316
 
@@ -5901,7 +6319,7 @@ class ImageViewerWindow(QMainWindow):
5901
6319
  QMessageBox.critical(
5902
6320
  self,
5903
6321
  "Error Loading File",
5904
- f"Failed to load tiff file: {str(e)}"
6322
+ f"Failed to load file: {str(e)}"
5905
6323
  )
5906
6324
 
5907
6325
  def delete_channel(self, channel_index, called = True, update = True):
@@ -6103,12 +6521,9 @@ class ImageViewerWindow(QMainWindow):
6103
6521
  def update_slice(self):
6104
6522
  """Queue a slice update when slider moves."""
6105
6523
  # Store current view settings
6106
- if not self.resizing:
6107
- current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
6108
- current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
6109
- else:
6110
- current_xlim = None
6111
- current_ylim = None
6524
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
6525
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
6526
+
6112
6527
 
6113
6528
  # Store the pending slice and view settings
6114
6529
  self.pending_slice = (self.slice_slider.value(), (current_xlim, current_ylim))
@@ -6130,15 +6545,19 @@ class ImageViewerWindow(QMainWindow):
6130
6545
  self.pm.convert_virtual_strokes_to_data()
6131
6546
  self.current_slice = slice_value
6132
6547
  if self.preview:
6548
+ self.highlight_overlay = None
6549
+ self.mini_overlay_data = None
6550
+ self.mini_overlay = False
6133
6551
  self.create_highlight_overlay_slice(self.targs, bounds=self.bounds)
6134
6552
  elif self.mini_overlay == True: #If we are rendering the highlight overlay for selected values one at a time.
6135
6553
  self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
6136
- if not self.hold_update:
6137
- self.update_display(preserve_zoom=view_settings)
6138
- else:
6139
- self.hold_update = False
6140
- #if self.machine_window is not None:
6141
- #self.machine_window.poke_segmenter()
6554
+
6555
+ if self.resizing:
6556
+ print('hello')
6557
+ self.highlight_overlay = None
6558
+ view_settings = ((-0.5, self.shape[2] - 0.5), (self.shape[1] - 0.5, -0.5))
6559
+ self.resizing = False
6560
+ self.update_display(preserve_zoom=view_settings)
6142
6561
  if self.pan_mode:
6143
6562
  self.pan_button.click()
6144
6563
  self.pending_slice = None
@@ -6155,7 +6574,6 @@ class ImageViewerWindow(QMainWindow):
6155
6574
  self.channel_brightness[channel_index]['max'] = max_val / 65535
6156
6575
  self.update_display(preserve_zoom = (current_xlim, current_ylim))
6157
6576
 
6158
-
6159
6577
  def update_display(self, preserve_zoom=None, dims=None, called=False, reset_resize=False, skip=False):
6160
6578
  """Optimized display update with view-based cropping for performance."""
6161
6579
  try:
@@ -6175,8 +6593,6 @@ class ImageViewerWindow(QMainWindow):
6175
6593
  if self.resume:
6176
6594
  self.machine_window.segmentation_worker.resume()
6177
6595
  self.resume = False
6178
- if self.prev_down != self.downsample_factor:
6179
- self.validate_downsample_input(text = self.prev_down)
6180
6596
 
6181
6597
  if self.static_background is not None:
6182
6598
  # Your existing virtual strokes conversion logic
@@ -6233,10 +6649,13 @@ class ImageViewerWindow(QMainWindow):
6233
6649
  for img in list(self.ax.get_images()):
6234
6650
  img.remove()
6235
6651
  # Clear measurement points
6236
- for artist in self.measurement_artists:
6237
- artist.remove()
6238
- self.measurement_artists.clear()
6239
-
6652
+ if hasattr(self, 'measurement_artists'):
6653
+ for artist in self.measurement_artists:
6654
+ try:
6655
+ artist.remove()
6656
+ except:
6657
+ pass # Artist might already be removed
6658
+ self.measurement_artists = [] # Reset the list
6240
6659
  # Determine the current view bounds (either from preserve_zoom or current state)
6241
6660
  if preserve_zoom:
6242
6661
  current_xlim, current_ylim = preserve_zoom
@@ -6303,7 +6722,6 @@ class ImageViewerWindow(QMainWindow):
6303
6722
  return cropped[::factor, ::factor, :]
6304
6723
  else:
6305
6724
  return cropped
6306
-
6307
6725
 
6308
6726
  # Update channel images efficiently with cropping and downsampling
6309
6727
  for channel in range(4):
@@ -6378,13 +6796,10 @@ class ImageViewerWindow(QMainWindow):
6378
6796
 
6379
6797
  im = self.ax.imshow(normalized_image, alpha=0.7, cmap=custom_cmap,
6380
6798
  vmin=0, vmax=1, extent=crop_extent)
6381
-
6382
6799
  # Handle preview, overlays, and measurements (apply cropping here too)
6383
- #if self.preview and not called:
6384
- # self.create_highlight_overlay_slice(self.targs, bounds=self.bounds)
6385
6800
 
6386
6801
  # Overlay handling (optimized with cropping and downsampling)
6387
- if self.mini_overlay and self.highlight and self.machine_window is None:
6802
+ if self.mini_overlay and self.highlight and self.machine_window is None and not self.preview:
6388
6803
  highlight_cmap = LinearSegmentedColormap.from_list('highlight', [(0, 0, 0, 0), (1, 1, 0, 1)])
6389
6804
  display_overlay = crop_and_downsample_image(
6390
6805
  self.mini_overlay_data, y_min_padded, y_max_padded,
@@ -6403,34 +6818,88 @@ class ImageViewerWindow(QMainWindow):
6403
6818
  [(0, 0, 0, 0), (1, 1, 0, 1), (0, 0.7, 1, 1)])
6404
6819
  self.ax.imshow(display_highlight, cmap=highlight_cmap, vmin=0, vmax=2, alpha=0.3, extent=crop_extent)
6405
6820
 
6406
- # Redraw measurement points efficiently (no cropping needed - these are vector graphics)
6821
+ # Redraw measurement points efficiently
6407
6822
  # Only draw points that are within the visible region for additional performance
6408
- for point in self.measurement_points:
6409
- x1, y1, z1 = point['point1']
6410
- x2, y2, z2 = point['point2']
6411
- pair_idx = point['pair_index']
6412
-
6413
- # Check if points are in visible region
6414
- point1_visible = (z1 == self.current_slice and
6415
- current_xlim[0] <= x1 <= current_xlim[1] and
6416
- current_ylim[1] <= y1 <= current_ylim[0])
6417
- point2_visible = (z2 == self.current_slice and
6418
- current_xlim[0] <= x2 <= current_xlim[1] and
6419
- current_ylim[1] <= y2 <= current_ylim[0])
6420
-
6421
- if point1_visible:
6422
- pt1 = self.ax.plot(x1, y1, 'yo', markersize=8)[0]
6423
- txt1 = self.ax.text(x1, y1+5, str(pair_idx), color='white', ha='center', va='bottom')
6424
- self.measurement_artists.extend([pt1, txt1])
6823
+
6824
+ if hasattr(self, 'measurement_points') and self.measurement_points:
6825
+ for point in self.measurement_points:
6826
+ x1, y1, z1 = point['point1']
6827
+ x2, y2, z2 = point['point2']
6828
+ pair_idx = point['pair_index']
6829
+ point_type = point.get('type', 'distance') # Default to distance for backward compatibility
6425
6830
 
6426
- if point2_visible:
6427
- pt2 = self.ax.plot(x2, y2, 'yo', markersize=8)[0]
6428
- txt2 = self.ax.text(x2, y2+5, str(pair_idx), color='white', ha='center', va='bottom')
6429
- self.measurement_artists.extend([pt2, txt2])
6831
+ # Determine colors based on type
6832
+ if point_type == 'angle':
6833
+ marker_color = 'go'
6834
+ text_color = 'green'
6835
+ line_color = 'g--'
6836
+ else: # distance
6837
+ marker_color = 'yo'
6838
+ text_color = 'yellow'
6839
+ line_color = 'r--'
6840
+
6841
+ # Check if points are in visible region and on current slice
6842
+ point1_visible = (z1 == self.current_slice and
6843
+ current_xlim[0] <= x1 <= current_xlim[1] and
6844
+ current_ylim[1] <= y1 <= current_ylim[0])
6845
+ point2_visible = (z2 == self.current_slice and
6846
+ current_xlim[0] <= x2 <= current_xlim[1] and
6847
+ current_ylim[1] <= y2 <= current_ylim[0])
6848
+
6849
+ # Always draw individual points if they're on the current slice (even without lines)
6850
+ if point1_visible:
6851
+ pt1 = self.ax.plot(x1, y1, marker_color, markersize=8)[0]
6852
+ txt1 = self.ax.text(x1, y1+5, str(pair_idx), color=text_color, ha='center', va='bottom')
6853
+ self.measurement_artists.extend([pt1, txt1])
6854
+
6855
+ if point2_visible:
6856
+ pt2 = self.ax.plot(x2, y2, marker_color, markersize=8)[0]
6857
+ txt2 = self.ax.text(x2, y2+5, str(pair_idx), color=text_color, ha='center', va='bottom')
6858
+ self.measurement_artists.extend([pt2, txt2])
6859
+
6860
+ # Only draw connecting line if both points are on the same slice AND visible
6861
+ if z1 == z2 == self.current_slice and (point1_visible or point2_visible):
6862
+ line = self.ax.plot([x1, x2], [y1, y2], line_color, alpha=0.5)[0]
6863
+ self.measurement_artists.append(line)
6864
+
6865
+ # Also handle any partial measurements in progress (individual points without pairs yet)
6866
+ # This shows individual points even when a measurement isn't complete
6867
+ if hasattr(self, 'current_point') and self.current_point is not None:
6868
+ x, y, z = self.current_point
6869
+ if z == self.current_slice:
6870
+ if hasattr(self, 'measurement_mode') and self.measurement_mode == "angle":
6871
+ # Show green for angle mode
6872
+ pt = self.ax.plot(x, y, 'go', markersize=8)[0]
6873
+ if hasattr(self, 'current_trio_index'):
6874
+ txt = self.ax.text(x, y+5, f"A{self.current_trio_index}", color='green', ha='center', va='bottom')
6875
+ else:
6876
+ txt = self.ax.text(x, y+5, "A", color='green', ha='center', va='bottom')
6877
+ else:
6878
+ # Show yellow for distance mode (default)
6879
+ pt = self.ax.plot(x, y, 'yo', markersize=8)[0]
6880
+ if hasattr(self, 'current_pair_index'):
6881
+ txt = self.ax.text(x, y+5, f"D{self.current_pair_index}", color='yellow', ha='center', va='bottom')
6882
+ else:
6883
+ txt = self.ax.text(x, y+5, "D", color='yellow', ha='center', va='bottom')
6884
+ self.measurement_artists.extend([pt, txt])
6885
+
6886
+ # Handle second point in angle measurements
6887
+ if hasattr(self, 'current_second_point') and self.current_second_point is not None:
6888
+ x, y, z = self.current_second_point
6889
+ if z == self.current_slice:
6890
+ pt = self.ax.plot(x, y, 'go', markersize=8)[0]
6891
+ if hasattr(self, 'current_trio_index'):
6892
+ txt = self.ax.text(x, y+5, f"B{self.current_trio_index}", color='green', ha='center', va='bottom')
6893
+ else:
6894
+ txt = self.ax.text(x, y+5, "B", color='green', ha='center', va='bottom')
6895
+ self.measurement_artists.extend([pt, txt])
6430
6896
 
6431
- if z1 == z2 == self.current_slice and (point1_visible or point2_visible):
6432
- line = self.ax.plot([x1, x2], [y1, y2], 'r--', alpha=0.5)[0]
6433
- self.measurement_artists.append(line)
6897
+ # Draw line from A to B if both are on current slice
6898
+ if (hasattr(self, 'current_point') and self.current_point is not None and
6899
+ self.current_point[2] == self.current_slice):
6900
+ x1, y1, z1 = self.current_point
6901
+ line = self.ax.plot([x1, x], [y1, y], 'g--', alpha=0.7)[0]
6902
+ self.measurement_artists.append(line)
6434
6903
 
6435
6904
  # Store current view limits for next update
6436
6905
  self.ax._current_xlim = current_xlim
@@ -6449,15 +6918,12 @@ class ImageViewerWindow(QMainWindow):
6449
6918
  if reset_resize:
6450
6919
  self.resizing = False
6451
6920
 
6452
- # Use draw_idle for better performance
6921
+ # draw_idle
6453
6922
  self.canvas.draw_idle()
6454
6923
 
6924
+
6455
6925
  except Exception as e:
6456
6926
  pass
6457
- #import traceback
6458
- #print(traceback.format_exc())
6459
-
6460
-
6461
6927
 
6462
6928
 
6463
6929
  def get_channel_image(self, channel):
@@ -6567,10 +7033,18 @@ class ImageViewerWindow(QMainWindow):
6567
7033
  dialog = RadDialog(self)
6568
7034
  dialog.exec()
6569
7035
 
7036
+ def show_branchstat_dialog(self):
7037
+ dialog = BranchStatDialog(self)
7038
+ dialog.exec()
7039
+
6570
7040
  def show_interaction_dialog(self):
6571
7041
  dialog = InteractionDialog(self)
6572
7042
  dialog.exec()
6573
7043
 
7044
+ def show_violin_dialog(self):
7045
+ dialog = ViolinDialog(self)
7046
+ dialog.show()
7047
+
6574
7048
  def show_degree_dialog(self):
6575
7049
  dialog = DegreeDialog(self)
6576
7050
  dialog.exec()
@@ -7904,11 +8378,6 @@ class MergeNodeIdDialog(QDialog):
7904
8378
  self.mode_selector.addItems(["Auto-Binarize(Otsu)/Presegmented", "Manual (Interactive Thresholder)"])
7905
8379
  self.mode_selector.setCurrentIndex(1) # Default to Mode 1
7906
8380
  layout.addRow("Binarization Strategy:", self.mode_selector)
7907
-
7908
- self.umap = QPushButton("If using manual threshold: Also generate identity UMAP?")
7909
- self.umap.setCheckable(True)
7910
- self.umap.setChecked(True)
7911
- layout.addWidget(self.umap)
7912
8381
 
7913
8382
  self.include = QPushButton("Include When a Node is Negative for an ID?")
7914
8383
  self.include.setCheckable(True)
@@ -7965,7 +8434,7 @@ class MergeNodeIdDialog(QDialog):
7965
8434
  z_scale = float(self.z_scale.text()) if self.z_scale.text().strip() else 1
7966
8435
  data = self.parent().channel_data[0]
7967
8436
  include = self.include.isChecked()
7968
- umap = self.umap.isChecked()
8437
+ umap = True
7969
8438
 
7970
8439
  if data is None:
7971
8440
  return
@@ -8101,15 +8570,13 @@ class MergeNodeIdDialog(QDialog):
8101
8570
  result = {key: np.array([d[key] for d in id_dicts]) for key in all_keys}
8102
8571
 
8103
8572
 
8104
- self.parent().format_for_upperright_table(result, 'NodeID', good_list, 'Mean Intensity')
8105
- if umap:
8106
- my_network.identity_umap(result)
8573
+ self.parent().format_for_upperright_table(result, 'NodeID', good_list, 'Mean Intensity (Save this Table for "Analyze -> Stats -> Show Violins")')
8107
8574
 
8108
8575
 
8109
8576
  QMessageBox.information(
8110
8577
  self,
8111
8578
  "Success",
8112
- "Node Identities Merged. New IDs represent presence of corresponding img foreground with +, absence with -. Please save your new identities as csv, then use File -> Load -> Load From Excel Helper to bulk search and rename desired combinations. (Press Help [above] for more info)"
8579
+ "Node Identities Merged. New IDs represent presence of corresponding img foreground with +, absence with -. If desired, please save your new identities as csv, then use File -> Load -> Load From Excel Helper to bulk search and rename desired combinations. If desired, please save the outputted mean intensity table to use with 'Analyze -> Stats -> Show Violins'. (Press Help [above] for more info)"
8113
8580
  )
8114
8581
 
8115
8582
  self.accept()
@@ -8131,6 +8598,89 @@ class MergeNodeIdDialog(QDialog):
8131
8598
  print(traceback.format_exc())
8132
8599
  #print(f"Error: {e}")
8133
8600
 
8601
+ class MultiChanDialog(QDialog):
8602
+
8603
+ def __init__(self, parent=None, data = None):
8604
+
8605
+ super().__init__(parent)
8606
+ self.setWindowTitle("Channel Loading")
8607
+ self.setModal(False)
8608
+
8609
+ layout = QFormLayout(self)
8610
+
8611
+ self.data = data
8612
+
8613
+ self.nodes = QComboBox()
8614
+ self.edges = QComboBox()
8615
+ self.overlay1 = QComboBox()
8616
+ self.overlay2 = QComboBox()
8617
+ options = ["None"]
8618
+ for i in range(self.data.shape[0]):
8619
+ options.append(str(i))
8620
+ self.nodes.addItems(options)
8621
+ self.edges.addItems(options)
8622
+ self.overlay1.addItems(options)
8623
+ self.overlay2.addItems(options)
8624
+ self.nodes.setCurrentIndex(0)
8625
+ self.edges.setCurrentIndex(0)
8626
+ self.overlay1.setCurrentIndex(0)
8627
+ self.overlay2.setCurrentIndex(0)
8628
+ layout.addRow("Load this channel into nodes?", self.nodes)
8629
+ layout.addRow("Load this channel into edges?", self.edges)
8630
+ layout.addRow("Load this channel into overlay1?", self.overlay1)
8631
+ layout.addRow("Load this channel into overlay2?", self.overlay2)
8632
+
8633
+ run_button = QPushButton("Load Channels")
8634
+ run_button.clicked.connect(self.run)
8635
+ layout.addWidget(run_button)
8636
+
8637
+ run_button2 = QPushButton("Save Channels to Directory")
8638
+ run_button2.clicked.connect(self.run2)
8639
+ layout.addWidget(run_button2)
8640
+
8641
+
8642
+ def run(self):
8643
+
8644
+ try:
8645
+ node_chan = int(self.nodes.currentText())
8646
+ self.parent().load_channel(0, self.data[node_chan, :, :, :], data = True)
8647
+ except:
8648
+ pass
8649
+ try:
8650
+ edge_chan = int(self.edges.currentText())
8651
+ self.parent().load_channel(1, self.data[edge_chan, :, :, :], data = True)
8652
+ except:
8653
+ pass
8654
+ try:
8655
+ overlay1_chan = int(self.overlay1.currentText())
8656
+ self.parent().load_channel(2, self.data[overlay1_chan, :, :, :], data = True)
8657
+ except:
8658
+ pass
8659
+ try:
8660
+ overlay2_chan = int(self.overlay2.currentText())
8661
+ self.parent().load_channel(3, self.data[overlay2_chan, :, :, :], data = True)
8662
+ except:
8663
+ pass
8664
+
8665
+ def run2(self):
8666
+
8667
+ try:
8668
+ # First let user select parent directory
8669
+ parent_dir = QFileDialog.getExistingDirectory(
8670
+ self,
8671
+ "Select Location to Save Channels",
8672
+ "",
8673
+ QFileDialog.Option.ShowDirsOnly
8674
+ )
8675
+
8676
+ for i in range(self.data.shape[0]):
8677
+ try:
8678
+ tifffile.imwrite(f'{parent_dir}/C{i}.tif', self.data[i, :, :, :])
8679
+ except:
8680
+ continue
8681
+ except:
8682
+ pass
8683
+
8134
8684
 
8135
8685
  class Show3dDialog(QDialog):
8136
8686
  def __init__(self, parent=None):
@@ -8198,6 +8748,9 @@ class Show3dDialog(QDialog):
8198
8748
  if visible:
8199
8749
  arrays_4d.append(channel)
8200
8750
 
8751
+ if self.parent().thresh_window_ref is not None:
8752
+ self.parent().thresh_window_ref.make_full_highlight()
8753
+
8201
8754
  if self.parent().highlight_overlay is not None or self.parent().mini_overlay_data is not None:
8202
8755
  if self.parent().mini_overlay == True:
8203
8756
  self.parent().create_highlight_overlay(node_indices = self.parent().clicked_values['nodes'], edge_indices = self.parent().clicked_values['edges'])
@@ -8209,6 +8762,11 @@ class Show3dDialog(QDialog):
8209
8762
  self.accept()
8210
8763
 
8211
8764
  except Exception as e:
8765
+ QMessageBox.critical(
8766
+ self,
8767
+ "Error",
8768
+ f"Error showing 3D: {str(e)}\nNote: You may need to install napari first - in your environment, please call 'pip install napari'"
8769
+ )
8212
8770
  print(f"Error: {e}")
8213
8771
  import traceback
8214
8772
  print(traceback.format_exc())
@@ -8224,6 +8782,9 @@ class NetOverlayDialog(QDialog):
8224
8782
 
8225
8783
  layout = QFormLayout(self)
8226
8784
 
8785
+ self.downsample = QLineEdit("")
8786
+ layout.addRow("Downsample Factor While Drawing? (Int - Makes the outputted lines larger):", self.downsample)
8787
+
8227
8788
  # Add Run button
8228
8789
  run_button = QPushButton("Generate (Will go to Overlay 1)")
8229
8790
  run_button.clicked.connect(self.netoverlay)
@@ -8240,7 +8801,16 @@ class NetOverlayDialog(QDialog):
8240
8801
  if my_network.node_centroids is None:
8241
8802
  return
8242
8803
 
8243
- my_network.network_overlay = my_network.draw_network()
8804
+ try:
8805
+ downsample = float(self.downsample.text()) if self.downsample.text() else None
8806
+ except ValueError:
8807
+ downsample = None
8808
+
8809
+ my_network.network_overlay = my_network.draw_network(down_factor = downsample)
8810
+
8811
+ if downsample is not None:
8812
+ my_network.network_overlay = n3d.upsample_with_padding(my_network.network_overlay, original_shape = self.parent().shape)
8813
+
8244
8814
 
8245
8815
  self.parent().load_channel(2, channel_data = my_network.network_overlay, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
8246
8816
 
@@ -8267,6 +8837,9 @@ class IdOverlayDialog(QDialog):
8267
8837
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
8268
8838
  layout.addRow("Execution Mode:", self.mode_selector)
8269
8839
 
8840
+ self.downsample = QLineEdit("")
8841
+ layout.addRow("Downsample Factor While Drawing? (Int - Makes the outputted numbers larger):", self.downsample)
8842
+
8270
8843
  # Add Run button
8271
8844
  run_button = QPushButton("Generate (Will go to Overlay 2)")
8272
8845
  run_button.clicked.connect(self.idoverlay)
@@ -8274,38 +8847,51 @@ class IdOverlayDialog(QDialog):
8274
8847
 
8275
8848
  def idoverlay(self):
8276
8849
 
8277
- accepted_mode = self.mode_selector.currentIndex()
8850
+ try:
8278
8851
 
8279
- if accepted_mode == 0:
8852
+ accepted_mode = self.mode_selector.currentIndex()
8280
8853
 
8281
- if my_network.node_centroids is None:
8854
+ try:
8855
+ downsample = float(self.downsample.text()) if self.downsample.text() else None
8856
+ except ValueError:
8857
+ downsample = None
8282
8858
 
8283
- self.parent().show_centroid_dialog()
8859
+ if accepted_mode == 0:
8284
8860
 
8285
- if my_network.node_centroids is None:
8286
- return
8861
+ if my_network.node_centroids is None:
8287
8862
 
8288
- elif accepted_mode == 1:
8863
+ self.parent().show_centroid_dialog()
8289
8864
 
8290
- if my_network.edge_centroids is None:
8865
+ if my_network.node_centroids is None:
8866
+ return
8291
8867
 
8292
- self.parent().show_centroid_dialog()
8868
+ elif accepted_mode == 1:
8293
8869
 
8294
- if my_network.edge_centroids is None:
8295
- return
8870
+ if my_network.edge_centroids is None:
8296
8871
 
8297
- if accepted_mode == 0:
8872
+ self.parent().show_centroid_dialog()
8298
8873
 
8299
- my_network.id_overlay = my_network.draw_node_indices()
8874
+ if my_network.edge_centroids is None:
8875
+ return
8300
8876
 
8301
- elif accepted_mode == 1:
8877
+ if accepted_mode == 0:
8302
8878
 
8303
- my_network.id_overlay = my_network.draw_edge_indices()
8879
+ my_network.id_overlay = my_network.draw_node_indices(down_factor = downsample)
8304
8880
 
8881
+ elif accepted_mode == 1:
8305
8882
 
8306
- self.parent().load_channel(3, channel_data = my_network.id_overlay, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
8883
+ my_network.id_overlay = my_network.draw_edge_indices(down_factor = downsample)
8884
+
8885
+ if downsample is not None:
8886
+ my_network.id_overlay = n3d.upsample_with_padding(my_network.id_overlay, original_shape = self.parent().shape)
8887
+
8888
+ self.parent().load_channel(3, channel_data = my_network.id_overlay, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
8889
+
8890
+ self.accept()
8891
+
8892
+ except:
8893
+ print(f"Error with Overlay Generation: {e}")
8307
8894
 
8308
- self.accept()
8309
8895
 
8310
8896
  class ColorOverlayDialog(QDialog):
8311
8897
 
@@ -8327,7 +8913,7 @@ class ColorOverlayDialog(QDialog):
8327
8913
  layout.addRow("Execution Mode:", self.mode_selector)
8328
8914
 
8329
8915
  self.down_factor = QLineEdit("")
8330
- layout.addRow("down_factor (for speeding up overlay generation - optional):", self.down_factor)
8916
+ layout.addRow("down_factor (int - for speeding up overlay generation - optional):", self.down_factor)
8331
8917
 
8332
8918
  # Add Run button
8333
8919
  run_button = QPushButton("Generate (Will go to Overlay 2)")
@@ -8481,11 +9067,6 @@ class NetShowDialog(QDialog):
8481
9067
  self.weighted.setCheckable(True)
8482
9068
  self.weighted.setChecked(True)
8483
9069
  layout.addRow("Use Weighted Network (Only for community graphs):", self.weighted)
8484
-
8485
- # Optional saving:
8486
- self.directory = QLineEdit()
8487
- self.directory.setPlaceholderText("Does not save when empty")
8488
- layout.addRow("Output Directory:", self.directory)
8489
9070
 
8490
9071
  # Add Run button
8491
9072
  run_button = QPushButton("Show Network")
@@ -8501,7 +9082,7 @@ class NetShowDialog(QDialog):
8501
9082
  self.parent().show_centroid_dialog()
8502
9083
  accepted_mode = self.mode_selector.currentIndex() # Convert to 1-based index
8503
9084
  # Get directory (None if empty)
8504
- directory = self.directory.text() if self.directory.text() else None
9085
+ directory = None
8505
9086
 
8506
9087
  weighted = self.weighted.isChecked()
8507
9088
 
@@ -8544,7 +9125,7 @@ class PartitionDialog(QDialog):
8544
9125
 
8545
9126
  # Add mode selection dropdown
8546
9127
  self.mode_selector = QComboBox()
8547
- self.mode_selector.addItems(["Label Propogation", "Louvain"])
9128
+ self.mode_selector.addItems(["Louvain", "Label Propogation"])
8548
9129
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
8549
9130
  layout.addRow("Execution Mode:", self.mode_selector)
8550
9131
 
@@ -8567,6 +9148,10 @@ class PartitionDialog(QDialog):
8567
9148
  self.parent().prev_coms = None
8568
9149
 
8569
9150
  accepted_mode = self.mode_selector.currentIndex()
9151
+ if accepted_mode == 0: #I switched where these are in the selection box
9152
+ accepted_mode = 1
9153
+ elif accepted_mode == 1:
9154
+ accepted_mode = 0
8570
9155
  weighted = self.weighted.isChecked()
8571
9156
  dostats = self.stats.isChecked()
8572
9157
 
@@ -8738,7 +9323,7 @@ class ComNeighborDialog(QDialog):
8738
9323
 
8739
9324
  mode = self.mode.currentIndex()
8740
9325
 
8741
- seed = float(self.seed.text()) if self.seed.text().strip() else 42
9326
+ seed = int(self.seed.text()) if self.seed.text().strip() else 42
8742
9327
 
8743
9328
  limit = int(self.limit.text()) if self.limit.text().strip() else None
8744
9329
 
@@ -8843,9 +9428,6 @@ class RadialDialog(QDialog):
8843
9428
  self.distance = QLineEdit("50")
8844
9429
  layout.addRow("Bucket Distance for Searching For Node Neighbors (automatically scaled by xy and z scales):", self.distance)
8845
9430
 
8846
- self.directory = QLineEdit("")
8847
- layout.addRow("Output Directory:", self.directory)
8848
-
8849
9431
  # Add Run button
8850
9432
  run_button = QPushButton("Get Radial Distribution")
8851
9433
  run_button.clicked.connect(self.radial)
@@ -8857,7 +9439,7 @@ class RadialDialog(QDialog):
8857
9439
 
8858
9440
  distance = float(self.distance.text()) if self.distance.text().strip() else 50
8859
9441
 
8860
- directory = str(self.distance.text()) if self.directory.text().strip() else None
9442
+ directory = None
8861
9443
 
8862
9444
  if my_network.node_centroids is None:
8863
9445
  self.parent().show_centroid_dialog()
@@ -8888,12 +9470,16 @@ class NearNeighDialog(QDialog):
8888
9470
  if my_network.node_identities is not None:
8889
9471
 
8890
9472
  self.root = QComboBox()
8891
- self.root.addItems(list(set(my_network.node_identities.values())))
9473
+ roots = list(set(my_network.node_identities.values()))
9474
+ roots.sort()
9475
+ roots.append("All (Excluding Targets)")
9476
+ self.root.addItems(roots)
8892
9477
  self.root.setCurrentIndex(0)
8893
9478
  identities_layout.addRow("Root Identity to Search for Neighbor's IDs?", self.root)
8894
9479
 
8895
9480
  self.targ = QComboBox()
8896
9481
  neighs = list(set(my_network.node_identities.values()))
9482
+ neighs.sort()
8897
9483
  neighs.append("All Others (Excluding Self)")
8898
9484
  self.targ.addItems(neighs)
8899
9485
  self.targ.setCurrentIndex(0)
@@ -8931,6 +9517,11 @@ class NearNeighDialog(QDialog):
8931
9517
  self.numpy.setChecked(False)
8932
9518
  self.numpy.clicked.connect(self.toggle_map)
8933
9519
  heatmap_layout.addRow("Overlay:", self.numpy)
9520
+
9521
+ self.mode = QComboBox()
9522
+ self.mode.addItems(["Anywhere", "Within Masked Bounds of Edges", "Within Masked Bounds of Overlay1", "Within Masked Bounds of Overlay2"])
9523
+ self.mode.setCurrentIndex(0)
9524
+ heatmap_layout.addRow("For heatmap, measure theoretical point distribution how?", self.mode)
8934
9525
 
8935
9526
  main_layout.addWidget(heatmap_group)
8936
9527
 
@@ -9018,35 +9609,52 @@ class NearNeighDialog(QDialog):
9018
9609
  except:
9019
9610
  targ = None
9020
9611
 
9612
+ if root == "All (Excluding Targets)" and targ == 'All Others (Excluding Self)':
9613
+ root = None
9614
+ targ = None
9615
+
9616
+ mode = self.mode.currentIndex()
9617
+
9618
+ if mode == 0:
9619
+ mask = None
9620
+ else:
9621
+ try:
9622
+ mask = self.parent().channel_data[mode] != 0
9623
+ except:
9624
+ print("Could not binarize mask")
9625
+ mask = None
9626
+
9021
9627
  heatmap = self.map.isChecked()
9022
9628
  threed = self.threed.isChecked()
9023
9629
  numpy = self.numpy.isChecked()
9024
9630
  num = int(self.num.text()) if self.num.text().strip() else 1
9025
9631
  quant = self.quant.isChecked()
9026
9632
  centroids = self.centroids.isChecked()
9633
+
9027
9634
  if not centroids:
9635
+ print("Using 1 nearest neighbor due to not using centroids")
9028
9636
  num = 1
9029
9637
 
9030
9638
  if root is not None and targ is not None:
9031
9639
  title = f"Nearest {num} Neighbor(s) Distance of {targ} from {root}"
9032
- header = f"Shortest Distance to Closest {num} {targ}(s)"
9640
+ header = f"Avg Shortest Distance to Closest {num} {targ}(s)"
9033
9641
  header2 = f"{root} Node ID"
9034
9642
  header3 = f'Theoretical Uniform Distance to Closest {num} {targ}(s)'
9035
9643
  else:
9036
9644
  title = f"Nearest {num} Neighbor(s) Distance Between Nodes"
9037
- header = f"Shortest Distance to Closest {num} Nodes"
9645
+ header = f"Avg Shortest Distance to Closest {num} Nodes"
9038
9646
  header2 = "Root Node ID"
9039
9647
  header3 = f'Simulated Theoretical Uniform Distance to Closest {num} Nodes'
9040
9648
 
9041
- if centroids and my_network.node_centroids is None:
9649
+ if my_network.node_centroids is None:
9042
9650
  self.parent().show_centroid_dialog()
9043
9651
  if my_network.node_centroids is None:
9044
9652
  return
9045
9653
 
9046
9654
  if not numpy:
9047
- avg, output, quant_overlay, pred = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num, heatmap = heatmap, threed = threed, quant = quant, centroids = centroids)
9655
+ avg, output, quant_overlay, pred = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num, heatmap = heatmap, threed = threed, quant = quant, centroids = centroids, mask = mask)
9048
9656
  else:
9049
- avg, output, overlay, quant_overlay, pred = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num, heatmap = heatmap, threed = threed, numpy = True, quant = quant, centroids = centroids)
9657
+ avg, output, overlay, quant_overlay, pred = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num, heatmap = heatmap, threed = threed, numpy = True, quant = quant, centroids = centroids, mask = mask)
9050
9658
  self.parent().load_channel(3, overlay, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
9051
9659
 
9052
9660
  if quant_overlay is not None:
@@ -9115,9 +9723,6 @@ class NeighborIdentityDialog(QDialog):
9115
9723
  else:
9116
9724
  self.root = None
9117
9725
 
9118
- self.directory = QLineEdit("")
9119
- layout.addRow("Output Directory:", self.directory)
9120
-
9121
9726
  self.mode = QComboBox()
9122
9727
  self.mode.addItems(["From Network - Based on Absolute Connectivity", "Use Labeled Nodes - Based on Morphological Neighborhood Densities"])
9123
9728
  self.mode.setCurrentIndex(0)
@@ -9145,7 +9750,7 @@ class NeighborIdentityDialog(QDialog):
9145
9750
  except:
9146
9751
  pass
9147
9752
 
9148
- directory = self.directory.text() if self.directory.text().strip() else None
9753
+ directory = None
9149
9754
 
9150
9755
  mode = self.mode.currentIndex()
9151
9756
 
@@ -9599,6 +10204,22 @@ class InteractionDialog(QDialog):
9599
10204
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
9600
10205
  layout.addRow("Execution Mode:", self.mode_selector)
9601
10206
 
10207
+ self.length = QPushButton("Return Lengths")
10208
+ self.length.setCheckable(True)
10209
+ self.length.setChecked(False)
10210
+ layout.addRow("(Will Skeletonize the Edge Mirror and use that to calculate adjacent length of edges, as opposed to default volumes):", self.length)
10211
+
10212
+ self.auto = QPushButton("Auto")
10213
+ self.auto.setCheckable(True)
10214
+ try:
10215
+ if self.parent().shape[0] == 1:
10216
+ self.auto.setChecked(False)
10217
+ else:
10218
+ self.auto.setChecked(True)
10219
+ except:
10220
+ self.auto.setChecked(False)
10221
+ layout.addRow("(If Above): Attempt to Auto Correct Skeleton Looping:", self.auto)
10222
+
9602
10223
  self.fastdil = QPushButton("Fast Dilate")
9603
10224
  self.fastdil.setCheckable(True)
9604
10225
  self.fastdil.setChecked(False)
@@ -9622,10 +10243,16 @@ class InteractionDialog(QDialog):
9622
10243
 
9623
10244
 
9624
10245
  fastdil = self.fastdil.isChecked()
10246
+ length = self.length.isChecked()
10247
+ auto = self.auto.isChecked()
9625
10248
 
9626
- result = my_network.interactions(search = node_search, cores = accepted_mode, fastdil = fastdil)
10249
+ result = my_network.interactions(search = node_search, cores = accepted_mode, skele = length, length = length, auto = auto, fastdil = fastdil)
10250
+
10251
+ if not length:
10252
+ self.parent().format_for_upperright_table(result, 'Node ID', ['Volume of Nearby Edge (Scaled)', 'Volume of Search Region (Scaled)'], title = 'Node/Edge Interactions')
10253
+ else:
10254
+ self.parent().format_for_upperright_table(result, 'Node ID', ['~Length of Nearby Edge (Scaled)', 'Volume of Search Region (Scaled)'], title = 'Node/Edge Interactions')
9627
10255
 
9628
- self.parent().format_for_upperright_table(result, 'Node ID', ['Volume of Nearby Edge (Scaled)', 'Volume of Search Region'], title = 'Node/Edge Interactions')
9629
10256
 
9630
10257
  self.accept()
9631
10258
 
@@ -9637,70 +10264,471 @@ class InteractionDialog(QDialog):
9637
10264
  print(f"Error finding interactions: {e}")
9638
10265
 
9639
10266
 
9640
- class DegreeDialog(QDialog):
9641
-
10267
+ class ViolinDialog(QDialog):
9642
10268
 
9643
10269
  def __init__(self, parent=None):
9644
10270
 
9645
10271
  super().__init__(parent)
9646
- self.setWindowTitle("Degree Parameters")
9647
- self.setModal(True)
9648
10272
 
9649
- layout = QFormLayout(self)
10273
+ QMessageBox.critical(
10274
+ self,
10275
+ "Notice",
10276
+ "Please select spreadsheet (Should be table output of 'File -> Images -> Node Identities -> Assign Node Identities from Overlap with Other Images'. Make sure to save that table as .csv/.xlsx and then load it here to use this.)"
10277
+ )
9650
10278
 
9651
- layout.addRow("Note:", QLabel(f"This operation will be executed on the image in 'Active Image', unless it is set to edges in which case it will use the nodes. \n (This is because you may want to run it on isolated nodes that have been placed in the Overlay channels)\nWe can draw optional overlays to Overlay 2 as described below:"))
10279
+ try:
10280
+ try:
10281
+ self.df = self.parent().load_file()
10282
+ except:
10283
+ return
9652
10284
 
9653
- # Add mode selection dropdown
9654
- self.mode_selector = QComboBox()
9655
- self.mode_selector.addItems(["Just make table", "Draw degree of node as overlay (literally draws 1, 2, 3, etc... faster)", "Label nodes by degree (nodes will take on the value 1, 2, 3, etc, based on their degree, to export for array based analysis)", "Create Heatmap of Degrees"])
9656
- self.mode_selector.setCurrentIndex(0) # Default to Mode 1
9657
- layout.addRow("Execution Mode:", self.mode_selector)
10285
+ self.backup_df = copy.deepcopy(self.df)
10286
+ try:
10287
+ # Get all identity lists and normalize the dataframe
10288
+ identity_lists = self.get_all_identity_lists()
10289
+ self.df = self.normalize_df_with_identity_centerpoints(self.df, identity_lists)
10290
+ except:
10291
+ pass
9658
10292
 
9659
- self.mask_limiter = QLineEdit("1")
9660
- layout.addRow("Proportion of high degree nodes to keep (ignore if only returning degrees)", self.mask_limiter)
10293
+ self.setWindowTitle("Violin Parameters")
10294
+ self.setModal(False)
9661
10295
 
9662
- self.down_factor = QLineEdit("1")
9663
- layout.addRow("down_factor (for speeding up overlay generation - ignore if only returning degrees:", self.down_factor)
10296
+ layout = QFormLayout(self)
9664
10297
 
9665
- # Add Run button
9666
- run_button = QPushButton("Get Degrees")
9667
- run_button.clicked.connect(self.degs)
9668
- layout.addWidget(run_button)
10298
+ if my_network.node_identities is not None:
10299
+
10300
+ self.idens = QComboBox()
10301
+ all_idens = list(set(my_network.node_identities.values()))
10302
+ idens = []
10303
+ for iden in all_idens:
10304
+ if '[' not in iden:
10305
+ idens.append(iden)
10306
+ idens.sort()
10307
+ idens.insert(0, "None")
10308
+ self.idens.addItems(idens)
10309
+ self.idens.setCurrentIndex(0)
10310
+ layout.addRow("Return Identity Violin Plots?", self.idens)
10311
+
10312
+ if my_network.communities is not None:
10313
+ self.coms = QComboBox()
10314
+ coms = list(set(my_network.communities.values()))
10315
+ coms.sort()
10316
+ coms.insert(0, "None")
10317
+ coms = [str(x) for x in coms]
10318
+ self.coms.addItems(coms)
10319
+ self.coms.setCurrentIndex(0)
10320
+ layout.addRow("Return Neighborhood/Community Violin Plots?", self.coms)
9669
10321
 
9670
- def degs(self):
10322
+ # Add Run button
10323
+ run_button = QPushButton("Show Z-score-like Violin")
10324
+ run_button.clicked.connect(self.run)
10325
+ layout.addWidget(run_button)
9671
10326
 
9672
- try:
10327
+ run_button2 = QPushButton("Show Z-score UMAP")
10328
+ run_button2.clicked.connect(self.run2)
9673
10329
 
9674
- accepted_mode = self.mode_selector.currentIndex()
10330
+ self.mode_selector = QComboBox()
10331
+ self.mode_selector.addItems(["Label UMAP By Identity", "Label UMAP By Neighborhood/Community"])
10332
+ self.mode_selector.setCurrentIndex(0) # Default to Mode 1
10333
+ layout.addRow("Execution Mode:", self.mode_selector)
9675
10334
 
9676
- if accepted_mode == 3:
9677
- degree_dict, overlay = my_network.get_degrees(heatmap = True)
9678
- self.parent().format_for_upperright_table(degree_dict, 'Node ID', 'Degree', title = 'Degrees of nodes')
9679
- self.parent().load_channel(3, channel_data = overlay, data = True)
9680
- self.accept()
9681
- return
10335
+ layout.addRow(self.mode_selector, run_button2)
9682
10336
 
10337
+ # Button in left column, input in right column
10338
+ run_button3 = QPushButton("Assign Neighborhoods via KMeans Clustering")
10339
+ run_button3.clicked.connect(self.run3)
9683
10340
 
9684
- try:
9685
- down_factor = float(self.down_factor.text()) if self.down_factor.text() else 1
9686
- except ValueError:
9687
- down_factor = 1
10341
+ self.kmeans_num_input = QLineEdit()
10342
+ self.kmeans_num_input.setPlaceholderText("Auto (num neighborhoods)")
10343
+ self.kmeans_num_input.setMaximumWidth(150)
10344
+ from PyQt6.QtGui import QIntValidator
10345
+ self.kmeans_num_input.setValidator(QIntValidator(1, 1000))
9688
10346
 
9689
- try:
9690
- mask_limiter = float(self.mask_limiter.text()) if self.mask_limiter.text() else 1
9691
- except ValueError:
9692
- mask_limiter = 1
10347
+ # QFormLayout's addRow takes (label/widget, field/widget)
10348
+ layout.addRow(run_button3, self.kmeans_num_input)
9693
10349
 
9694
- if self.parent().active_channel == 1:
9695
- active_data = self.parent().channel_data[0]
9696
- else:
9697
- # Get the active channel data from parent
9698
- active_data = self.parent().channel_data[self.parent().active_channel]
9699
- if active_data is None:
9700
- raise ValueError("No active image selected")
10350
+ except:
10351
+ import traceback
10352
+ print(traceback.format_exc())
10353
+ QTimer.singleShot(0, self.close)
9701
10354
 
9702
- if my_network.node_centroids is None and accepted_mode > 0:
9703
- self.parent().show_centroid_dialog()
10355
+ def get_all_identity_lists(self):
10356
+ """
10357
+ Get all identity lists for normalization purposes.
10358
+
10359
+ Returns:
10360
+ dict: Dictionary where keys are identity names and values are lists of node IDs
10361
+ """
10362
+ identity_lists = {}
10363
+
10364
+ # Get all unique identities
10365
+ all_identities = set()
10366
+ import ast
10367
+ for item in my_network.node_identities:
10368
+ try:
10369
+ parse = ast.literal_eval(my_network.node_identities[item])
10370
+ if isinstance(parse, (list, tuple, set)):
10371
+ all_identities.update(parse)
10372
+ else:
10373
+ all_identities.add(str(parse))
10374
+ except:
10375
+ all_identities.add(str(my_network.node_identities[item]))
10376
+
10377
+ # For each identity, get the list of nodes that have it
10378
+ for identity in all_identities:
10379
+ iden_list = []
10380
+ for item in my_network.node_identities:
10381
+ try:
10382
+ parse = ast.literal_eval(my_network.node_identities[item])
10383
+ if identity in parse:
10384
+ iden_list.append(item)
10385
+ except:
10386
+ if identity == str(my_network.node_identities[item]):
10387
+ iden_list.append(item)
10388
+
10389
+ if iden_list: # Only add if we found nodes
10390
+ identity_lists[identity] = iden_list
10391
+
10392
+ return identity_lists
10393
+
10394
+ def prepare_data_for_umap(self, df, node_identities=None):
10395
+ """
10396
+ Prepare data for UMAP visualization by z-score normalizing columns.
10397
+
10398
+ Args:
10399
+ df: DataFrame with first column as NodeID, rest as marker intensities
10400
+ node_identities: Optional dict mapping node_id (int) -> identity (string).
10401
+ If provided, only nodes present as keys will be kept.
10402
+
10403
+ Returns:
10404
+ dict: {node_id: [normalized_marker_values]}
10405
+ """
10406
+ from sklearn.preprocessing import StandardScaler
10407
+ import numpy as np
10408
+
10409
+ # Filter dataframe if node_identities is provided
10410
+ if my_network.node_identities is not None:
10411
+ # Get the valid node IDs from node_identities keys
10412
+ valid_node_ids = set(my_network.node_identities.keys())
10413
+
10414
+ # Filter df to only keep rows where first column value is in valid_node_ids
10415
+ mask = df.iloc[:, 0].isin(valid_node_ids)
10416
+ df = df[mask].copy()
10417
+
10418
+ # Optional: Check if any rows remain after filtering
10419
+ if len(df) == 0:
10420
+ raise ValueError("No matching nodes found between df and node_identities")
10421
+
10422
+ # Extract node IDs from first column
10423
+ node_ids = df.iloc[:, 0].values
10424
+
10425
+ # Extract marker data (all columns except first)
10426
+ X = df.iloc[:, 1:].values
10427
+
10428
+ # Z-score normalization (column-wise)
10429
+ scaler = StandardScaler()
10430
+ X_normalized = scaler.fit_transform(X)
10431
+
10432
+ # Create dictionary mapping node_id -> normalized row
10433
+ result_dict = {
10434
+ int(node_ids[i]): X_normalized[i].tolist()
10435
+ for i in range(len(node_ids))
10436
+ }
10437
+
10438
+ return result_dict
10439
+
10440
+
10441
+ def normalize_df_with_identity_centerpoints(self, df, identity_lists):
10442
+ """
10443
+ Normalize the entire dataframe using identity-specific centerpoints.
10444
+ Uses Z-score-like normalization with identity centerpoint as the "mean".
10445
+
10446
+ Parameters:
10447
+ df (pd.DataFrame): Original dataframe
10448
+ identity_lists (dict): Dictionary where keys are identity names and values are lists of node IDs
10449
+
10450
+ Returns:
10451
+ pd.DataFrame: Normalized dataframe
10452
+ """
10453
+ # Make a copy to avoid modifying the original dataframe
10454
+ df_copy = df.copy()
10455
+
10456
+ # Set the first column as the index (row headers)
10457
+ df_copy = df_copy.set_index(df_copy.columns[0])
10458
+
10459
+ # Convert all remaining columns to float type (batch conversion)
10460
+ df_copy = df_copy.astype(float)
10461
+
10462
+ # First, calculate the centerpoint for each column by finding the min across all identity groups
10463
+ column_centerpoints = {}
10464
+
10465
+ for column in df_copy.columns:
10466
+ centerpoint = None
10467
+
10468
+ for identity, node_list in identity_lists.items():
10469
+ # Get nodes that exist in both the identity list and the dataframe
10470
+ valid_nodes = [node for node in node_list if node in df_copy.index]
10471
+ if valid_nodes and ((str(identity) == str(column)) or str(identity) == f'{str(column)}+'):
10472
+ # Get the min value for this identity in this column
10473
+ identity_min = df_copy.loc[valid_nodes, column].min()
10474
+ centerpoint = identity_min
10475
+ break # Found the match, no need to continue
10476
+
10477
+ if centerpoint is not None:
10478
+ # Use the identity-specific centerpoint
10479
+ column_centerpoints[column] = centerpoint
10480
+ else:
10481
+ # Fallback: if no matching identity, use column median
10482
+ print(f"Could not find {str(column)} in node identities. As a fallback, using the median of all values in this channel rather than the minimum of user-designated valid values.")
10483
+ column_centerpoints[column] = df_copy[column].median()
10484
+
10485
+ # Now normalize each column using Z-score-like calculation with identity centerpoint
10486
+ df_normalized = df_copy.copy()
10487
+ for column in df_copy.columns:
10488
+ centerpoint = column_centerpoints[column]
10489
+ # Calculate standard deviation of the column
10490
+ std_dev = df_copy[column].std()
10491
+
10492
+ if std_dev > 0: # Avoid division by zero
10493
+ # Z-score-like: (value - centerpoint) / std_dev
10494
+ df_normalized[column] = (df_copy[column] - centerpoint) / std_dev
10495
+ else:
10496
+ # If std_dev is 0, just subtract centerpoint
10497
+ df_normalized[column] = df_copy[column] - centerpoint
10498
+
10499
+ # Convert back to original format with first column as regular column
10500
+ df_normalized = df_normalized.reset_index()
10501
+
10502
+ return df_normalized
10503
+
10504
+ def show_in_table(self, df, metric, title):
10505
+
10506
+ # Create new table
10507
+ table = CustomTableView(self.parent())
10508
+ table.setModel(PandasModel(df))
10509
+
10510
+ try:
10511
+ first_column_name = table.model()._data.columns[0]
10512
+ table.sort_table(first_column_name, ascending=True)
10513
+ except:
10514
+ pass
10515
+
10516
+ # Add to tabbed widget
10517
+ if title is None:
10518
+ self.parent().tabbed_data.add_table(f"{metric} Analysis", table)
10519
+ else:
10520
+ self.parent().tabbed_data.add_table(f"{title}", table)
10521
+
10522
+
10523
+
10524
+ # Adjust column widths to content
10525
+ for column in range(table.model().columnCount(None)):
10526
+ table.resizeColumnToContents(column)
10527
+
10528
+ def run(self):
10529
+
10530
+ def df_to_dict_by_rows(df, row_indices, title):
10531
+ """
10532
+ Convert a pandas DataFrame to a dictionary by selecting specific rows.
10533
+ No normalization - dataframe is already normalized.
10534
+
10535
+ Parameters:
10536
+ df (pd.DataFrame): DataFrame with first column as row headers, remaining columns contain floats
10537
+ row_indices (list): List of values from the first column representing rows to include
10538
+
10539
+ Returns:
10540
+ dict: Dictionary where keys are column headers and values are lists of column values (as floats)
10541
+ for the specified rows
10542
+ """
10543
+ # Make a copy to avoid modifying the original dataframe
10544
+ df_copy = df.copy()
10545
+
10546
+ # Set the first column as the index (row headers)
10547
+ df_copy = df_copy.set_index(df_copy.columns[0])
10548
+
10549
+ # Mask the dataframe to include only the specified rows
10550
+ masked_df = df_copy.loc[row_indices]
10551
+
10552
+ # Create empty dictionary
10553
+ result_dict = {}
10554
+
10555
+ # For each column, add the column header as key and column values as list
10556
+ for column in masked_df.columns:
10557
+ result_dict[column] = masked_df[column].tolist()
10558
+
10559
+ masked_df.insert(0, "NodeIDs", row_indices)
10560
+ self.show_in_table(masked_df, metric = "NodeID", title = title)
10561
+
10562
+
10563
+ return result_dict
10564
+
10565
+ from . import neighborhoods
10566
+
10567
+ try:
10568
+
10569
+ if self.idens.currentIndex() != 0:
10570
+
10571
+ iden = self.idens.currentText()
10572
+ iden_list = []
10573
+ import ast
10574
+
10575
+ for item in my_network.node_identities:
10576
+
10577
+ try:
10578
+ parse = ast.literal_eval(my_network.node_identities[item])
10579
+ if iden in parse:
10580
+ iden_list.append(item)
10581
+ except:
10582
+ if (iden == my_network.node_identities[item]):
10583
+ iden_list.append(item)
10584
+
10585
+ violin_dict = df_to_dict_by_rows(self.df, iden_list, f"Z-Score-like Channel Intensities of Identity {iden}, {len(iden_list)} Nodes")
10586
+
10587
+ neighborhoods.create_violin_plots(violin_dict, graph_title=f"Z-Score-like Channel Intensities of Identity {iden}, {len(iden_list)} Nodes")
10588
+ except:
10589
+ pass
10590
+
10591
+ try:
10592
+ if self.coms.currentIndex() != 0:
10593
+
10594
+ com = self.coms.currentText()
10595
+
10596
+ com_dict = n3d.invert_dict(my_network.communities)
10597
+
10598
+ com_list = com_dict[int(com)]
10599
+
10600
+ violin_dict = df_to_dict_by_rows(self.df, com_list, f"Z-Score-like Channel Intensities of Community/Neighborhood {com}, {len(com_list)} Nodes")
10601
+
10602
+ neighborhoods.create_violin_plots(violin_dict, graph_title=f"Z-Score-like Channel Intensities of Community/Neighborhood {com}, {len(com_list)} Nodes")
10603
+ except:
10604
+ pass
10605
+
10606
+ """
10607
+ def run2(self):
10608
+ def df_to_dict(df):
10609
+ # Make a copy to avoid modifying the original dataframe
10610
+ df_copy = df.copy()
10611
+
10612
+ # Set the first column as the index (row headers)
10613
+ df_copy = df_copy.set_index(df_copy.columns[0])
10614
+
10615
+ # Convert all remaining columns to float type (batch conversion)
10616
+ df_copy = df_copy.astype(float)
10617
+
10618
+ # Create the result dictionary
10619
+ result_dict = {}
10620
+ for row_idx in df_copy.index:
10621
+ result_dict[row_idx] = df_copy.loc[row_idx].tolist()
10622
+
10623
+ return result_dict
10624
+
10625
+ try:
10626
+ umap_dict = df_to_dict(self.backup_df)
10627
+ my_network.identity_umap(umap_dict)
10628
+ except:
10629
+ pass
10630
+ """
10631
+
10632
+ def run2(self):
10633
+
10634
+ try:
10635
+ umap_dict = self.prepare_data_for_umap(self.backup_df)
10636
+ mode = self.mode_selector.currentIndex()
10637
+ my_network.identity_umap(umap_dict, mode)
10638
+ except:
10639
+ import traceback
10640
+ print(traceback.format_exc())
10641
+ pass
10642
+
10643
+ def run3(self):
10644
+
10645
+ num_clusters_text = self.kmeans_num_input.text()
10646
+
10647
+ if num_clusters_text:
10648
+ num_clusters = int(num_clusters_text)
10649
+ # Use specified number of clusters
10650
+ print(f"Using {num_clusters} clusters")
10651
+ else:
10652
+ num_clusters = None # Auto-determine
10653
+ print("Auto-determining number of clusters")
10654
+
10655
+ try:
10656
+ cluster_dict = self.prepare_data_for_umap(self.backup_df)
10657
+ my_network.group_nodes_by_intensity(cluster_dict, count = num_clusters)
10658
+ self.parent().format_for_upperright_table(my_network.communities, 'NodeID', 'Community')
10659
+ self.accept()
10660
+ except:
10661
+ import traceback
10662
+ print(traceback.format_exc())
10663
+ pass
10664
+
10665
+
10666
+
10667
+
10668
+ class DegreeDialog(QDialog):
10669
+
10670
+
10671
+ def __init__(self, parent=None):
10672
+
10673
+ super().__init__(parent)
10674
+ self.setWindowTitle("Degree Parameters")
10675
+ self.setModal(True)
10676
+
10677
+ layout = QFormLayout(self)
10678
+
10679
+ layout.addRow("Note:", QLabel(f"This operation will be executed on the image in 'Active Image', unless it is set to edges in which case it will use the nodes. \n (This is because you may want to run it on isolated nodes that have been placed in the Overlay channels)\nWe can draw optional overlays to Overlay 2 as described below:"))
10680
+
10681
+ # Add mode selection dropdown
10682
+ self.mode_selector = QComboBox()
10683
+ self.mode_selector.addItems(["Just make table", "Draw degree of node as overlay (literally draws 1, 2, 3, etc... faster)", "Label nodes by degree (nodes will take on the value 1, 2, 3, etc, based on their degree, to export for array based analysis)", "Create Heatmap of Degrees"])
10684
+ self.mode_selector.setCurrentIndex(0) # Default to Mode 1
10685
+ layout.addRow("Execution Mode:", self.mode_selector)
10686
+
10687
+ self.mask_limiter = QLineEdit("1")
10688
+ layout.addRow("Proportion of high degree nodes to keep (ignore if only returning degrees)", self.mask_limiter)
10689
+
10690
+ self.down_factor = QLineEdit("1")
10691
+ layout.addRow("down_factor (for speeding up overlay generation - ignore if only returning degrees:", self.down_factor)
10692
+
10693
+ # Add Run button
10694
+ run_button = QPushButton("Get Degrees")
10695
+ run_button.clicked.connect(self.degs)
10696
+ layout.addWidget(run_button)
10697
+
10698
+ def degs(self):
10699
+
10700
+ try:
10701
+
10702
+ accepted_mode = self.mode_selector.currentIndex()
10703
+
10704
+ if accepted_mode == 3:
10705
+ degree_dict, overlay = my_network.get_degrees(heatmap = True)
10706
+ self.parent().format_for_upperright_table(degree_dict, 'Node ID', 'Degree', title = 'Degrees of nodes')
10707
+ self.parent().load_channel(3, channel_data = overlay, data = True)
10708
+ self.accept()
10709
+ return
10710
+
10711
+
10712
+ try:
10713
+ down_factor = float(self.down_factor.text()) if self.down_factor.text() else 1
10714
+ except ValueError:
10715
+ down_factor = 1
10716
+
10717
+ try:
10718
+ mask_limiter = float(self.mask_limiter.text()) if self.mask_limiter.text() else 1
10719
+ except ValueError:
10720
+ mask_limiter = 1
10721
+
10722
+ if self.parent().active_channel == 1:
10723
+ active_data = self.parent().channel_data[0]
10724
+ else:
10725
+ # Get the active channel data from parent
10726
+ active_data = self.parent().channel_data[self.parent().active_channel]
10727
+ if active_data is None:
10728
+ raise ValueError("No active image selected")
10729
+
10730
+ if my_network.node_centroids is None and accepted_mode > 0:
10731
+ self.parent().show_centroid_dialog()
9704
10732
  if my_network.node_centroids is None:
9705
10733
  accepted_mode == 0
9706
10734
  print("Error retrieving centroids")
@@ -9892,6 +10920,9 @@ class MotherDialog(QDialog):
9892
10920
 
9893
10921
  except Exception as e:
9894
10922
 
10923
+ import traceback
10924
+ print(traceback.format_exc())
10925
+
9895
10926
  print(f"Error finding mothers: {e}")
9896
10927
 
9897
10928
 
@@ -10010,7 +11041,7 @@ class ResizeDialog(QDialog):
10010
11041
 
10011
11042
  def run_resize(self, undo = False, upsize = True, special = False):
10012
11043
  try:
10013
- self.parent().resizing = False
11044
+ self.parent().resizing = True
10014
11045
  # Get parameters
10015
11046
  try:
10016
11047
  resize = float(self.resize.text()) if self.resize.text() else None
@@ -10125,6 +11156,7 @@ class ResizeDialog(QDialog):
10125
11156
  if channel is not None:
10126
11157
  self.parent().slice_slider.setMinimum(0)
10127
11158
  self.parent().slice_slider.setMaximum(channel.shape[0] - 1)
11159
+ self.parent().shape = channel.shape
10128
11160
  break
10129
11161
 
10130
11162
  if not special:
@@ -10194,10 +11226,7 @@ class ResizeDialog(QDialog):
10194
11226
  except Exception as e:
10195
11227
  print(f"Error loading edge centroid table: {e}")
10196
11228
 
10197
-
10198
11229
  self.parent().update_display()
10199
- self.reset_fields()
10200
- self.parent().resizing = False
10201
11230
  self.accept()
10202
11231
 
10203
11232
  except Exception as e:
@@ -10206,6 +11235,79 @@ class ResizeDialog(QDialog):
10206
11235
  print(traceback.format_exc())
10207
11236
  QMessageBox.critical(self, "Error", f"Failed to resize: {str(e)}")
10208
11237
 
11238
+ class CleanDialog(QDialog):
11239
+ def __init__(self, parent=None):
11240
+ super().__init__(parent)
11241
+ self.setWindowTitle("Some options for cleaning segmentation")
11242
+ self.setModal(False)
11243
+
11244
+ layout = QFormLayout(self)
11245
+
11246
+ # Add Run button
11247
+ run_button = QPushButton("Close")
11248
+ run_button.clicked.connect(self.close)
11249
+ layout.addRow("Close (Fill Small Gaps - Dilate then Erode by same amount):", run_button)
11250
+
11251
+ # Add Run button
11252
+ run_button = QPushButton("Open")
11253
+ run_button.clicked.connect(self.open)
11254
+ layout.addRow("Open (Eliminate Noise, Jagged Borders, and Small Connections Between Objects - Erode then Dilate by same amount):", run_button)
11255
+
11256
+ # Add Run button
11257
+ run_button = QPushButton("Fill Holes")
11258
+ run_button.clicked.connect(self.holes)
11259
+ layout.addRow("Call the fill holes function:", run_button)
11260
+
11261
+ # Add Run button
11262
+ run_button = QPushButton("Threshold Noise")
11263
+ run_button.clicked.connect(self.thresh)
11264
+ layout.addRow("Threshold Noise By Volume:", run_button)
11265
+
11266
+ def close(self):
11267
+
11268
+ try:
11269
+ self.parent().show_dilate_dialog(args = [1])
11270
+ self.parent().show_erode_dialog(args = [self.parent().last_dil])
11271
+ except:
11272
+ pass
11273
+
11274
+ def open(self):
11275
+
11276
+ try:
11277
+ self.parent().show_erode_dialog(args = [1])
11278
+ self.parent().show_dilate_dialog(args = [self.parent().last_ero])
11279
+ except:
11280
+ pass
11281
+
11282
+ def holes(self):
11283
+
11284
+ try:
11285
+ self.parent().show_hole_dialog()
11286
+ except:
11287
+ pass
11288
+
11289
+ def thresh(self):
11290
+ try:
11291
+ if len(np.unique(self.parent().channel_data[self.parent().active_channel])) < 3:
11292
+ self.parent().show_label_dialog()
11293
+
11294
+ if self.parent().volume_dict[self.parent().active_channel] is None:
11295
+ self.parent().volumes()
11296
+
11297
+ thresh_window = ThresholdWindow(self.parent(), 1)
11298
+ thresh_window.show() # Non-modal window
11299
+ self.parent().highlight_overlay = None
11300
+ #self.mini_overlay = False
11301
+ self.parent().mini_overlay_data = None
11302
+ except:
11303
+ import traceback
11304
+ print(traceback.format_exc())
11305
+ pass
11306
+
11307
+
11308
+
11309
+
11310
+
10209
11311
 
10210
11312
  class OverrideDialog(QDialog):
10211
11313
  def __init__(self, parent=None):
@@ -10439,7 +11541,7 @@ class LabelDialog(QDialog):
10439
11541
  class SLabelDialog(QDialog):
10440
11542
  def __init__(self, parent=None):
10441
11543
  super().__init__(parent)
10442
- self.setWindowTitle("Smart Label (Use label array to assign label neighborhoods to binary array)?")
11544
+ self.setWindowTitle("Label a binary image based on it's voxels proximity to labeled components of a second image?")
10443
11545
  self.setModal(True)
10444
11546
 
10445
11547
  layout = QFormLayout(self)
@@ -10451,7 +11553,7 @@ class SLabelDialog(QDialog):
10451
11553
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
10452
11554
  layout.addRow("Prelabeled Array:", self.mode_selector)
10453
11555
 
10454
- layout.addRow(QLabel("Will Label Neighborhoods in: "))
11556
+ layout.addRow(QLabel("Will Label Binary Foreground Voxels in: "))
10455
11557
 
10456
11558
  # Add mode selection dropdown
10457
11559
  self.target_selector = QComboBox()
@@ -10497,10 +11599,6 @@ class SLabelDialog(QDialog):
10497
11599
  # Update both the display data and the network object
10498
11600
  binary_array = sdl.smart_label(binary_array, label_array, directory = None, GPU = GPU, predownsample = down_factor, remove_template = True)
10499
11601
 
10500
- label_array = sdl.invert_array(label_array)
10501
-
10502
- binary_array = binary_array * label_array
10503
-
10504
11602
  self.parent().load_channel(accepted_target, binary_array, True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
10505
11603
 
10506
11604
  self.accept()
@@ -10579,12 +11677,11 @@ class ThresholdDialog(QDialog):
10579
11677
  print("Error - please calculate network first")
10580
11678
  return
10581
11679
 
10582
- if self.parent().mini_overlay_data is not None:
10583
- self.parent().mini_overlay_data = None
10584
-
10585
11680
  thresh_window = ThresholdWindow(self.parent(), accepted_mode)
10586
11681
  thresh_window.show() # Non-modal window
10587
11682
  self.highlight_overlay = None
11683
+ #self.mini_overlay = False
11684
+ self.mini_overlay_data = None
10588
11685
  self.accept()
10589
11686
  except:
10590
11687
  import traceback
@@ -11517,6 +12614,7 @@ class ThresholdWindow(QMainWindow):
11517
12614
 
11518
12615
  def __init__(self, parent=None, accepted_mode=0):
11519
12616
  super().__init__(parent)
12617
+ self.parent().thresh_window_ref = self
11520
12618
  self.setWindowTitle("Threshold")
11521
12619
 
11522
12620
  self.accepted_mode = accepted_mode
@@ -11558,16 +12656,23 @@ class ThresholdWindow(QMainWindow):
11558
12656
  self.parent().bounds = False
11559
12657
 
11560
12658
  elif accepted_mode == 0:
11561
- targ_shape = self.parent().channel_data[self.parent().active_channel].shape
11562
- if (targ_shape[0] + targ_shape[1] + targ_shape[2]) > 2500: #Take a simpler histogram on big arrays
11563
- temp_max = np.max(self.parent().channel_data[self.parent().active_channel])
11564
- temp_min = np.min(self.parent().channel_data[self.parent().active_channel])
11565
- temp_array = n3d.downsample(self.parent().channel_data[self.parent().active_channel], 5)
11566
- self.histo_list = temp_array.flatten().tolist()
11567
- self.histo_list.append(temp_min)
11568
- self.histo_list.append(temp_max)
11569
- else: #Otherwise just use full array data
11570
- self.histo_list = self.parent().channel_data[self.parent().active_channel].flatten().tolist()
12659
+ data = self.parent().channel_data[self.parent().active_channel]
12660
+ nonzero_data = data[data != 0]
12661
+
12662
+ if nonzero_data.size > 578009537:
12663
+ # For large arrays, use numpy histogram directly
12664
+ counts, bin_edges = np.histogram(nonzero_data, bins= min(int(np.sqrt(nonzero_data.size)), 500), density=False)
12665
+ # Store min/max separately if needed elsewhere
12666
+ self.data_min = np.min(nonzero_data)
12667
+ self.data_max = np.max(nonzero_data)
12668
+ self.histo_list = [self.data_min, self.data_max]
12669
+ else:
12670
+ # For smaller arrays, can still use histogram method for consistency
12671
+ counts, bin_edges = np.histogram(nonzero_data, bins='auto', density=False)
12672
+ self.data_min = np.min(nonzero_data)
12673
+ self.data_max = np.max(nonzero_data)
12674
+ self.histo_list = [self.data_min, self.data_max]
12675
+
11571
12676
  self.bounds = True
11572
12677
  self.parent().bounds = True
11573
12678
 
@@ -11580,16 +12685,26 @@ class ThresholdWindow(QMainWindow):
11580
12685
  layout.addWidget(self.canvas)
11581
12686
 
11582
12687
  # Pre-compute histogram with numpy
11583
- counts, bin_edges = np.histogram(self.histo_list, bins=50)
11584
- bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
12688
+ if accepted_mode != 0:
12689
+ counts, bin_edges = np.histogram(self.histo_list, bins=50)
12690
+ bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
12691
+ # Store histogram bounds
12692
+ if self.bounds:
12693
+ self.data_min = 0
12694
+ else:
12695
+ self.data_min = min(self.histo_list)
12696
+ self.data_max = max(self.histo_list)
12697
+ else:
12698
+ bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
12699
+ bin_width = bin_edges[1] - bin_edges[0]
11585
12700
 
11586
12701
  # Plot pre-computed histogram
11587
12702
  self.ax = fig.add_subplot(111)
11588
12703
  self.ax.bar(bin_centers, counts, width=bin_edges[1] - bin_edges[0], alpha=0.5)
11589
12704
 
11590
12705
  # Add vertical lines for thresholds
11591
- self.min_line = self.ax.axvline(min(self.histo_list), color='r')
11592
- self.max_line = self.ax.axvline(max(self.histo_list), color='b')
12706
+ self.min_line = self.ax.axvline(self.data_min, color='r')
12707
+ self.max_line = self.ax.axvline(self.data_max, color='b')
11593
12708
 
11594
12709
  # Connect events for dragging
11595
12710
  self.canvas.mpl_connect('button_press_event', self.on_press)
@@ -11597,13 +12712,6 @@ class ThresholdWindow(QMainWindow):
11597
12712
  self.canvas.mpl_connect('button_release_event', self.on_release)
11598
12713
 
11599
12714
  self.dragging = None
11600
-
11601
- # Store histogram bounds
11602
- if self.bounds:
11603
- self.data_min = 0
11604
- else:
11605
- self.data_min = min(self.histo_list)
11606
- self.data_max = max(self.histo_list)
11607
12715
 
11608
12716
  # Create form layout for inputs
11609
12717
  form_layout = QFormLayout()
@@ -11636,7 +12744,7 @@ class ThresholdWindow(QMainWindow):
11636
12744
  button_layout.addWidget(run_button)
11637
12745
 
11638
12746
  # Add Cancel button for external dialog use
11639
- cancel_button = QPushButton("Cancel/Skip")
12747
+ cancel_button = QPushButton("Cancel/Skip (Retains Selection)")
11640
12748
  cancel_button.clicked.connect(self.cancel_processing)
11641
12749
  button_layout.addWidget(cancel_button)
11642
12750
 
@@ -11660,10 +12768,8 @@ class ThresholdWindow(QMainWindow):
11660
12768
  self.processing_cancelled.emit()
11661
12769
  self.close()
11662
12770
 
11663
- def closeEvent(self, event):
11664
- self.parent().preview = False
11665
- self.parent().targs = None
11666
- self.parent().bounds = False
12771
+ def make_full_highlight(self):
12772
+
11667
12773
  try: # could probably be refactored but this just handles keeping the highlight elements if the user presses X
11668
12774
  if self.chan == 0:
11669
12775
  if not self.bounds:
@@ -11697,6 +12803,14 @@ class ThresholdWindow(QMainWindow):
11697
12803
  pass
11698
12804
 
11699
12805
 
12806
+ def closeEvent(self, event):
12807
+ self.parent().preview = False
12808
+ self.parent().targs = None
12809
+ self.parent().bounds = False
12810
+ self.parent().thresh_window_ref = None
12811
+ self.make_full_highlight()
12812
+
12813
+
11700
12814
  def get_values_in_range_all_vols(self, chan, min_val, max_val):
11701
12815
  output = []
11702
12816
  if self.accepted_mode == 1:
@@ -11963,36 +13077,33 @@ class SmartDilateDialog(QDialog):
11963
13077
 
11964
13078
 
11965
13079
  class DilateDialog(QDialog):
11966
- def __init__(self, parent=None):
13080
+ def __init__(self, parent=None, args = None):
11967
13081
  super().__init__(parent)
11968
13082
  self.setWindowTitle("Dilate Parameters")
11969
13083
  self.setModal(True)
11970
13084
 
11971
13085
  layout = QFormLayout(self)
11972
13086
 
11973
- self.amount = QLineEdit("1")
11974
- layout.addRow("Dilation Radius:", self.amount)
11975
-
11976
- if my_network.xy_scale is not None:
11977
- xy_scale = f"{my_network.xy_scale}"
13087
+ if args:
13088
+ self.parent().last_dil = args[0]
13089
+ self.index = 1
11978
13090
  else:
11979
- xy_scale = "1"
13091
+ self.parent().last_dil = 1
13092
+ self.index = 0
11980
13093
 
11981
- self.xy_scale = QLineEdit(xy_scale)
11982
- layout.addRow("xy_scale:", self.xy_scale)
13094
+ self.amount = QLineEdit(f"{self.parent().last_dil}")
13095
+ layout.addRow("Dilation Radius:", self.amount)
11983
13096
 
11984
- if my_network.z_scale is not None:
11985
- z_scale = f"{my_network.z_scale}"
11986
- else:
11987
- z_scale = "1"
13097
+ self.xy_scale = QLineEdit("1")
13098
+ layout.addRow("xy_scale:", self.xy_scale)
11988
13099
 
11989
- self.z_scale = QLineEdit(z_scale)
13100
+ self.z_scale = QLineEdit("1")
11990
13101
  layout.addRow("z_scale:", self.z_scale)
11991
13102
 
11992
13103
  # Add mode selection dropdown
11993
13104
  self.mode_selector = QComboBox()
11994
- self.mode_selector.addItems(["Pseudo3D Binary Kernels (For Fast, small dilations)", "Preserve Labels (slower)", "Distance Transform-Based (Slower but more accurate at larger dilations)"])
11995
- self.mode_selector.setCurrentIndex(0) # Default to Mode 1
13105
+ self.mode_selector.addItems(["Distance Transform-Based (Slower but more accurate at larger dilations)", "Preserve Labels (slower)", "Pseudo3D Binary Kernels (For Fast, small dilations)"])
13106
+ self.mode_selector.setCurrentIndex(self.index) # Default to Mode 1
11996
13107
  layout.addRow("Execution Mode:", self.mode_selector)
11997
13108
 
11998
13109
  # Add Run button
@@ -12034,13 +13145,15 @@ class DilateDialog(QDialog):
12034
13145
  if active_data is None:
12035
13146
  raise ValueError("No active image selected")
12036
13147
 
13148
+ self.parent().last_dil = amount
13149
+
12037
13150
  if accepted_mode == 1:
12038
13151
  dialog = SmartDilateDialog(self.parent(), [active_data, amount, xy_scale, z_scale])
12039
13152
  dialog.exec()
12040
13153
  self.accept()
12041
13154
  return
12042
13155
 
12043
- if accepted_mode == 2:
13156
+ if accepted_mode == 0:
12044
13157
  result = n3d.dilate_3D_dt(active_data, amount, xy_scaling = xy_scale, z_scaling = z_scale)
12045
13158
  else:
12046
13159
 
@@ -12070,36 +13183,33 @@ class DilateDialog(QDialog):
12070
13183
  )
12071
13184
 
12072
13185
  class ErodeDialog(QDialog):
12073
- def __init__(self, parent=None):
13186
+ def __init__(self, parent=None, args = None):
12074
13187
  super().__init__(parent)
12075
13188
  self.setWindowTitle("Erosion Parameters")
12076
13189
  self.setModal(True)
12077
13190
 
12078
13191
  layout = QFormLayout(self)
12079
13192
 
12080
- self.amount = QLineEdit("1")
12081
- layout.addRow("Erosion Radius:", self.amount)
12082
-
12083
- if my_network.xy_scale is not None:
12084
- xy_scale = f"{my_network.xy_scale}"
13193
+ if args:
13194
+ self.parent().last_ero = args[0]
13195
+ self.index = 1
12085
13196
  else:
12086
- xy_scale = "1"
13197
+ self.parent().last_ero = 1
13198
+ self.index = 0
12087
13199
 
12088
- self.xy_scale = QLineEdit(xy_scale)
12089
- layout.addRow("xy_scale:", self.xy_scale)
13200
+ self.amount = QLineEdit(f"{self.parent().last_ero}")
13201
+ layout.addRow("Erosion Radius:", self.amount)
12090
13202
 
12091
- if my_network.z_scale is not None:
12092
- z_scale = f"{my_network.z_scale}"
12093
- else:
12094
- z_scale = "1"
13203
+ self.xy_scale = QLineEdit("1")
13204
+ layout.addRow("xy_scale:", self.xy_scale)
12095
13205
 
12096
- self.z_scale = QLineEdit(z_scale)
13206
+ self.z_scale = QLineEdit("1")
12097
13207
  layout.addRow("z_scale:", self.z_scale)
12098
13208
 
12099
13209
  # Add mode selection dropdown
12100
13210
  self.mode_selector = QComboBox()
12101
- self.mode_selector.addItems(["Pseudo3D Binary Kernels (For Fast, small erosions)", "Distance Transform-Based (Slower but more accurate at larger dilations)", "Preserve Labels (Slower)"])
12102
- self.mode_selector.setCurrentIndex(0) # Default to Mode 1
13211
+ self.mode_selector.addItems(["Distance Transform-Based (Slower but more accurate at larger erosions)", "Preserve Labels (Slower)", "Pseudo3D Binary Kernels (For Fast, small erosions)"])
13212
+ self.mode_selector.setCurrentIndex(self.index) # Default to Mode 1
12103
13213
  layout.addRow("Execution Mode:", self.mode_selector)
12104
13214
 
12105
13215
  # Add Run button
@@ -12135,8 +13245,7 @@ class ErodeDialog(QDialog):
12135
13245
 
12136
13246
  mode = self.mode_selector.currentIndex()
12137
13247
 
12138
- if mode == 2:
12139
- mode = 1
13248
+ if mode == 1:
12140
13249
  preserve_labels = True
12141
13250
  else:
12142
13251
  preserve_labels = False
@@ -12158,7 +13267,7 @@ class ErodeDialog(QDialog):
12158
13267
 
12159
13268
 
12160
13269
  self.parent().load_channel(self.parent().active_channel, result, True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
12161
-
13270
+ self.parent().last_ero = amount
12162
13271
  self.accept()
12163
13272
 
12164
13273
  except Exception as e:
@@ -12188,6 +13297,11 @@ class HoleDialog(QDialog):
12188
13297
  self.borders.setChecked(False)
12189
13298
  layout.addRow("Fill Small Holes Along Borders:", self.borders)
12190
13299
 
13300
+ self.preserve_labels = QPushButton("Preserve Labels")
13301
+ self.preserve_labels.setCheckable(True)
13302
+ self.preserve_labels.setChecked(False)
13303
+ layout.addRow("Preserve Labels (Slower):", self.preserve_labels)
13304
+
12191
13305
  self.sep_holes = QPushButton("Seperate Hole Mask")
12192
13306
  self.sep_holes.setCheckable(True)
12193
13307
  self.sep_holes.setChecked(False)
@@ -12210,15 +13324,32 @@ class HoleDialog(QDialog):
12210
13324
  borders = self.borders.isChecked()
12211
13325
  headon = self.headon.isChecked()
12212
13326
  sep_holes = self.sep_holes.isChecked()
13327
+ preserve_labels = self.preserve_labels.isChecked()
13328
+ if preserve_labels:
13329
+ label_copy = np.copy(active_data)
13330
+
13331
+ if borders:
12213
13332
 
12214
- # Call dilate method with parameters
12215
- result = n3d.fill_holes_3d(
12216
- active_data,
12217
- head_on = headon,
12218
- fill_borders = borders
12219
- )
13333
+ # Call dilate method with parameters
13334
+ result = n3d.fill_holes_3d_old(
13335
+ active_data,
13336
+ head_on = headon,
13337
+ fill_borders = borders
13338
+ )
13339
+
13340
+ else:
13341
+ # Call dilate method with parameters
13342
+ result = n3d.fill_holes_3d(
13343
+ active_data,
13344
+ head_on = headon,
13345
+ fill_borders = borders
13346
+ )
13347
+
12220
13348
 
12221
13349
  if not sep_holes:
13350
+ if preserve_labels:
13351
+ result = sdl.smart_label(result, label_copy, directory = None, GPU = False, remove_template = True)
13352
+
12222
13353
  self.parent().load_channel(self.parent().active_channel, result, True)
12223
13354
  else:
12224
13355
  self.parent().load_channel(3, active_data - result, True)
@@ -12563,7 +13694,13 @@ class SkeletonizeDialog(QDialog):
12563
13694
  # auto checkbox (default True)
12564
13695
  self.auto = QPushButton("Auto")
12565
13696
  self.auto.setCheckable(True)
12566
- self.auto.setChecked(False)
13697
+ try:
13698
+ if self.shape[0] == 1:
13699
+ self.auto.setChecked(False)
13700
+ else:
13701
+ self.auto.setChecked(True)
13702
+ except:
13703
+ self.auto.setChecked(True)
12567
13704
  layout.addRow("Attempt to Auto Correct Skeleton Looping:", self.auto)
12568
13705
 
12569
13706
  # Add Run button
@@ -12619,6 +13756,86 @@ class SkeletonizeDialog(QDialog):
12619
13756
  f"Error running skeletonize: {str(e)}"
12620
13757
  )
12621
13758
 
13759
+
13760
+ class BranchStatDialog(QDialog):
13761
+
13762
+ def __init__(self, parent=None):
13763
+ super().__init__(parent)
13764
+ self.setWindowTitle("Make sure branches are labeled first (Image -> Generate -> Label Branches)")
13765
+ self.setModal(True)
13766
+
13767
+ layout = QFormLayout(self)
13768
+
13769
+ info_label = QLabel("Skeletonization Params for Getting Branch Stats, Make sure xy and z scale are set correctly in properties")
13770
+ layout.addRow(info_label)
13771
+
13772
+ self.remove = QLineEdit("0")
13773
+ layout.addRow("Remove Branches Pixel Length (int):", self.remove)
13774
+
13775
+ # auto checkbox (default True)
13776
+ self.auto = QPushButton("Auto")
13777
+ self.auto.setCheckable(True)
13778
+ try:
13779
+ if self.shape[0] == 1:
13780
+ self.auto.setChecked(False)
13781
+ else:
13782
+ self.auto.setChecked(True)
13783
+ except:
13784
+ self.auto.setChecked(True)
13785
+ layout.addRow("Attempt to Auto Correct Skeleton Looping:", self.auto)
13786
+
13787
+ # Add Run button
13788
+ run_button = QPushButton("Get Branchstats (For Active Image)")
13789
+ run_button.clicked.connect(self.run)
13790
+ layout.addRow(run_button)
13791
+
13792
+ def run(self):
13793
+
13794
+ try:
13795
+
13796
+ # Get branch removal
13797
+ try:
13798
+ remove = int(self.remove.text()) if self.remove.text() else 0
13799
+ except ValueError:
13800
+ remove = 0
13801
+
13802
+ auto = self.auto.isChecked()
13803
+
13804
+ # Get the active channel data from parent
13805
+ active_data = np.copy(self.parent().channel_data[self.parent().active_channel])
13806
+ if active_data is None:
13807
+ raise ValueError("No active image selected")
13808
+
13809
+ if auto:
13810
+ active_data = n3d.skeletonize(active_data)
13811
+ active_data = n3d.fill_holes_3d(active_data)
13812
+
13813
+ active_data = n3d.skeletonize(
13814
+ active_data
13815
+ )
13816
+
13817
+ if remove > 0:
13818
+ active_data = n3d.remove_branches_new(active_data, remove)
13819
+ active_data = n3d.dilate_3D(active_data, 3, 3, 3)
13820
+ active_data = n3d.skeletonize(active_data)
13821
+
13822
+ active_data = active_data * self.parent().channel_data[self.parent().active_channel]
13823
+ len_dict, tortuosity_dict, angle_dict = n3d.compute_optional_branchstats(None, active_data, None, xy_scale = my_network.xy_scale, z_scale = my_network.z_scale)
13824
+
13825
+ if self.parent().active_channel == 0:
13826
+ self.parent().branch_dict[0] = [len_dict, tortuosity_dict]
13827
+ elif self.parent().active_channel == 1:
13828
+ self.parent().branch_dict[1] = [len_dict, tortuosity_dict]
13829
+
13830
+ self.parent().format_for_upperright_table(len_dict, 'BranchID', 'Length (Scaled)', 'Branch Lengths')
13831
+ self.parent().format_for_upperright_table(tortuosity_dict, 'BranchID', 'Tortuosity', 'Branch Tortuosities')
13832
+
13833
+
13834
+ self.accept()
13835
+
13836
+ except Exception as e:
13837
+ print(f"Error: {e}")
13838
+
12622
13839
  class DistanceDialog(QDialog):
12623
13840
  def __init__(self, parent=None):
12624
13841
  super().__init__(parent)
@@ -12666,16 +13883,58 @@ class GrayWaterDialog(QDialog):
12666
13883
  run_button.clicked.connect(self.run_watershed)
12667
13884
  layout.addRow(run_button)
12668
13885
 
13886
+ def wait_for_threshold_processing(self):
13887
+ """
13888
+ Opens ThresholdWindow and waits for user to process the image.
13889
+ Returns True if completed, False if cancelled.
13890
+ The thresholded image will be available in the main window after completion.
13891
+ """
13892
+ # Create event loop to wait for user
13893
+ loop = QEventLoop()
13894
+ result = {'completed': False}
13895
+
13896
+ # Create the threshold window
13897
+ thresh_window = ThresholdWindow(self.parent(), 0)
13898
+
13899
+
13900
+ # Connect signals
13901
+ def on_processing_complete():
13902
+ result['completed'] = True
13903
+ loop.quit()
13904
+
13905
+ def on_processing_cancelled():
13906
+ result['completed'] = False
13907
+ loop.quit()
13908
+
13909
+ thresh_window.processing_complete.connect(on_processing_complete)
13910
+ thresh_window.processing_cancelled.connect(on_processing_cancelled)
13911
+
13912
+ # Show window and wait
13913
+ thresh_window.show()
13914
+ thresh_window.raise_()
13915
+ thresh_window.activateWindow()
13916
+
13917
+ # Block until user clicks "Apply Threshold & Continue" or "Cancel"
13918
+ loop.exec()
13919
+
13920
+ # Clean up
13921
+ thresh_window.deleteLater()
13922
+
13923
+ return result['completed']
13924
+
12669
13925
  def run_watershed(self):
12670
13926
 
12671
13927
  try:
12672
13928
 
13929
+ self.accept()
13930
+ print("Please threshold foreground, or press cancel/skip if not desired:")
13931
+ self.wait_for_threshold_processing()
13932
+ data = self.parent().channel_data[self.parent().active_channel]
13933
+
12673
13934
  min_intensity = float(self.min_intensity.text()) if self.min_intensity.text().strip() else None
12674
13935
 
12675
13936
  min_peak_distance = int(self.min_peak_distance.text()) if self.min_peak_distance.text().strip() else 1
12676
13937
 
12677
- data = self.parent().channel_data[self.parent().active_channel]
12678
-
12679
13938
  data = n3d.gray_watershed(data, min_peak_distance, min_intensity)
12680
13939
 
12681
13940
  self.parent().load_channel(self.parent().active_channel, data, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
@@ -12695,11 +13954,6 @@ class WatershedDialog(QDialog):
12695
13954
  self.setModal(True)
12696
13955
 
12697
13956
  layout = QFormLayout(self)
12698
-
12699
- # Directory (empty by default)
12700
- self.directory = QLineEdit()
12701
- self.directory.setPlaceholderText("Leave empty for None")
12702
- layout.addRow("Output Directory:", self.directory)
12703
13957
 
12704
13958
  try:
12705
13959
 
@@ -12750,7 +14004,7 @@ class WatershedDialog(QDialog):
12750
14004
  def run_watershed(self):
12751
14005
  try:
12752
14006
  # Get directory (None if empty)
12753
- directory = self.directory.text() if self.directory.text() else None
14007
+ directory = None
12754
14008
 
12755
14009
  # Get proportion (0.1 if empty or invalid)
12756
14010
  try:
@@ -13108,7 +14362,7 @@ class GenNodesDialog(QDialog):
13108
14362
 
13109
14363
  if my_network.edges is None and my_network.nodes is not None:
13110
14364
  self.parent().load_channel(1, my_network.nodes, data = True)
13111
- self.parent().delete_channel(0, True)
14365
+ self.parent().delete_channel(0, False)
13112
14366
  # Get directory (None if empty)
13113
14367
  #directory = self.directory.text() if self.directory.text() else None
13114
14368
 
@@ -13170,7 +14424,6 @@ class GenNodesDialog(QDialog):
13170
14424
  order = order,
13171
14425
  return_skele = True,
13172
14426
  fastdil = fastdil
13173
-
13174
14427
  )
13175
14428
 
13176
14429
  if down_factor > 0 and not self.called:
@@ -13262,7 +14515,7 @@ class BranchDialog(QDialog):
13262
14515
  self.fix3.setChecked(True)
13263
14516
  else:
13264
14517
  self.fix3.setChecked(False)
13265
- correction_layout.addWidget(QLabel("Split Nontouching Branches? (Useful if branch pruning - may want to threshold out small, split branches after): "), 4, 0)
14518
+ correction_layout.addWidget(QLabel("Split Nontouching Branches?: "), 4, 0)
13266
14519
  correction_layout.addWidget(self.fix3, 4, 1)
13267
14520
 
13268
14521
  correction_group.setLayout(correction_layout)
@@ -13290,20 +14543,27 @@ class BranchDialog(QDialog):
13290
14543
  # --- Misc Options Group ---
13291
14544
  misc_group = QGroupBox("Misc Options")
13292
14545
  misc_layout = QGridLayout()
14546
+
14547
+ # optional computation checkbox
14548
+ self.compute = QPushButton("Branch Stats")
14549
+ self.compute.setCheckable(True)
14550
+ self.compute.setChecked(True)
14551
+ misc_layout.addWidget(QLabel("Compute Branch Stats (Branch Lengths, Tortuosity. Set xy_scale and z_scale in properties first if real distances are desired.):"), 0, 0)
14552
+ misc_layout.addWidget(self.compute, 0, 1)
13293
14553
 
13294
14554
  # Nodes checkbox
13295
14555
  self.nodes = QPushButton("Generate Nodes")
13296
14556
  self.nodes.setCheckable(True)
13297
14557
  self.nodes.setChecked(True)
13298
- misc_layout.addWidget(QLabel("Generate nodes from edges? (Skip if already completed):"), 0, 0)
13299
- misc_layout.addWidget(self.nodes, 0, 1)
14558
+ misc_layout.addWidget(QLabel("Generate nodes from edges? (Skip if already completed):"), 1, 0)
14559
+ misc_layout.addWidget(self.nodes, 1, 1)
13300
14560
 
13301
14561
  # GPU checkbox
13302
14562
  self.GPU = QPushButton("GPU")
13303
14563
  self.GPU.setCheckable(True)
13304
14564
  self.GPU.setChecked(False)
13305
- misc_layout.addWidget(QLabel("Use GPU (May downsample large images):"), 1, 0)
13306
- misc_layout.addWidget(self.GPU, 1, 1)
14565
+ misc_layout.addWidget(QLabel("Use GPU (May downsample large images):"), 2, 0)
14566
+ misc_layout.addWidget(self.GPU, 2, 1)
13307
14567
 
13308
14568
  misc_group.setLayout(misc_layout)
13309
14569
  main_layout.addWidget(misc_group)
@@ -13337,10 +14597,11 @@ class BranchDialog(QDialog):
13337
14597
  fix3 = self.fix3.isChecked()
13338
14598
  fix_val = float(self.fix_val.text()) if self.fix_val.text() else None
13339
14599
  seed = int(self.seed.text()) if self.seed.text() else None
14600
+ compute = self.compute.isChecked()
13340
14601
 
13341
14602
  if my_network.edges is None and my_network.nodes is not None:
13342
14603
  self.parent().load_channel(1, my_network.nodes, data = True)
13343
- self.parent().delete_channel(0, True)
14604
+ self.parent().delete_channel(0, False)
13344
14605
 
13345
14606
  original_shape = my_network.edges.shape
13346
14607
  original_array = copy.deepcopy(my_network.edges)
@@ -13353,7 +14614,7 @@ class BranchDialog(QDialog):
13353
14614
 
13354
14615
  if my_network.edges is not None and my_network.nodes is not None and my_network.id_overlay is not None:
13355
14616
 
13356
- output = n3d.label_branches(my_network.edges, nodes = my_network.nodes, bonus_array = original_array, GPU = GPU, down_factor = down_factor, arrayshape = original_shape)
14617
+ 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)
13357
14618
 
13358
14619
  if fix2:
13359
14620
 
@@ -13392,6 +14653,19 @@ class BranchDialog(QDialog):
13392
14653
 
13393
14654
  output = self.parent().separate_nontouching_objects(output, max_val=np.max(output))
13394
14655
 
14656
+ if compute:
14657
+ labeled_image = (skeleton != 0) * output
14658
+ len_dict, tortuosity_dict, angle_dict = n3d.compute_optional_branchstats(verts, labeled_image, endpoints, xy_scale = my_network.xy_scale, z_scale = my_network.z_scale)
14659
+ self.parent().branch_dict[1] = [len_dict, tortuosity_dict]
14660
+ #max_length = max(len(v) for v in angle_dict.values())
14661
+ #title = [str(i+1) if i < 2 else i+1 for i in range(max_length)]
14662
+
14663
+ #del labeled_image
14664
+
14665
+ self.parent().format_for_upperright_table(len_dict, 'BranchID', 'Length (Scaled)', 'Branch Lengths')
14666
+ self.parent().format_for_upperright_table(tortuosity_dict, 'BranchID', 'Tortuosity', 'Branch Tortuosities')
14667
+ #self.parent().format_for_upperright_table(angle_dict, 'Vertex ID', title, 'Branch Angles')
14668
+
13395
14669
 
13396
14670
  if down_factor is not None:
13397
14671
 
@@ -13791,10 +15065,6 @@ class CentroidDialog(QDialog):
13791
15065
 
13792
15066
  layout = QFormLayout(self)
13793
15067
 
13794
- self.directory = QLineEdit()
13795
- self.directory.setPlaceholderText("Leave empty for active directory")
13796
- layout.addRow("Output Directory:", self.directory)
13797
-
13798
15068
  self.downsample = QLineEdit("1")
13799
15069
  layout.addRow("Downsample Factor:", self.downsample)
13800
15070
 
@@ -13824,7 +15094,7 @@ class CentroidDialog(QDialog):
13824
15094
  ignore_empty = self.ignore_empty.isChecked()
13825
15095
 
13826
15096
  # Get directory (None if empty)
13827
- directory = self.directory.text() if self.directory.text() else None
15097
+ directory = None
13828
15098
 
13829
15099
  # Get downsample
13830
15100
  try:
@@ -13905,7 +15175,6 @@ class CentroidDialog(QDialog):
13905
15175
 
13906
15176
  class CalcAllDialog(QDialog):
13907
15177
  # Class variables to store previous settings
13908
- prev_directory = ""
13909
15178
  prev_search = ""
13910
15179
  prev_diledge = ""
13911
15180
  prev_down_factor = ""
@@ -13977,7 +15246,7 @@ class CalcAllDialog(QDialog):
13977
15246
 
13978
15247
  self.down_factor = QLineEdit(self.prev_down_factor)
13979
15248
  self.down_factor.setPlaceholderText("Leave empty for None")
13980
- speedup_layout.addRow("Downsample for Centroids (int):", self.down_factor)
15249
+ speedup_layout.addRow("Downsample for Centroids/Overlays (int):", self.down_factor)
13981
15250
 
13982
15251
  self.GPU_downsample = QLineEdit(self.prev_GPU_downsample)
13983
15252
  self.GPU_downsample.setPlaceholderText("Leave empty for None")
@@ -13999,10 +15268,6 @@ class CalcAllDialog(QDialog):
13999
15268
  output_group = QGroupBox("Output Options")
14000
15269
  output_layout = QFormLayout(output_group)
14001
15270
 
14002
- self.directory = QLineEdit(self.prev_directory)
14003
- self.directory.setPlaceholderText("Will Have to Save Manually If Empty")
14004
- output_layout.addRow("Output Directory:", self.directory)
14005
-
14006
15271
  self.overlays = QPushButton("Overlays")
14007
15272
  self.overlays.setCheckable(True)
14008
15273
  self.overlays.setChecked(self.prev_overlays)
@@ -14024,7 +15289,7 @@ class CalcAllDialog(QDialog):
14024
15289
 
14025
15290
  try:
14026
15291
  # Get directory (None if empty)
14027
- directory = self.directory.text() if self.directory.text() else None
15292
+ directory = None
14028
15293
 
14029
15294
  # Get xy_scale and z_scale (1 if empty or invalid)
14030
15295
  try:
@@ -14101,7 +15366,6 @@ class CalcAllDialog(QDialog):
14101
15366
  )
14102
15367
 
14103
15368
  # Store current values as previous values
14104
- CalcAllDialog.prev_directory = self.directory.text()
14105
15369
  CalcAllDialog.prev_search = self.search.text()
14106
15370
  CalcAllDialog.prev_diledge = self.diledge.text()
14107
15371
  CalcAllDialog.prev_down_factor = self.down_factor.text()
@@ -14135,8 +15399,12 @@ class CalcAllDialog(QDialog):
14135
15399
  directory = 'my_network'
14136
15400
 
14137
15401
  # Generate and update overlays
14138
- my_network.network_overlay = my_network.draw_network(directory=directory)
14139
- my_network.id_overlay = my_network.draw_node_indices(directory=directory)
15402
+ my_network.network_overlay = my_network.draw_network(directory=directory, down_factor = down_factor)
15403
+ my_network.id_overlay = my_network.draw_node_indices(directory=directory, down_factor = down_factor)
15404
+
15405
+ if down_factor is not None:
15406
+ my_network.id_overlay = n3d.upsample_with_padding(my_network.id_overlay, original_shape = self.parent().shape)
15407
+ my_network.network_overlay = n3d.upsample_with_padding(my_network.network_overlay, original_shape = self.parent().shape)
14140
15408
 
14141
15409
  # Update channel data
14142
15410
  self.parent().load_channel(2, my_network.network_overlay, True)
@@ -14249,14 +15517,13 @@ class ProxDialog(QDialog):
14249
15517
  output_group = QGroupBox("Output Options")
14250
15518
  output_layout = QFormLayout(output_group)
14251
15519
 
14252
- self.directory = QLineEdit('')
14253
- self.directory.setPlaceholderText("Leave empty for 'my_network'")
14254
- output_layout.addRow("Output Directory:", self.directory)
14255
-
14256
15520
  self.overlays = QPushButton("Overlays")
14257
15521
  self.overlays.setCheckable(True)
14258
15522
  self.overlays.setChecked(True)
14259
15523
  output_layout.addRow("Generate Overlays:", self.overlays)
15524
+
15525
+ self.downsample = QLineEdit()
15526
+ output_layout.addRow("(If above): Downsample factor for drawing overlays (Int - Makes Overlay Elements Larger):", self.downsample)
14260
15527
 
14261
15528
  self.populate = QPushButton("Populate Nodes from Centroids?")
14262
15529
  self.populate.setCheckable(True)
@@ -14301,10 +15568,8 @@ class ProxDialog(QDialog):
14301
15568
  else:
14302
15569
  targets = None
14303
15570
 
14304
- try:
14305
- directory = self.directory.text() if self.directory.text() else None
14306
- except:
14307
- directory = None
15571
+ directory = None
15572
+
14308
15573
 
14309
15574
  # Get xy_scale and z_scale (1 if empty or invalid)
14310
15575
  try:
@@ -14328,6 +15593,12 @@ class ProxDialog(QDialog):
14328
15593
  except:
14329
15594
  max_neighbors = None
14330
15595
 
15596
+
15597
+ try:
15598
+ downsample = int(self.downsample.text()) if self.downsample.text() else None
15599
+ except:
15600
+ downsample = None
15601
+
14331
15602
  overlays = self.overlays.isChecked()
14332
15603
  fastdil = self.fastdil.isChecked()
14333
15604
 
@@ -14383,8 +15654,12 @@ class ProxDialog(QDialog):
14383
15654
  directory = 'my_network'
14384
15655
 
14385
15656
  # Generate and update overlays
14386
- my_network.network_overlay = my_network.draw_network(directory=directory)
14387
- my_network.id_overlay = my_network.draw_node_indices(directory=directory)
15657
+ my_network.network_overlay = my_network.draw_network(directory=directory, down_factor = downsample)
15658
+ my_network.id_overlay = my_network.draw_node_indices(directory=directory, down_factor = downsample)
15659
+
15660
+ if downsample is not None:
15661
+ my_network.id_overlay = n3d.upsample_with_padding(my_network.id_overlay, original_shape = self.parent().shape)
15662
+ my_network.network_overlay = n3d.upsample_with_padding(my_network.network_overlay, original_shape = self.parent().shape)
14388
15663
 
14389
15664
  # Update channel data
14390
15665
  self.parent().load_channel(2, channel_data = my_network.network_overlay, data = True)
@@ -14506,31 +15781,81 @@ class HistogramSelector(QWidget):
14506
15781
  """)
14507
15782
  layout.addWidget(button)
14508
15783
 
15784
+
14509
15785
  def shortest_path_histogram(self):
14510
15786
  try:
14511
- shortest_path_lengths = dict(nx.all_pairs_shortest_path_length(self.G))
14512
- diameter = max(nx.eccentricity(self.G, sp=shortest_path_lengths).values())
14513
- path_lengths = np.zeros(diameter + 1, dtype=int)
14514
-
14515
- for pls in shortest_path_lengths.values():
14516
- pl, cnts = np.unique(list(pls.values()), return_counts=True)
15787
+ # Check if graph has multiple disconnected components
15788
+ components = list(nx.connected_components(self.G))
15789
+
15790
+ if len(components) > 1:
15791
+ print(f"Warning: Graph has {len(components)} disconnected components. Computing shortest paths within each component separately.")
15792
+
15793
+ # Initialize variables to collect data from all components
15794
+ all_path_lengths = []
15795
+ max_diameter = 0
15796
+
15797
+ # Process each component separately
15798
+ for i, component in enumerate(components):
15799
+ subgraph = self.G.subgraph(component)
15800
+
15801
+ if len(component) < 2:
15802
+ # Skip single-node components (no paths to compute)
15803
+ continue
15804
+
15805
+ # Compute shortest paths for this component
15806
+ shortest_path_lengths = dict(nx.all_pairs_shortest_path_length(subgraph))
15807
+ component_diameter = max(nx.eccentricity(subgraph, sp=shortest_path_lengths).values())
15808
+ max_diameter = max(max_diameter, component_diameter)
15809
+
15810
+ # Collect path lengths from this component
15811
+ for pls in shortest_path_lengths.values():
15812
+ all_path_lengths.extend(list(pls.values()))
15813
+
15814
+ # Remove self-paths (length 0) and create histogram
15815
+ all_path_lengths = [pl for pl in all_path_lengths if pl > 0]
15816
+
15817
+ if not all_path_lengths:
15818
+ print("No paths found across components (only single-node components)")
15819
+ return
15820
+
15821
+ # Create combined histogram
15822
+ path_lengths = np.zeros(max_diameter + 1, dtype=int)
15823
+ pl, cnts = np.unique(all_path_lengths, return_counts=True)
14517
15824
  path_lengths[pl] += cnts
14518
-
15825
+
15826
+ title_suffix = f" (across {len(components)} components)"
15827
+
15828
+ else:
15829
+ # Single component
15830
+ shortest_path_lengths = dict(nx.all_pairs_shortest_path_length(self.G))
15831
+ diameter = max(nx.eccentricity(self.G, sp=shortest_path_lengths).values())
15832
+ path_lengths = np.zeros(diameter + 1, dtype=int)
15833
+ for pls in shortest_path_lengths.values():
15834
+ pl, cnts = np.unique(list(pls.values()), return_counts=True)
15835
+ path_lengths[pl] += cnts
15836
+ max_diameter = diameter
15837
+ title_suffix = ""
15838
+
15839
+ # Generate visualization and results (same for both cases)
14519
15840
  freq_percent = 100 * path_lengths[1:] / path_lengths[1:].sum()
14520
-
14521
15841
  fig, ax = plt.subplots(figsize=(15, 8))
14522
- ax.bar(np.arange(1, diameter + 1), height=freq_percent)
15842
+ ax.bar(np.arange(1, max_diameter + 1), height=freq_percent)
14523
15843
  ax.set_title(
14524
- "Distribution of shortest path length in G", fontdict={"size": 35}, loc="center"
15844
+ f"Distribution of shortest path length in G{title_suffix}",
15845
+ fontdict={"size": 35}, loc="center"
14525
15846
  )
14526
15847
  ax.set_xlabel("Shortest Path Length", fontdict={"size": 22})
14527
15848
  ax.set_ylabel("Frequency (%)", fontdict={"size": 22})
14528
15849
  plt.show()
14529
15850
 
14530
15851
  freq_dict = {freq: length for length, freq in enumerate(freq_percent, start=1)}
14531
- self.network_analysis.format_for_upperright_table(freq_dict, metric='Frequency (%)',
14532
- value='Shortest Path Length',
14533
- title="Distribution of shortest path length in G")
15852
+ self.network_analysis.format_for_upperright_table(
15853
+ freq_dict,
15854
+ metric='Frequency (%)',
15855
+ value='Shortest Path Length',
15856
+ title=f"Distribution of shortest path length in G{title_suffix}"
15857
+ )
15858
+
14534
15859
  except Exception as e:
14535
15860
  print(f"Error generating shortest path histogram: {e}")
14536
15861
 
@@ -14549,20 +15874,62 @@ class HistogramSelector(QWidget):
14549
15874
  title="Degree Centrality Table")
14550
15875
  except Exception as e:
14551
15876
  print(f"Error generating degree centrality histogram: {e}")
14552
-
15877
+
14553
15878
  def betweenness_centrality_histogram(self):
14554
15879
  try:
14555
- betweenness_centrality = nx.centrality.betweenness_centrality(self.G)
15880
+ # Check if graph has multiple disconnected components
15881
+ components = list(nx.connected_components(self.G))
15882
+
15883
+ if len(components) > 1:
15884
+ print(f"Warning: Graph has {len(components)} disconnected components. Computing betweenness centrality within each component separately.")
15885
+
15886
+ # Initialize dictionary to collect betweenness centrality from all components
15887
+ combined_betweenness_centrality = {}
15888
+
15889
+ # Process each component separately
15890
+ for i, component in enumerate(components):
15891
+ if len(component) < 2:
15892
+ # For single-node components, betweenness centrality is 0
15893
+ for node in component:
15894
+ combined_betweenness_centrality[node] = 0.0
15895
+ continue
15896
+
15897
+ # Create subgraph for this component
15898
+ subgraph = self.G.subgraph(component)
15899
+
15900
+ # Compute betweenness centrality for this component
15901
+ component_betweenness = nx.centrality.betweenness_centrality(subgraph)
15902
+
15903
+ # Add to combined results
15904
+ combined_betweenness_centrality.update(component_betweenness)
15905
+
15906
+ betweenness_centrality = combined_betweenness_centrality
15907
+ title_suffix = f" (across {len(components)} components)"
15908
+
15909
+ else:
15910
+ # Single component
15911
+ betweenness_centrality = nx.centrality.betweenness_centrality(self.G)
15912
+ title_suffix = ""
15913
+
15914
+ # Generate visualization and results (same for both cases)
14556
15915
  plt.figure(figsize=(15, 8))
14557
15916
  plt.hist(betweenness_centrality.values(), bins=100)
14558
15917
  plt.xticks(ticks=[0, 0.02, 0.1, 0.2, 0.3, 0.4, 0.5])
14559
- plt.title("Betweenness Centrality Histogram ", fontdict={"size": 35}, loc="center")
15918
+ plt.title(
15919
+ f"Betweenness Centrality Histogram{title_suffix}",
15920
+ fontdict={"size": 35}, loc="center"
15921
+ )
14560
15922
  plt.xlabel("Betweenness Centrality", fontdict={"size": 20})
14561
15923
  plt.ylabel("Counts", fontdict={"size": 20})
14562
15924
  plt.show()
14563
- self.network_analysis.format_for_upperright_table(betweenness_centrality, metric='Node',
14564
- value='Betweenness Centrality',
14565
- title="Betweenness Centrality Table")
15925
+
15926
+ self.network_analysis.format_for_upperright_table(
15927
+ betweenness_centrality,
15928
+ metric='Node',
15929
+ value='Betweenness Centrality',
15930
+ title=f"Betweenness Centrality Table{title_suffix}"
15931
+ )
15932
+
14566
15933
  except Exception as e:
14567
15934
  print(f"Error generating betweenness centrality histogram: {e}")
14568
15935
 
@@ -14615,7 +15982,27 @@ class HistogramSelector(QWidget):
14615
15982
  def bridges_analysis(self):
14616
15983
  try:
14617
15984
  bridges = list(nx.bridges(self.G))
14618
- self.network_analysis.format_for_upperright_table(bridges, metric='Node Pair',
15985
+ try:
15986
+ # Get the existing DataFrame from the model
15987
+ original_df = self.network_analysis.network_table.model()._data
15988
+
15989
+ # Create boolean mask
15990
+ mask = pd.Series([False] * len(original_df))
15991
+
15992
+ for u, v in bridges:
15993
+ # Check for both (u,v) and (v,u) orientations
15994
+ bridge_mask = (
15995
+ ((original_df.iloc[:, 0] == u) & (original_df.iloc[:, 1] == v)) |
15996
+ ((original_df.iloc[:, 0] == v) & (original_df.iloc[:, 1] == u))
15997
+ )
15998
+ mask |= bridge_mask
15999
+ # Filter the DataFrame to only include bridge connections
16000
+ filtered_df = original_df[mask].copy()
16001
+ df_dict = {i: row.tolist() for i, row in enumerate(filtered_df.values)}
16002
+ self.network_analysis.format_for_upperright_table(df_dict, metric='Bridge ID', value = ['NodeA', 'NodeB', 'EdgeC'],
16003
+ title="Bridges")
16004
+ except:
16005
+ self.network_analysis.format_for_upperright_table(bridges, metric='Node Pair',
14619
16006
  title="Bridges")
14620
16007
  except Exception as e:
14621
16008
  print(f"Error generating bridges analysis: {e}")
@@ -14642,7 +16029,7 @@ class HistogramSelector(QWidget):
14642
16029
  def node_connectivity_histogram(self):
14643
16030
  """Local node connectivity - minimum number of nodes that must be removed to disconnect neighbors"""
14644
16031
  try:
14645
- if self.G.number_of_nodes() > 500: # Skip for large networks (computationally expensive)
16032
+ if self.G.number_of_nodes() > 500:
14646
16033
  print("Note this analysis may be slow for large network (>500 nodes)")
14647
16034
  #return
14648
16035
 
@@ -14720,7 +16107,7 @@ class HistogramSelector(QWidget):
14720
16107
  def load_centrality_histogram(self):
14721
16108
  """Load centrality - fraction of shortest paths passing through each node"""
14722
16109
  try:
14723
- if self.G.number_of_nodes() > 1000: # Skip for very large networks
16110
+ if self.G.number_of_nodes() > 1000:
14724
16111
  print("Note this analysis may be slow for large network (>1000 nodes)")
14725
16112
  #return
14726
16113
 
@@ -14739,21 +16126,67 @@ class HistogramSelector(QWidget):
14739
16126
  def communicability_centrality_histogram(self):
14740
16127
  """Communicability centrality - based on communicability between nodes"""
14741
16128
  try:
14742
- if self.G.number_of_nodes() > 500: # Skip for large networks (memory intensive)
16129
+ if self.G.number_of_nodes() > 500:
14743
16130
  print("Note this analysis may be slow for large network (>500 nodes)")
14744
16131
  #return
16132
+
16133
+ # Check if graph has multiple disconnected components
16134
+ components = list(nx.connected_components(self.G))
16135
+
16136
+ if len(components) > 1:
16137
+ print(f"Warning: Graph has {len(components)} disconnected components. Computing communicability centrality within each component separately.")
16138
+
16139
+ # Initialize dictionary to collect communicability centrality from all components
16140
+ combined_comm_centrality = {}
16141
+
16142
+ # Process each component separately
16143
+ for i, component in enumerate(components):
16144
+ if len(component) < 2:
16145
+ # For single-node components, communicability betweenness centrality is 0
16146
+ for node in component:
16147
+ combined_comm_centrality[node] = 0.0
16148
+ continue
16149
+
16150
+ # Create subgraph for this component
16151
+ subgraph = self.G.subgraph(component)
16152
+
16153
+ # Compute communicability betweenness centrality for this component
16154
+ try:
16155
+ component_comm_centrality = nx.communicability_betweenness_centrality(subgraph)
16156
+ # Add to combined results
16157
+ combined_comm_centrality.update(component_comm_centrality)
16158
+ except Exception as comp_e:
16159
+ print(f"Error computing communicability centrality for component {i+1}: {comp_e}")
16160
+ # Set centrality to 0 for nodes in this component if computation fails
16161
+ for node in component:
16162
+ combined_comm_centrality[node] = 0.0
16163
+
16164
+ comm_centrality = combined_comm_centrality
16165
+ title_suffix = f" (across {len(components)} components)"
14745
16166
 
14746
- # Use the correct function name - it's in the communicability module
14747
- comm_centrality = nx.communicability_betweenness_centrality(self.G)
16167
+ else:
16168
+ # Single component
16169
+ comm_centrality = nx.communicability_betweenness_centrality(self.G)
16170
+ title_suffix = ""
16171
+
16172
+ # Generate visualization and results (same for both cases)
14748
16173
  plt.figure(figsize=(15, 8))
14749
16174
  plt.hist(comm_centrality.values(), bins=50, alpha=0.7)
14750
- plt.title("Communicability Betweenness Centrality Distribution", fontdict={"size": 35}, loc="center")
16175
+ plt.title(
16176
+ f"Communicability Betweenness Centrality Distribution{title_suffix}",
16177
+ fontdict={"size": 35}, loc="center"
16178
+ )
14751
16179
  plt.xlabel("Communicability Betweenness Centrality", fontdict={"size": 20})
14752
16180
  plt.ylabel("Frequency", fontdict={"size": 20})
14753
16181
  plt.show()
14754
- self.network_analysis.format_for_upperright_table(comm_centrality, metric='Node',
14755
- value='Communicability Betweenness Centrality',
14756
- title="Communicability Betweenness Centrality Table")
16182
+
16183
+ self.network_analysis.format_for_upperright_table(
16184
+ comm_centrality,
16185
+ metric='Node',
16186
+ value='Communicability Betweenness Centrality',
16187
+ title=f"Communicability Betweenness Centrality Table{title_suffix}"
16188
+ )
16189
+
14757
16190
  except Exception as e:
14758
16191
  print(f"Error generating communicability betweenness centrality histogram: {e}")
14759
16192
 
@@ -14776,20 +16209,67 @@ class HistogramSelector(QWidget):
14776
16209
  def current_flow_betweenness_histogram(self):
14777
16210
  """Current flow betweenness - models network as electrical circuit"""
14778
16211
  try:
14779
- if self.G.number_of_nodes() > 500: # Skip for large networks (computationally expensive)
16212
+ if self.G.number_of_nodes() > 500:
14780
16213
  print("Note this analysis may be slow for large network (>500 nodes)")
14781
16214
  #return
16215
+
16216
+ # Check if graph has multiple disconnected components
16217
+ components = list(nx.connected_components(self.G))
16218
+
16219
+ if len(components) > 1:
16220
+ print(f"Warning: Graph has {len(components)} disconnected components. Computing current flow betweenness centrality within each component separately.")
16221
+
16222
+ # Initialize dictionary to collect current flow betweenness from all components
16223
+ combined_current_flow = {}
14782
16224
 
14783
- current_flow = nx.current_flow_betweenness_centrality(self.G)
16225
+ # Process each component separately
16226
+ for i, component in enumerate(components):
16227
+ if len(component) < 2:
16228
+ # For single-node components, current flow betweenness centrality is 0
16229
+ for node in component:
16230
+ combined_current_flow[node] = 0.0
16231
+ continue
16232
+
16233
+ # Create subgraph for this component
16234
+ subgraph = self.G.subgraph(component)
16235
+
16236
+ # Compute current flow betweenness centrality for this component
16237
+ try:
16238
+ component_current_flow = nx.current_flow_betweenness_centrality(subgraph)
16239
+ # Add to combined results
16240
+ combined_current_flow.update(component_current_flow)
16241
+ except Exception as comp_e:
16242
+ print(f"Error computing current flow betweenness for component {i+1}: {comp_e}")
16243
+ # Set centrality to 0 for nodes in this component if computation fails
16244
+ for node in component:
16245
+ combined_current_flow[node] = 0.0
16246
+
16247
+ current_flow = combined_current_flow
16248
+ title_suffix = f" (across {len(components)} components)"
16249
+
16250
+ else:
16251
+ # Single component
16252
+ current_flow = nx.current_flow_betweenness_centrality(self.G)
16253
+ title_suffix = ""
16254
+
16255
+ # Generate visualization and results (same for both cases)
14784
16256
  plt.figure(figsize=(15, 8))
14785
16257
  plt.hist(current_flow.values(), bins=50, alpha=0.7)
14786
- plt.title("Current Flow Betweenness Centrality Distribution", fontdict={"size": 35}, loc="center")
16258
+ plt.title(
16259
+ f"Current Flow Betweenness Centrality Distribution{title_suffix}",
16260
+ fontdict={"size": 35}, loc="center"
16261
+ )
14787
16262
  plt.xlabel("Current Flow Betweenness Centrality", fontdict={"size": 20})
14788
16263
  plt.ylabel("Frequency", fontdict={"size": 20})
14789
16264
  plt.show()
14790
- self.network_analysis.format_for_upperright_table(current_flow, metric='Node',
14791
- value='Current Flow Betweenness',
14792
- title="Current Flow Betweenness Table")
16265
+
16266
+ self.network_analysis.format_for_upperright_table(
16267
+ current_flow,
16268
+ metric='Node',
16269
+ value='Current Flow Betweenness',
16270
+ title=f"Current Flow Betweenness Table{title_suffix}"
16271
+ )
16272
+
14793
16273
  except Exception as e:
14794
16274
  print(f"Error generating current flow betweenness histogram: {e}")
14795
16275