nettracer3d 0.9.8__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.

Potentially problematic release.


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

@@ -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,10 +467,97 @@ 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
 
473
+
474
+ def load_file(self):
475
+ """Load CSV or Excel file and convert to dictionary format."""
476
+ try:
477
+ # Open file dialog
478
+ file_filter = "Spreadsheet Files (*.csv *.xlsx);;CSV Files (*.csv);;Excel Files (*.xlsx)"
479
+ filename, _ = QFileDialog.getOpenFileName(
480
+ self,
481
+ "Load File",
482
+ "",
483
+ file_filter
484
+ )
485
+
486
+ if not filename:
487
+ return
488
+
489
+ # Read the file
490
+ if filename.endswith('.csv'):
491
+ df = pd.read_csv(filename)
492
+ elif filename.endswith('.xlsx'):
493
+ df = pd.read_excel(filename)
494
+ else:
495
+ QMessageBox.warning(self, "Error", "Please select a CSV or Excel file.")
496
+ return
497
+
498
+ if df.empty:
499
+ QMessageBox.warning(self, "Error", "The file appears to be empty.")
500
+ return
501
+
502
+ # Extract headers
503
+ headers = df.columns.tolist()
504
+ if len(headers) < 1:
505
+ QMessageBox.warning(self, "Error", "File must have at least 1 column.")
506
+ return
507
+
508
+ # Extract filename without extension for title
509
+ import os
510
+ title = os.path.splitext(os.path.basename(filename))[0]
511
+
512
+ if len(headers) == 1:
513
+ # Single column: pass header to metric, column data as list to data, nothing to value
514
+ metric = headers[0]
515
+ data = df.iloc[:, 0].tolist() # First column as list
516
+ value = None
517
+
518
+ df = self.format_for_upperright_table(data=data, metric=metric, value=value, title=title)
519
+ return df
520
+ else:
521
+ # Multiple columns: create dictionary as before
522
+ # First column header (for metric parameter)
523
+ metric = headers[0]
524
+
525
+ # Remaining headers (for value parameter)
526
+ value = headers[1:]
527
+
528
+ # Create dictionary
529
+ data_dict = {}
530
+
531
+ for index, row in df.iterrows():
532
+ key = row.iloc[0] # First column value as key
533
+
534
+ if len(headers) == 2:
535
+ # If only 2 columns, store single value
536
+ data_dict[key] = row.iloc[1]
537
+ else:
538
+ # If more than 2 columns, store as list
539
+ data_dict[key] = row.iloc[1:].tolist()
540
+
541
+ if len(value) == 1:
542
+ value = value[0]
543
+
544
+ # Call the parent method
545
+ df = self.format_for_upperright_table(data=data_dict, metric=metric, value=value, title=title)
546
+ return df
547
+
548
+ QMessageBox.information(
549
+ self,
550
+ "Success",
551
+ f"File '{title}' loaded successfully with {len(df)} entries."
552
+ )
553
+
554
+ except Exception as e:
555
+ QMessageBox.critical(
556
+ self,
557
+ "Error",
558
+ f"Failed to load file: {str(e)}"
559
+ )
560
+
469
561
  def popup_canvas(self):
470
562
  """Pop the canvas out into its own window"""
471
563
  if hasattr(self, 'popup_window') and self.popup_window.isVisible():
@@ -791,6 +883,21 @@ class ImageViewerWindow(QMainWindow):
791
883
  elif self.scroll_direction > 0 and new_value <= self.slice_slider.maximum():
792
884
  self.slice_slider.setValue(new_value)
793
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
+
794
901
  def create_highlight_overlay(self, node_indices=None, edge_indices=None, overlay1_indices = None, overlay2_indices = None, bounds = False):
795
902
  """
796
903
  Create a binary overlay highlighting specific nodes and/or edges using parallel processing.
@@ -916,13 +1023,13 @@ class ImageViewerWindow(QMainWindow):
916
1023
 
917
1024
  # Combine results
918
1025
  if node_overlay is not None:
919
- self.highlight_overlay = np.maximum(self.highlight_overlay, node_overlay)
1026
+ self.highlight_overlay = np.maximum(self.highlight_overlay, node_overlay).astype(np.uint8)
920
1027
  if edge_overlay is not None:
921
- self.highlight_overlay = np.maximum(self.highlight_overlay, edge_overlay)
1028
+ self.highlight_overlay = np.maximum(self.highlight_overlay, edge_overlay).astype(np.uint8)
922
1029
  if overlay1_overlay is not None:
923
- self.highlight_overlay = np.maximum(self.highlight_overlay, overlay1_overlay)
1030
+ self.highlight_overlay = np.maximum(self.highlight_overlay, overlay1_overlay).astype(np.uint8)
924
1031
  if overlay2_overlay is not None:
925
- self.highlight_overlay = np.maximum(self.highlight_overlay, overlay2_overlay)
1032
+ self.highlight_overlay = np.maximum(self.highlight_overlay, overlay2_overlay).astype(np.uint8)
926
1033
 
927
1034
  # Update display
928
1035
  self.update_display(preserve_zoom=(current_xlim, current_ylim))
@@ -931,6 +1038,8 @@ class ImageViewerWindow(QMainWindow):
931
1038
 
932
1039
  """Highlight overlay generation method specific for the segmenter interactive mode"""
933
1040
 
1041
+ self.mini_overlay_data = None
1042
+ self.highlight_overlay = None
934
1043
 
935
1044
  def process_chunk_bounds(chunk_data, indices_to_check):
936
1045
  """Process a single chunk of the array to create highlight mask"""
@@ -1010,6 +1119,30 @@ class ImageViewerWindow(QMainWindow):
1010
1119
  current_ylim = self.ax.get_ylim()
1011
1120
  self.update_display_pan_mode(current_xlim, current_ylim)
1012
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
+
1013
1146
 
1014
1147
 
1015
1148
  def create_mini_overlay(self, node_indices = None, edge_indices = None):
@@ -1169,7 +1302,9 @@ class ImageViewerWindow(QMainWindow):
1169
1302
 
1170
1303
  if my_network.node_identities is not None:
1171
1304
  identity_menu = QMenu("Show Identity", self)
1172
- 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:
1173
1308
  show_identity = identity_menu.addAction(f"ID: {item}")
1174
1309
  show_identity.triggered.connect(lambda checked, item=item: self.handle_show_identities(sort=item))
1175
1310
  context_menu.addMenu(identity_menu)
@@ -1178,6 +1313,9 @@ class ImageViewerWindow(QMainWindow):
1178
1313
  select_nodes = select_all_menu.addAction("Nodes")
1179
1314
  select_both = select_all_menu.addAction("Nodes + Edges")
1180
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")
1181
1319
  context_menu.addMenu(select_all_menu)
1182
1320
 
1183
1321
  if len(self.clicked_values['nodes']) > 0 or len(self.clicked_values['edges']) > 0:
@@ -1201,32 +1339,40 @@ class ImageViewerWindow(QMainWindow):
1201
1339
  # Create measurement submenu
1202
1340
  measure_menu = context_menu.addMenu("Measurements")
1203
1341
 
1204
- # Distance measurement options
1205
1342
  distance_menu = measure_menu.addMenu("Distance")
1206
1343
  if self.current_point is None:
1207
1344
  show_point_menu = distance_menu.addAction("Place First Point")
1208
1345
  show_point_menu.triggered.connect(
1209
1346
  lambda: self.place_distance_point(x_idx, y_idx, self.current_slice))
1210
- else:
1347
+ elif (self.current_point is not None and
1348
+ hasattr(self, 'measurement_mode') and
1349
+ self.measurement_mode == "distance"):
1211
1350
  show_point_menu = distance_menu.addAction("Place Second Point")
1212
1351
  show_point_menu.triggered.connect(
1213
1352
  lambda: self.place_distance_point(x_idx, y_idx, self.current_slice))
1214
-
1353
+
1215
1354
  # Angle measurement options
1216
1355
  angle_menu = measure_menu.addMenu("Angle")
1217
1356
  if self.current_point is None:
1218
1357
  angle_first = angle_menu.addAction("Place First Point (A)")
1219
1358
  angle_first.triggered.connect(
1220
1359
  lambda: self.place_angle_point(x_idx, y_idx, self.current_slice))
1221
- 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"):
1222
1364
  angle_second = angle_menu.addAction("Place Second Point (B - Vertex)")
1223
1365
  angle_second.triggered.connect(
1224
1366
  lambda: self.place_angle_point(x_idx, y_idx, self.current_slice))
1225
- 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"):
1226
1371
  angle_third = angle_menu.addAction("Place Third Point (C)")
1227
1372
  angle_third.triggered.connect(
1228
1373
  lambda: self.place_angle_point(x_idx, y_idx, self.current_slice))
1229
1374
 
1375
+
1230
1376
  show_remove_menu = measure_menu.addAction("Remove All Measurements")
1231
1377
  show_remove_menu.triggered.connect(self.handle_remove_all_measurements)
1232
1378
 
@@ -1244,6 +1390,9 @@ class ImageViewerWindow(QMainWindow):
1244
1390
  select_nodes.triggered.connect(lambda: self.handle_select_all(edges = False, nodes = True))
1245
1391
  select_both.triggered.connect(lambda: self.handle_select_all(edges = True))
1246
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))
1247
1396
  if self.highlight_overlay is not None or self.mini_overlay_data is not None:
1248
1397
  highlight_select = context_menu.addAction("Add highlight in network selection")
1249
1398
  highlight_select.triggered.connect(self.handle_highlight_select)
@@ -1254,15 +1403,22 @@ class ImageViewerWindow(QMainWindow):
1254
1403
  except IndexError:
1255
1404
  pass
1256
1405
 
1257
-
1258
1406
  def place_distance_point(self, x, y, z):
1259
1407
  """Place a measurement point for distance measurement."""
1260
1408
  if self.current_point is None:
1261
1409
  # This is the first point
1262
1410
  self.current_point = (x, y, z)
1263
- self.ax.plot(x, y, 'yo', markersize=8)
1264
- 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}",
1265
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
+
1266
1422
  self.canvas.draw()
1267
1423
  self.measurement_mode = "distance"
1268
1424
  else:
@@ -1276,21 +1432,28 @@ class ImageViewerWindow(QMainWindow):
1276
1432
  ((z2-z1)*my_network.z_scale)**2)
1277
1433
  distance2 = np.sqrt(((x2-x1))**2 + ((y2-y1))**2 + ((z2-z1))**2)
1278
1434
 
1279
- # Store the point pair
1435
+ # Store the point pair with type indicator
1280
1436
  self.measurement_points.append({
1281
1437
  'pair_index': self.current_pair_index,
1282
1438
  'point1': self.current_point,
1283
1439
  'point2': (x2, y2, z2),
1284
1440
  'distance': distance,
1285
- 'distance2': distance2
1441
+ 'distance2': distance2,
1442
+ 'type': 'distance' # Added type tracking
1286
1443
  })
1287
1444
 
1288
- # Draw second point and line
1289
- self.ax.plot(x2, y2, 'yo', markersize=8)
1290
- 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}",
1291
1448
  color='yellow', ha='center', va='bottom')
1449
+
1450
+ # Add to measurement_artists
1451
+ self.measurement_artists.extend([pt2, txt2])
1452
+
1292
1453
  if z1 == z2: # Only draw line if points are on same slice
1293
- 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
+
1294
1457
  self.canvas.draw()
1295
1458
 
1296
1459
  # Update measurement display
@@ -1303,12 +1466,19 @@ class ImageViewerWindow(QMainWindow):
1303
1466
 
1304
1467
  def place_angle_point(self, x, y, z):
1305
1468
  """Place a measurement point for angle measurement."""
1469
+ if not hasattr(self, 'measurement_artists'):
1470
+ self.measurement_artists = []
1471
+
1306
1472
  if self.current_point is None:
1307
1473
  # First point (A)
1308
1474
  self.current_point = (x, y, z)
1309
- self.ax.plot(x, y, 'go', markersize=8)
1310
- 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}",
1311
1479
  color='green', ha='center', va='bottom')
1480
+ self.measurement_artists.extend([pt, txt])
1481
+
1312
1482
  self.canvas.draw()
1313
1483
  self.measurement_mode = "angle"
1314
1484
 
@@ -1317,13 +1487,16 @@ class ImageViewerWindow(QMainWindow):
1317
1487
  self.current_second_point = (x, y, z)
1318
1488
  x1, y1, z1 = self.current_point
1319
1489
 
1320
- self.ax.plot(x, y, 'go', markersize=8)
1321
- 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}",
1322
1493
  color='green', ha='center', va='bottom')
1494
+ self.measurement_artists.extend([pt, txt])
1323
1495
 
1324
1496
  # Draw line from A to B
1325
1497
  if z1 == z:
1326
- 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)
1327
1500
  self.canvas.draw()
1328
1501
 
1329
1502
  else:
@@ -1346,7 +1519,7 @@ class ImageViewerWindow(QMainWindow):
1346
1519
  **angle_data
1347
1520
  })
1348
1521
 
1349
- # Also add the two distances as separate pairs
1522
+ # Also add the two distances as separate pairs with type indicator
1350
1523
  dist_ab = np.sqrt(((x2-x1)*my_network.xy_scale)**2 +
1351
1524
  ((y2-y1)*my_network.xy_scale)**2 +
1352
1525
  ((z2-z1)*my_network.z_scale)**2)
@@ -1363,24 +1536,28 @@ class ImageViewerWindow(QMainWindow):
1363
1536
  'point1': (x1, y1, z1),
1364
1537
  'point2': (x2, y2, z2),
1365
1538
  'distance': dist_ab,
1366
- 'distance2': dist_ab_voxel
1539
+ 'distance2': dist_ab_voxel,
1540
+ 'type': 'angle' # Added type tracking
1367
1541
  },
1368
1542
  {
1369
1543
  'pair_index': f"B{self.current_trio_index}-C{self.current_trio_index}",
1370
1544
  'point1': (x2, y2, z2),
1371
1545
  'point2': (x3, y3, z3),
1372
1546
  'distance': dist_bc,
1373
- 'distance2': dist_bc_voxel
1547
+ 'distance2': dist_bc_voxel,
1548
+ 'type': 'angle' # Added type tracking
1374
1549
  }
1375
1550
  ])
1376
1551
 
1377
- # Draw third point and line
1378
- self.ax.plot(x3, y3, 'go', markersize=8)
1379
- 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}",
1380
1555
  color='green', ha='center', va='bottom')
1556
+ self.measurement_artists.extend([pt3, txt3])
1381
1557
 
1382
1558
  if z2 == z3: # Draw line from B to C if on same slice
1383
- 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)
1384
1561
  self.canvas.draw()
1385
1562
 
1386
1563
  # Update measurement display
@@ -1392,6 +1569,7 @@ class ImageViewerWindow(QMainWindow):
1392
1569
  self.current_trio_index += 1
1393
1570
  self.measurement_mode = "angle"
1394
1571
 
1572
+
1395
1573
  def calculate_3d_angle(self, point_a, point_b, point_c):
1396
1574
  """Calculate 3D angle at vertex B between points A-B-C."""
1397
1575
  x1, y1, z1 = point_a
@@ -1555,28 +1733,12 @@ class ImageViewerWindow(QMainWindow):
1555
1733
  if edges:
1556
1734
  edge_indices = filtered_df.iloc[:, 2].unique().tolist()
1557
1735
  self.clicked_values['edges'] = edge_indices
1558
-
1559
- if self.channel_data[1].shape[0] * self.channel_data[1].shape[1] * self.channel_data[1].shape[2] > self.mini_thresh:
1560
- self.mini_overlay = True
1561
- self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
1562
- else:
1563
- self.create_highlight_overlay(
1564
- node_indices=self.clicked_values['nodes'],
1565
- edge_indices=self.clicked_values['edges']
1566
- )
1736
+ self.evaluate_mini(mode = 'edges')
1567
1737
  else:
1568
- if self.channel_data[0].shape[0] * self.channel_data[0].shape[1] * self.channel_data[0].shape[2] > self.mini_thresh:
1569
- self.mini_overlay = True
1570
- self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
1571
- else:
1572
- self.create_highlight_overlay(
1573
- node_indices=self.clicked_values['nodes'],
1574
- edge_indices = self.clicked_values['edges']
1575
- )
1576
-
1738
+ self.evaluate_mini()
1577
1739
 
1578
1740
  except Exception as e:
1579
- print(f"Error processing neighbors: {e}")
1741
+ print(f"Error showing neighbors: {e}")
1580
1742
 
1581
1743
 
1582
1744
  def handle_show_component(self, edges = False, nodes = True):
@@ -1647,23 +1809,10 @@ class ImageViewerWindow(QMainWindow):
1647
1809
  if edges:
1648
1810
  edge_indices = filtered_df.iloc[:, 2].unique().tolist()
1649
1811
  self.clicked_values['edges'] = edge_indices
1650
- if self.channel_data[1].shape[0] * self.channel_data[1].shape[1] * self.channel_data[1].shape[2] > self.mini_thresh:
1651
- self.mini_overlay = True
1652
- self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
1653
- else:
1654
- self.create_highlight_overlay(
1655
- node_indices=self.clicked_values['nodes'],
1656
- edge_indices=edge_indices
1657
- )
1812
+ self.evaluate_mini(mode = 'edges')
1658
1813
  else:
1659
- if self.channel_data[0].shape[0] * self.channel_data[0].shape[1] * self.channel_data[0].shape[2] > self.mini_thresh:
1660
- self.mini_overlay = True
1661
- self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
1662
- else:
1663
- self.create_highlight_overlay(
1664
- node_indices = self.clicked_values['nodes'],
1665
- edge_indices = self.clicked_values['edges']
1666
- )
1814
+ self.evaluate_mini()
1815
+
1667
1816
 
1668
1817
  except Exception as e:
1669
1818
 
@@ -1706,23 +1855,27 @@ class ImageViewerWindow(QMainWindow):
1706
1855
 
1707
1856
  nodes = list(set(nodes))
1708
1857
 
1709
- # Get the existing DataFrame from the model
1710
- original_df = self.network_table.model()._data
1858
+ try:
1711
1859
 
1712
- # Create mask for rows for nodes in question
1713
- mask = (
1714
- (original_df.iloc[:, 0].isin(nodes) & original_df.iloc[:, 1].isin(nodes))
1715
- )
1716
-
1717
- # Filter the DataFrame to only include direct connections
1718
- filtered_df = original_df[mask].copy()
1719
-
1720
- # Create new model with filtered DataFrame and update selection table
1721
- new_model = PandasModel(filtered_df)
1722
- self.selection_table.setModel(new_model)
1723
-
1724
- # Switch to selection table
1725
- 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
1726
1879
 
1727
1880
  if edges:
1728
1881
  edge_indices = filtered_df.iloc[:, 2].unique().tolist()
@@ -1855,7 +2008,7 @@ class ImageViewerWindow(QMainWindow):
1855
2008
  self.parent().toggle_channel(1)
1856
2009
  # Navigate to the Z-slice
1857
2010
  self.parent().slice_slider.setValue(int(centroid[0]))
1858
- print(f"Found edge {value} at Z-slice {centroid[0]}")
2011
+ print(f"Found edge {value} at [Z,Y,X] -> {centroid}")
1859
2012
 
1860
2013
  else:
1861
2014
  print(f"Edge {value} not found in centroids dictionary")
@@ -1891,9 +2044,9 @@ class ImageViewerWindow(QMainWindow):
1891
2044
  # Navigate to the Z-slice
1892
2045
  self.parent().slice_slider.setValue(int(centroid[0]))
1893
2046
  if mode == 0:
1894
- print(f"Found node {value} at Z-slice {centroid[0]}")
2047
+ print(f"Found node {value} at [Z,Y,X] -> {centroid}")
1895
2048
  elif mode == 2:
1896
- 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}")
1897
2050
 
1898
2051
 
1899
2052
  else:
@@ -1924,12 +2077,15 @@ class ImageViewerWindow(QMainWindow):
1924
2077
 
1925
2078
 
1926
2079
 
1927
- def handle_select_all(self, nodes = True, edges = False):
2080
+ def handle_select_all(self, nodes = True, edges = False, network = False):
1928
2081
 
1929
2082
  try:
1930
2083
 
1931
2084
  if nodes:
1932
- 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]))
1933
2089
  if nodes[0] == 0:
1934
2090
  del nodes[0]
1935
2091
  num = (self.channel_data[0].shape[0] * self.channel_data[0].shape[1] * self.channel_data[0].shape[2])
@@ -1937,7 +2093,10 @@ class ImageViewerWindow(QMainWindow):
1937
2093
  else:
1938
2094
  nodes = []
1939
2095
  if edges:
1940
- 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]
1941
2100
  num = (self.channel_data[1].shape[0] * self.channel_data[1].shape[1] * self.channel_data[1].shape[2])
1942
2101
  if edges[0] == 0:
1943
2102
  del edges[0]
@@ -2015,6 +2174,16 @@ class ImageViewerWindow(QMainWindow):
2015
2174
  except:
2016
2175
  pass
2017
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
+
2018
2187
 
2019
2188
  elif sort == 'edge':
2020
2189
 
@@ -2060,7 +2229,17 @@ class ImageViewerWindow(QMainWindow):
2060
2229
  except:
2061
2230
  pass
2062
2231
 
2063
- 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)
2064
2243
 
2065
2244
  except:
2066
2245
  pass
@@ -2178,7 +2357,7 @@ class ImageViewerWindow(QMainWindow):
2178
2357
  unique_labels = np.unique(input_array[binary_mask])
2179
2358
  print(f"Processing {len(unique_labels)} unique labels")
2180
2359
 
2181
- # Get all bounding boxes at once - this is very fast
2360
+ # Get all bounding boxes at once
2182
2361
  bounding_boxes = ndimage.find_objects(input_array)
2183
2362
 
2184
2363
  # Prepare work items - just check if bounding box exists for each label
@@ -2194,7 +2373,7 @@ class ImageViewerWindow(QMainWindow):
2194
2373
  bbox = bounding_boxes[bbox_index]
2195
2374
  work_items.append((orig_label, bbox))
2196
2375
 
2197
- print(f"Created {len(work_items)} work items")
2376
+ #print(f"Created {len(work_items)} work items")
2198
2377
 
2199
2378
  # If we have work items, process them
2200
2379
  if len(work_items) == 0:
@@ -2214,7 +2393,7 @@ class ImageViewerWindow(QMainWindow):
2214
2393
  return orig_label, bbox, labeled_sub, num_cc
2215
2394
 
2216
2395
  except Exception as e:
2217
- print(f"Error processing label {orig_label}: {e}")
2396
+ #print(f"Error processing label {orig_label}: {e}")
2218
2397
  return orig_label, bbox, None, 0
2219
2398
 
2220
2399
  # Execute in parallel
@@ -2230,7 +2409,7 @@ class ImageViewerWindow(QMainWindow):
2230
2409
 
2231
2410
  for orig_label, bbox, labeled_sub, num_cc in results:
2232
2411
  if num_cc > 0 and labeled_sub is not None:
2233
- print(f"Label {orig_label}: {num_cc} components")
2412
+ #print(f"Label {orig_label}: {num_cc} components")
2234
2413
  # Remap labels and place in output
2235
2414
  for cc_id in range(1, num_cc + 1):
2236
2415
  mask = labeled_sub == cc_id
@@ -2243,7 +2422,7 @@ class ImageViewerWindow(QMainWindow):
2243
2422
 
2244
2423
  def handle_seperate(self):
2245
2424
  """
2246
- Fixed version with proper mask handling and debugging
2425
+ Seperate objects in an array that share a label but do not touch
2247
2426
  """
2248
2427
  try:
2249
2428
  # Handle nodes
@@ -2252,7 +2431,6 @@ class ImageViewerWindow(QMainWindow):
2252
2431
  # Create highlight overlay (this should preserve original label values)
2253
2432
  self.create_highlight_overlay(node_indices=self.clicked_values['nodes'])
2254
2433
 
2255
- # DON'T convert to boolean yet - we need the original labels!
2256
2434
  # Create a boolean mask for where we have highlighted values
2257
2435
  highlight_mask = self.highlight_overlay != 0
2258
2436
 
@@ -2262,7 +2440,7 @@ class ImageViewerWindow(QMainWindow):
2262
2440
  # Get non-highlighted part of the array
2263
2441
  non_highlighted = np.where(highlight_mask, 0, my_network.nodes)
2264
2442
 
2265
- # Calculate max_val properly
2443
+ # Calculate max_val
2266
2444
  max_val = np.max(non_highlighted) if np.any(non_highlighted) else 0
2267
2445
 
2268
2446
  # Process highlighted part
@@ -2487,9 +2665,9 @@ class ImageViewerWindow(QMainWindow):
2487
2665
  self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
2488
2666
  self.needs_mini = False
2489
2667
  else:
2490
- self.create_highlight_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
2668
+ self.evaluate_mini()
2491
2669
  else:
2492
- self.create_highlight_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
2670
+ self.evaluate_mini()
2493
2671
 
2494
2672
 
2495
2673
  self.update_display(preserve_zoom=(current_xlim, current_ylim))
@@ -3554,17 +3732,23 @@ class ImageViewerWindow(QMainWindow):
3554
3732
 
3555
3733
  # Add highlight overlays if they exist (with downsampling)
3556
3734
  if self.mini_overlay and self.highlight and self.machine_window is None:
3557
- display_overlay = crop_and_downsample_image(self.mini_overlay_data, y_min_padded, y_max_padded, x_min_padded, x_max_padded, downsample_factor)
3558
- highlight_rgba = self.create_highlight_rgba(display_overlay, yellow=True)
3559
- 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
3560
3741
  elif self.highlight_overlay is not None and self.highlight:
3561
- highlight_slice = self.highlight_overlay[self.current_slice]
3562
- display_highlight = crop_and_downsample_image(highlight_slice, y_min_padded, y_max_padded, x_min_padded, x_max_padded, downsample_factor)
3563
- if self.machine_window is None:
3564
- highlight_rgba = self.create_highlight_rgba(display_highlight, yellow=True)
3565
- else:
3566
- highlight_rgba = self.create_highlight_rgba(display_highlight, yellow=False)
3567
- 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
3568
3752
 
3569
3753
  # Convert to 0-255 range for display
3570
3754
  return (composite * 255).astype(np.uint8)
@@ -3659,6 +3843,12 @@ class ImageViewerWindow(QMainWindow):
3659
3843
  self.ax.clear()
3660
3844
  self.ax.set_facecolor('black')
3661
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
+
3662
3852
  # Get original dimensions (before downsampling)
3663
3853
  if hasattr(self, 'original_dims') and self.original_dims:
3664
3854
  height, width = self.original_dims
@@ -3740,23 +3930,129 @@ class ImageViewerWindow(QMainWindow):
3740
3930
  for spine in self.ax.spines.values():
3741
3931
  spine.set_color('black')
3742
3932
 
3743
- # Add measurement points if they exist (coordinates remain in original space)
3744
- for point in self.measurement_points:
3745
- x1, y1, z1 = point['point1']
3746
- x2, y2, z2 = point['point2']
3747
- pair_idx = point['pair_index']
3748
-
3749
- if z1 == self.current_slice:
3750
- self.ax.plot(x1, y1, 'yo', markersize=8)
3751
- self.ax.text(x1, y1+5, str(pair_idx),
3752
- color='white', ha='center', va='bottom')
3753
- if z2 == self.current_slice:
3754
- self.ax.plot(x2, y2, 'yo', markersize=8)
3755
- self.ax.text(x2, y2+5, str(pair_idx),
3756
- 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])
3999
+
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)
3757
4014
 
3758
- if z1 == z2 == self.current_slice:
3759
- self.ax.plot([x1, x2], [y1, y2], 'r--', alpha=0.5)
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)
3760
4056
 
3761
4057
  #self.canvas.setCursor(Qt.CursorShape.ClosedHandCursor)
3762
4058
 
@@ -3872,7 +4168,34 @@ class ImageViewerWindow(QMainWindow):
3872
4168
  if len(self.clicked_values['edges']):
3873
4169
  self.highlight_value_in_tables(self.clicked_values['edges'][-1])
3874
4170
  self.handle_info('edge')
3875
-
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
+
3876
4199
  elif not self.selecting and self.selection_start: # If we had a click but never started selection
3877
4200
  # Handle as a normal click
3878
4201
  self.on_mouse_click(event)
@@ -3895,10 +4218,8 @@ class ImageViewerWindow(QMainWindow):
3895
4218
  elif self.zoom_mode:
3896
4219
  # Handle zoom mode press
3897
4220
  if self.original_xlim is None:
3898
- self.original_xlim = self.ax.get_xlim()
3899
- #print(self.original_xlim)
3900
- self.original_ylim = self.ax.get_ylim()
3901
- #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)
3902
4223
 
3903
4224
  current_xlim = self.ax.get_xlim()
3904
4225
  current_ylim = self.ax.get_ylim()
@@ -4060,8 +4381,8 @@ class ImageViewerWindow(QMainWindow):
4060
4381
  if self.zoom_mode:
4061
4382
  # Existing zoom functionality
4062
4383
  if self.original_xlim is None:
4063
- self.original_xlim = self.ax.get_xlim()
4064
- 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)
4065
4386
 
4066
4387
  current_xlim = self.ax.get_xlim()
4067
4388
  current_ylim = self.ax.get_ylim()
@@ -4237,6 +4558,8 @@ class ImageViewerWindow(QMainWindow):
4237
4558
  for i in range(4):
4238
4559
  load_action = load_menu.addAction(f"Load {self.channel_names[i]}")
4239
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))
4240
4563
  load_action = load_menu.addAction("Load Network")
4241
4564
  load_action.triggered.connect(self.load_network)
4242
4565
  load_action = load_menu.addAction("Load From Excel Helper")
@@ -4277,6 +4600,8 @@ class ImageViewerWindow(QMainWindow):
4277
4600
  allstats_action.triggered.connect(self.stats)
4278
4601
  histos_action = stats_menu.addAction("Network Statistic Histograms")
4279
4602
  histos_action.triggered.connect(self.histos)
4603
+ sig_action = stats_menu.addAction("Significance Testing")
4604
+ sig_action.triggered.connect(self.sig_test)
4280
4605
  radial_action = stats_menu.addAction("Radial Distribution Analysis")
4281
4606
  radial_action.triggered.connect(self.show_radial_dialog)
4282
4607
  neighbor_id_action = stats_menu.addAction("Identity Distribution of Neighbors")
@@ -4291,8 +4616,12 @@ class ImageViewerWindow(QMainWindow):
4291
4616
  vol_action.triggered.connect(self.volumes)
4292
4617
  rad_action = stats_menu.addAction("Calculate Radii")
4293
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)
4294
4621
  inter_action = stats_menu.addAction("Calculate Node < > Edge Interaction")
4295
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)
4296
4625
  overlay_menu = analysis_menu.addMenu("Data/Overlays")
4297
4626
  degree_action = overlay_menu.addAction("Get Degree Information")
4298
4627
  degree_action.triggered.connect(self.show_degree_dialog)
@@ -4326,12 +4655,16 @@ class ImageViewerWindow(QMainWindow):
4326
4655
  calc_branch_action.triggered.connect(self.handle_calc_branch)
4327
4656
  calc_branchprox_action = calculate_menu.addAction("Calculate Branch Adjacency Network (Of Edges)")
4328
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)
4329
4660
  centroid_action = calculate_menu.addAction("Calculate Centroids (Active Image)")
4330
4661
  centroid_action.triggered.connect(self.show_centroid_dialog)
4331
4662
 
4332
4663
  image_menu = process_menu.addMenu("Image")
4333
4664
  resize_action = image_menu.addAction("Resize (Up/Downsample)")
4334
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)
4335
4668
  dilate_action = image_menu.addAction("Dilate")
4336
4669
  dilate_action.triggered.connect(self.show_dilate_dialog)
4337
4670
  erode_action = image_menu.addAction("Erode")
@@ -4342,7 +4675,7 @@ class ImageViewerWindow(QMainWindow):
4342
4675
  binarize_action.triggered.connect(self.show_binarize_dialog)
4343
4676
  label_action = image_menu.addAction("Label Objects")
4344
4677
  label_action.triggered.connect(self.show_label_dialog)
4345
- slabel_action = image_menu.addAction("Neighborhood Labels")
4678
+ slabel_action = image_menu.addAction("Neighbor Labels")
4346
4679
  slabel_action.triggered.connect(self.show_slabel_dialog)
4347
4680
  thresh_action = image_menu.addAction("Threshold/Segment")
4348
4681
  thresh_action.triggered.connect(self.show_thresh_dialog)
@@ -4418,7 +4751,7 @@ class ImageViewerWindow(QMainWindow):
4418
4751
 
4419
4752
 
4420
4753
  # Add after your other buttons
4421
- self.popup_button = QPushButton("⤴") # or "🔗" or "⤴"
4754
+ self.popup_button = QPushButton("⤴")
4422
4755
  self.popup_button.setFixedSize(40, 40)
4423
4756
  self.popup_button.setToolTip("Pop out canvas")
4424
4757
  self.popup_button.clicked.connect(self.popup_canvas)
@@ -4433,6 +4766,12 @@ class ImageViewerWindow(QMainWindow):
4433
4766
  cam_button.setStyleSheet("font-size: 24px;")
4434
4767
  cam_button.clicked.connect(self.snap)
4435
4768
  corner_layout.addWidget(cam_button)
4769
+
4770
+ load_button = QPushButton("📁")
4771
+ load_button.setFixedSize(40, 40)
4772
+ load_button.setStyleSheet("font-size: 24px;")
4773
+ load_button.clicked.connect(self.load_file)
4774
+ corner_layout.addWidget(load_button)
4436
4775
 
4437
4776
  # Set as corner widget
4438
4777
  menubar.setCornerWidget(corner_widget, Qt.Corner.TopRightCorner)
@@ -4480,7 +4819,10 @@ class ImageViewerWindow(QMainWindow):
4480
4819
  # Invalid input - reset to default
4481
4820
  self.downsample_factor = 1
4482
4821
 
4483
- 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
4484
4826
 
4485
4827
  # Optional: Trigger display update if you want immediate effect
4486
4828
  if update:
@@ -4565,8 +4907,11 @@ class ImageViewerWindow(QMainWindow):
4565
4907
  self.cellpose_launcher.launch_cellpose_gui(use_3d = use_3d)
4566
4908
 
4567
4909
  except:
4568
- import traceback
4569
- 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
+ )
4570
4915
  pass
4571
4916
 
4572
4917
 
@@ -4613,6 +4958,16 @@ class ImageViewerWindow(QMainWindow):
4613
4958
  except Exception as e:
4614
4959
  print(f"Error creating histogram selector: {e}")
4615
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
+
4616
4971
  def volumes(self):
4617
4972
 
4618
4973
 
@@ -4638,7 +4993,7 @@ class ImageViewerWindow(QMainWindow):
4638
4993
 
4639
4994
 
4640
4995
 
4641
- 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):
4642
4997
  """
4643
4998
  Format dictionary or list data for display in upper right table.
4644
4999
 
@@ -4724,11 +5079,12 @@ class ImageViewerWindow(QMainWindow):
4724
5079
  table = CustomTableView(self)
4725
5080
  table.setModel(PandasModel(df))
4726
5081
 
4727
- try:
4728
- first_column_name = table.model()._data.columns[0]
4729
- table.sort_table(first_column_name, ascending=True)
4730
- except:
4731
- 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
4732
5088
 
4733
5089
  # Add to tabbed widget
4734
5090
  if title is None:
@@ -4742,6 +5098,8 @@ class ImageViewerWindow(QMainWindow):
4742
5098
  for column in range(table.model().columnCount(None)):
4743
5099
  table.resizeColumnToContents(column)
4744
5100
 
5101
+ return df
5102
+
4745
5103
  except:
4746
5104
  pass
4747
5105
 
@@ -4758,6 +5116,10 @@ class ImageViewerWindow(QMainWindow):
4758
5116
  dialog = MergeNodeIdDialog(self)
4759
5117
  dialog.exec()
4760
5118
 
5119
+ def show_multichan_dialog(self, data):
5120
+ dialog = MultiChanDialog(self, data)
5121
+ dialog.show()
5122
+
4761
5123
  def show_gray_water_dialog(self):
4762
5124
  """Show the gray watershed parameter dialog."""
4763
5125
  dialog = GrayWaterDialog(self)
@@ -4860,7 +5222,7 @@ class ImageViewerWindow(QMainWindow):
4860
5222
 
4861
5223
  my_network.edges = (my_network.nodes == 0) * my_network.edges
4862
5224
 
4863
- 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)
4864
5226
 
4865
5227
  self.load_channel(1, my_network.edges, data = True)
4866
5228
  self.load_channel(0, my_network.nodes, data = True)
@@ -4894,6 +5256,12 @@ class ImageViewerWindow(QMainWindow):
4894
5256
 
4895
5257
  self.load_channel(0, my_network.edges, data = True)
4896
5258
 
5259
+ try:
5260
+ self.branch_dict[0] = self.branch_dict[1]
5261
+ self.branch_dict[1] = None
5262
+ except:
5263
+ pass
5264
+
4897
5265
  self.delete_channel(1, False)
4898
5266
 
4899
5267
  my_network.morph_proximity(search = [3,3], fastdil = True)
@@ -4910,14 +5278,51 @@ class ImageViewerWindow(QMainWindow):
4910
5278
  dialog = CentroidDialog(self)
4911
5279
  dialog.exec()
4912
5280
 
4913
- 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):
4914
5319
  """show the dilate dialog"""
4915
- dialog = DilateDialog(self)
5320
+ dialog = DilateDialog(self, args)
4916
5321
  dialog.exec()
4917
5322
 
4918
- def show_erode_dialog(self):
5323
+ def show_erode_dialog(self, args = None):
4919
5324
  """show the erode dialog"""
4920
- dialog = ErodeDialog(self)
5325
+ dialog = ErodeDialog(self, args)
4921
5326
  dialog.exec()
4922
5327
 
4923
5328
  def show_hole_dialog(self):
@@ -5016,6 +5421,9 @@ class ImageViewerWindow(QMainWindow):
5016
5421
  dialog = ResizeDialog(self)
5017
5422
  dialog.exec()
5018
5423
 
5424
+ def show_clean_dialog(self):
5425
+ dialog = CleanDialog(self)
5426
+ dialog.show()
5019
5427
 
5020
5428
  def show_properties_dialog(self):
5021
5429
  """Show the properties dialog"""
@@ -5170,7 +5578,6 @@ class ImageViewerWindow(QMainWindow):
5170
5578
 
5171
5579
  elif sort == 'Merge Nodes':
5172
5580
  try:
5173
-
5174
5581
  if my_network.nodes is None:
5175
5582
  QMessageBox.critical(
5176
5583
  self,
@@ -5178,72 +5585,118 @@ class ImageViewerWindow(QMainWindow):
5178
5585
  "Please load your first set of nodes into the 'Nodes' channel first"
5179
5586
  )
5180
5587
  return
5181
-
5182
5588
  if len(np.unique(my_network.nodes)) < 3:
5183
5589
  self.show_label_dialog()
5184
-
5185
- # First ask user what they want to select
5186
- msg = QMessageBox()
5187
- msg.setWindowTitle("Selection Type")
5188
- msg.setText("Would you like to select a TIFF file or a directory?")
5189
- tiff_button = msg.addButton("TIFF File", QMessageBox.ButtonRole.AcceptRole)
5190
- dir_button = msg.addButton("Directory", QMessageBox.ButtonRole.AcceptRole)
5191
- msg.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
5192
-
5193
- msg.exec()
5194
-
5195
- # Also if they want centroids:
5196
- msg2 = QMessageBox()
5197
- msg2.setWindowTitle("Selection Type")
5198
- msg2.setText("Would you like to compute node centroids for each image prior to merging?")
5199
- yes_button = msg2.addButton("Yes", QMessageBox.ButtonRole.AcceptRole)
5200
- no_button = msg2.addButton("No", QMessageBox.ButtonRole.AcceptRole)
5201
- msg2.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
5202
-
5203
- msg2.exec()
5204
-
5205
- if msg2.clickedButton() == yes_button:
5206
- centroids = True
5207
- else:
5208
- centroids = False
5209
-
5210
- if msg.clickedButton() == tiff_button:
5211
- # Code for selecting TIFF files
5212
- filename, _ = QFileDialog.getOpenFileName(
5213
- self,
5214
- "Select TIFF file",
5215
- "",
5216
- "TIFF files (*.tiff *.tif)"
5217
- )
5218
- if filename:
5219
- selected_path = filename
5220
-
5221
- elif msg.clickedButton() == dir_button:
5222
- # Code for selecting directories
5223
- dialog = QFileDialog(self)
5224
- dialog.setOption(QFileDialog.Option.DontUseNativeDialog)
5225
- dialog.setOption(QFileDialog.Option.ReadOnly)
5226
- dialog.setFileMode(QFileDialog.FileMode.Directory)
5227
- dialog.setViewMode(QFileDialog.ViewMode.Detail)
5228
-
5229
- if dialog.exec() == QFileDialog.DialogCode.Accepted:
5230
- selected_path = dialog.directory().absolutePath()
5231
-
5232
- my_network.merge_nodes(selected_path, root_id = self.node_name, centroids = centroids)
5233
- self.load_channel(0, my_network.nodes, True)
5234
-
5235
-
5236
- 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
5237
5643
  try:
5238
- self.format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity', 'Node Identities')
5239
- except Exception as e:
5240
- print(f"Error loading node identity table: {e}")
5241
- if centroids:
5242
- self.format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
5243
-
5244
-
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
+
5245
5699
  except Exception as e:
5246
-
5247
5700
  QMessageBox.critical(
5248
5701
  self,
5249
5702
  "Error Merging",
@@ -5266,8 +5719,10 @@ class ImageViewerWindow(QMainWindow):
5266
5719
  )
5267
5720
 
5268
5721
  self.last_load = directory
5269
-
5722
+ self.last_saved = os.path.dirname(directory)
5723
+ self.last_save_name = directory
5270
5724
 
5725
+ self.channel_data = [None] * 5
5271
5726
  if directory != "":
5272
5727
 
5273
5728
  self.reset(network = True, xy_scale = 1, z_scale = 1, nodes = True, edges = True, network_overlay = True, id_overlay = True, update = False)
@@ -5570,6 +6025,16 @@ class ImageViewerWindow(QMainWindow):
5570
6025
  msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
5571
6026
  return msg.exec() == QMessageBox.StandardButton.Yes
5572
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
+
5573
6038
  def confirm_resize_dialog(self):
5574
6039
  """Shows a dialog asking user to resize image"""
5575
6040
  msg = QMessageBox()
@@ -5580,12 +6045,41 @@ class ImageViewerWindow(QMainWindow):
5580
6045
  msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
5581
6046
  return msg.exec() == QMessageBox.StandardButton.Yes
5582
6047
 
5583
- 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):
5584
6079
  """Load a channel and enable active channel selection if needed."""
5585
6080
 
5586
6081
  try:
5587
6082
 
5588
- self.hold_update = True
5589
6083
  if not data: # For solo loading
5590
6084
  filename, _ = QFileDialog.getOpenFileName(
5591
6085
  self,
@@ -5605,7 +6099,18 @@ class ImageViewerWindow(QMainWindow):
5605
6099
  try:
5606
6100
  if file_extension in ['tif', 'tiff']:
5607
6101
  import tifffile
5608
- self.channel_data[channel_index] = tifffile.imread(filename)
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
5609
6114
 
5610
6115
  elif file_extension == 'nii':
5611
6116
  import nibabel as nib
@@ -5653,7 +6158,7 @@ class ImageViewerWindow(QMainWindow):
5653
6158
  try:
5654
6159
  if len(self.channel_data[channel_index].shape) == 3: # potentially 2D RGB
5655
6160
  if self.channel_data[channel_index].shape[-1] in (3, 4): # last dim is 3 or 4
5656
- if not data and self.shape is None:
6161
+ if not data:
5657
6162
  if self.confirm_rgb_dialog():
5658
6163
  # User confirmed it's 2D RGB, expand to 4D
5659
6164
  self.channel_data[channel_index] = np.expand_dims(self.channel_data[channel_index], axis=0)
@@ -5663,12 +6168,18 @@ class ImageViewerWindow(QMainWindow):
5663
6168
  except:
5664
6169
  pass
5665
6170
 
5666
- if not color:
5667
- try:
5668
- 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:
5669
6180
  self.channel_data[channel_index] = self.reduce_rgb_dimension(self.channel_data[channel_index], 'weight')
5670
- except:
5671
- pass
6181
+ except:
6182
+ pass
5672
6183
 
5673
6184
  reset_resize = False
5674
6185
 
@@ -5677,7 +6188,6 @@ class ImageViewerWindow(QMainWindow):
5677
6188
  if self.highlight_overlay is not None: #Make sure highlight overlay is always the same shape as new images
5678
6189
  try:
5679
6190
  if self.channel_data[i].shape[:3] != self.highlight_overlay.shape:
5680
- self.resizing = True
5681
6191
  reset_resize = True
5682
6192
  self.highlight_overlay = None
5683
6193
  except:
@@ -5704,51 +6214,52 @@ class ImageViewerWindow(QMainWindow):
5704
6214
  my_network.id_overlay = self.channel_data[channel_index]
5705
6215
 
5706
6216
  # Enable the channel button
5707
- self.channel_buttons[channel_index].setEnabled(True)
5708
- 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)
5709
6220
 
5710
6221
 
5711
- # Enable active channel selector if this is the first channel loaded
5712
- if not self.active_channel_combo.isEnabled():
5713
- 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)
5714
6225
 
5715
- # Update slider range if this is the first channel loaded
5716
- try:
5717
- if len(self.channel_data[channel_index].shape) == 3 or len(self.channel_data[channel_index].shape) == 4:
5718
- if not self.slice_slider.isEnabled():
5719
- self.slice_slider.setEnabled(True)
5720
- self.slice_slider.setMinimum(0)
5721
- self.slice_slider.setMaximum(self.channel_data[channel_index].shape[0] - 1)
5722
- if self.slice_slider.value() < self.channel_data[channel_index].shape[0] - 1:
5723
- 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
5724
6238
  else:
5725
- self.slice_slider.setValue(0)
5726
- 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)
5727
6247
  else:
5728
- self.slice_slider.setEnabled(True)
5729
- self.slice_slider.setMinimum(0)
5730
- self.slice_slider.setMaximum(self.channel_data[channel_index].shape[0] - 1)
5731
- if self.slice_slider.value() < self.channel_data[channel_index].shape[0] - 1:
5732
- self.current_slice = self.slice_slider.value()
5733
- else:
5734
- self.current_slice = 0
5735
- self.slice_slider.setValue(0)
5736
- else:
5737
- self.slice_slider.setEnabled(False)
5738
- except:
5739
- pass
6248
+ self.slice_slider.setEnabled(False)
6249
+ except:
6250
+ pass
5740
6251
 
5741
-
5742
- # If this is the first channel loaded, make it active
5743
- if all(not btn.isEnabled() for btn in self.channel_buttons[:channel_index]):
5744
- 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)
5745
6256
 
5746
- if not self.channel_buttons[channel_index].isChecked():
5747
- self.channel_buttons[channel_index].click()
6257
+ if not self.channel_buttons[channel_index].isChecked():
6258
+ self.channel_buttons[channel_index].click()
5748
6259
 
5749
- self.min_max[channel_index][0] = np.min(self.channel_data[channel_index])
5750
- self.min_max[channel_index][1] = np.max(self.channel_data[channel_index])
5751
- 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
5752
6263
 
5753
6264
  try:
5754
6265
  if assign_shape: #keep original shape tracked to undo resampling.
@@ -5763,7 +6274,13 @@ class ImageViewerWindow(QMainWindow):
5763
6274
 
5764
6275
  if self.shape == self.channel_data[channel_index].shape:
5765
6276
  preserve_zoom = (self.ax.get_xlim(), self.ax.get_ylim())
5766
- 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)
5767
6284
  if self.shape[1] * self.shape[2] > 3000 * 3000 * self.downsample_factor:
5768
6285
  self.throttle = True
5769
6286
  else:
@@ -5771,8 +6288,6 @@ class ImageViewerWindow(QMainWindow):
5771
6288
 
5772
6289
 
5773
6290
  self.img_height, self.img_width = self.shape[1], self.shape[2]
5774
- self.original_ylim, self.original_xlim = (self.shape[1] + 0.5, -0.5), (-0.5, self.shape[2] - 0.5)
5775
- #print(self.original_xlim)
5776
6291
 
5777
6292
  self.completed_paint_strokes = [] #Reset pending paint operations
5778
6293
  self.current_stroke_points = []
@@ -5782,6 +6297,12 @@ class ImageViewerWindow(QMainWindow):
5782
6297
  self.current_operation = []
5783
6298
  self.current_operation_type = None
5784
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
+
5785
6306
  if self.pan_mode:
5786
6307
  self.pan_button.click()
5787
6308
  if self.show_channels:
@@ -5790,7 +6311,6 @@ class ImageViewerWindow(QMainWindow):
5790
6311
  elif not end_paint:
5791
6312
 
5792
6313
  self.update_display(reset_resize = reset_resize, preserve_zoom = preserve_zoom)
5793
-
5794
6314
 
5795
6315
  except Exception as e:
5796
6316
 
@@ -5799,7 +6319,7 @@ class ImageViewerWindow(QMainWindow):
5799
6319
  QMessageBox.critical(
5800
6320
  self,
5801
6321
  "Error Loading File",
5802
- f"Failed to load tiff file: {str(e)}"
6322
+ f"Failed to load file: {str(e)}"
5803
6323
  )
5804
6324
 
5805
6325
  def delete_channel(self, channel_index, called = True, update = True):
@@ -6001,12 +6521,9 @@ class ImageViewerWindow(QMainWindow):
6001
6521
  def update_slice(self):
6002
6522
  """Queue a slice update when slider moves."""
6003
6523
  # Store current view settings
6004
- if not self.resizing:
6005
- current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
6006
- current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
6007
- else:
6008
- current_xlim = None
6009
- 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
+
6010
6527
 
6011
6528
  # Store the pending slice and view settings
6012
6529
  self.pending_slice = (self.slice_slider.value(), (current_xlim, current_ylim))
@@ -6028,15 +6545,19 @@ class ImageViewerWindow(QMainWindow):
6028
6545
  self.pm.convert_virtual_strokes_to_data()
6029
6546
  self.current_slice = slice_value
6030
6547
  if self.preview:
6548
+ self.highlight_overlay = None
6549
+ self.mini_overlay_data = None
6550
+ self.mini_overlay = False
6031
6551
  self.create_highlight_overlay_slice(self.targs, bounds=self.bounds)
6032
6552
  elif self.mini_overlay == True: #If we are rendering the highlight overlay for selected values one at a time.
6033
6553
  self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
6034
- if not self.hold_update:
6035
- self.update_display(preserve_zoom=view_settings)
6036
- else:
6037
- self.hold_update = False
6038
- #if self.machine_window is not None:
6039
- #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)
6040
6561
  if self.pan_mode:
6041
6562
  self.pan_button.click()
6042
6563
  self.pending_slice = None
@@ -6053,7 +6574,6 @@ class ImageViewerWindow(QMainWindow):
6053
6574
  self.channel_brightness[channel_index]['max'] = max_val / 65535
6054
6575
  self.update_display(preserve_zoom = (current_xlim, current_ylim))
6055
6576
 
6056
-
6057
6577
  def update_display(self, preserve_zoom=None, dims=None, called=False, reset_resize=False, skip=False):
6058
6578
  """Optimized display update with view-based cropping for performance."""
6059
6579
  try:
@@ -6073,8 +6593,6 @@ class ImageViewerWindow(QMainWindow):
6073
6593
  if self.resume:
6074
6594
  self.machine_window.segmentation_worker.resume()
6075
6595
  self.resume = False
6076
- if self.prev_down != self.downsample_factor:
6077
- self.validate_downsample_input(text = self.prev_down)
6078
6596
 
6079
6597
  if self.static_background is not None:
6080
6598
  # Your existing virtual strokes conversion logic
@@ -6131,10 +6649,13 @@ class ImageViewerWindow(QMainWindow):
6131
6649
  for img in list(self.ax.get_images()):
6132
6650
  img.remove()
6133
6651
  # Clear measurement points
6134
- for artist in self.measurement_artists:
6135
- artist.remove()
6136
- self.measurement_artists.clear()
6137
-
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
6138
6659
  # Determine the current view bounds (either from preserve_zoom or current state)
6139
6660
  if preserve_zoom:
6140
6661
  current_xlim, current_ylim = preserve_zoom
@@ -6201,7 +6722,6 @@ class ImageViewerWindow(QMainWindow):
6201
6722
  return cropped[::factor, ::factor, :]
6202
6723
  else:
6203
6724
  return cropped
6204
-
6205
6725
 
6206
6726
  # Update channel images efficiently with cropping and downsampling
6207
6727
  for channel in range(4):
@@ -6276,13 +6796,10 @@ class ImageViewerWindow(QMainWindow):
6276
6796
 
6277
6797
  im = self.ax.imshow(normalized_image, alpha=0.7, cmap=custom_cmap,
6278
6798
  vmin=0, vmax=1, extent=crop_extent)
6279
-
6280
6799
  # Handle preview, overlays, and measurements (apply cropping here too)
6281
- #if self.preview and not called:
6282
- # self.create_highlight_overlay_slice(self.targs, bounds=self.bounds)
6283
6800
 
6284
6801
  # Overlay handling (optimized with cropping and downsampling)
6285
- 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:
6286
6803
  highlight_cmap = LinearSegmentedColormap.from_list('highlight', [(0, 0, 0, 0), (1, 1, 0, 1)])
6287
6804
  display_overlay = crop_and_downsample_image(
6288
6805
  self.mini_overlay_data, y_min_padded, y_max_padded,
@@ -6301,34 +6818,88 @@ class ImageViewerWindow(QMainWindow):
6301
6818
  [(0, 0, 0, 0), (1, 1, 0, 1), (0, 0.7, 1, 1)])
6302
6819
  self.ax.imshow(display_highlight, cmap=highlight_cmap, vmin=0, vmax=2, alpha=0.3, extent=crop_extent)
6303
6820
 
6304
- # Redraw measurement points efficiently (no cropping needed - these are vector graphics)
6821
+ # Redraw measurement points efficiently
6305
6822
  # Only draw points that are within the visible region for additional performance
6306
- for point in self.measurement_points:
6307
- x1, y1, z1 = point['point1']
6308
- x2, y2, z2 = point['point2']
6309
- pair_idx = point['pair_index']
6310
-
6311
- # Check if points are in visible region
6312
- point1_visible = (z1 == self.current_slice and
6313
- current_xlim[0] <= x1 <= current_xlim[1] and
6314
- current_ylim[1] <= y1 <= current_ylim[0])
6315
- point2_visible = (z2 == self.current_slice and
6316
- current_xlim[0] <= x2 <= current_xlim[1] and
6317
- current_ylim[1] <= y2 <= current_ylim[0])
6318
-
6319
- if point1_visible:
6320
- pt1 = self.ax.plot(x1, y1, 'yo', markersize=8)[0]
6321
- txt1 = self.ax.text(x1, y1+5, str(pair_idx), color='white', ha='center', va='bottom')
6322
- 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
6830
+
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--'
6323
6840
 
6324
- if point2_visible:
6325
- pt2 = self.ax.plot(x2, y2, 'yo', markersize=8)[0]
6326
- txt2 = self.ax.text(x2, y2+5, str(pair_idx), color='white', ha='center', va='bottom')
6327
- self.measurement_artists.extend([pt2, txt2])
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])
6328
6896
 
6329
- if z1 == z2 == self.current_slice and (point1_visible or point2_visible):
6330
- line = self.ax.plot([x1, x2], [y1, y2], 'r--', alpha=0.5)[0]
6331
- 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)
6332
6903
 
6333
6904
  # Store current view limits for next update
6334
6905
  self.ax._current_xlim = current_xlim
@@ -6347,15 +6918,12 @@ class ImageViewerWindow(QMainWindow):
6347
6918
  if reset_resize:
6348
6919
  self.resizing = False
6349
6920
 
6350
- # Use draw_idle for better performance
6921
+ # draw_idle
6351
6922
  self.canvas.draw_idle()
6352
6923
 
6924
+
6353
6925
  except Exception as e:
6354
6926
  pass
6355
- #import traceback
6356
- #print(traceback.format_exc())
6357
-
6358
-
6359
6927
 
6360
6928
 
6361
6929
  def get_channel_image(self, channel):
@@ -6465,10 +7033,18 @@ class ImageViewerWindow(QMainWindow):
6465
7033
  dialog = RadDialog(self)
6466
7034
  dialog.exec()
6467
7035
 
7036
+ def show_branchstat_dialog(self):
7037
+ dialog = BranchStatDialog(self)
7038
+ dialog.exec()
7039
+
6468
7040
  def show_interaction_dialog(self):
6469
7041
  dialog = InteractionDialog(self)
6470
7042
  dialog.exec()
6471
7043
 
7044
+ def show_violin_dialog(self):
7045
+ dialog = ViolinDialog(self)
7046
+ dialog.show()
7047
+
6472
7048
  def show_degree_dialog(self):
6473
7049
  dialog = DegreeDialog(self)
6474
7050
  dialog.exec()
@@ -6655,15 +7231,19 @@ class CustomTableView(QTableView):
6655
7231
  desc_action.triggered.connect(lambda checked, c=col: self.sort_table(c, ascending=False))
6656
7232
 
6657
7233
  # Different menus for top and bottom tables
6658
- if self in self.parent.data_table: # Top table
7234
+ if self.is_top_table: # Use the flag instead of checking membership
6659
7235
  save_menu = context_menu.addMenu("Save As")
6660
7236
  save_csv = save_menu.addAction("CSV")
6661
7237
  save_excel = save_menu.addAction("Excel")
7238
+
7239
+ if self.model() and len(self.model()._data.columns) == 2:
7240
+ thresh_action = context_menu.addAction("Use to Threshold Nodes")
7241
+ thresh_action.triggered.connect(lambda: self.thresh(self.create_threshold_dict()))
7242
+
6662
7243
  close_action = context_menu.addAction("Close All")
6663
-
6664
7244
  close_action.triggered.connect(self.close_all)
6665
7245
 
6666
- # Connect the actions
7246
+ # Connect the save actions
6667
7247
  save_csv.triggered.connect(lambda: self.save_table_as('csv'))
6668
7248
  save_excel.triggered.connect(lambda: self.save_table_as('xlsx'))
6669
7249
  else: # Bottom tables
@@ -6707,6 +7287,38 @@ class CustomTableView(QTableView):
6707
7287
  cursor_pos = QCursor.pos()
6708
7288
  context_menu.exec(cursor_pos)
6709
7289
 
7290
+
7291
+
7292
+ def thresh(self, special_dict):
7293
+ try:
7294
+ self.parent.special_dict = special_dict
7295
+ thresh_window = ThresholdWindow(self.parent, 4)
7296
+ thresh_window.show()
7297
+ except:
7298
+ pass
7299
+
7300
+ def create_threshold_dict(self):
7301
+ try:
7302
+ """Create a dictionary from the 2-column table data."""
7303
+ if not self.model() or not hasattr(self.model(), '_data'):
7304
+ return {}
7305
+
7306
+ df = self.model()._data
7307
+ if len(df.columns) != 2:
7308
+ return {}
7309
+
7310
+ # Create dictionary: {column_0_value: column_1_value}
7311
+ threshold_dict = {}
7312
+ for index, row in df.iterrows():
7313
+ key = row.iloc[0] # Column 0 value
7314
+ value = row.iloc[1] # Column 1 value
7315
+ threshold_dict[int(key)] = float(value)
7316
+
7317
+ return threshold_dict
7318
+ except:
7319
+ pass
7320
+
7321
+
6710
7322
  def sort_table(self, column, ascending=True):
6711
7323
  """Sort the table by the specified column."""
6712
7324
  try:
@@ -7766,11 +8378,6 @@ class MergeNodeIdDialog(QDialog):
7766
8378
  self.mode_selector.addItems(["Auto-Binarize(Otsu)/Presegmented", "Manual (Interactive Thresholder)"])
7767
8379
  self.mode_selector.setCurrentIndex(1) # Default to Mode 1
7768
8380
  layout.addRow("Binarization Strategy:", self.mode_selector)
7769
-
7770
- self.umap = QPushButton("If using manual threshold: Also generate identity UMAP?")
7771
- self.umap.setCheckable(True)
7772
- self.umap.setChecked(True)
7773
- layout.addWidget(self.umap)
7774
8381
 
7775
8382
  self.include = QPushButton("Include When a Node is Negative for an ID?")
7776
8383
  self.include.setCheckable(True)
@@ -7827,7 +8434,7 @@ class MergeNodeIdDialog(QDialog):
7827
8434
  z_scale = float(self.z_scale.text()) if self.z_scale.text().strip() else 1
7828
8435
  data = self.parent().channel_data[0]
7829
8436
  include = self.include.isChecked()
7830
- umap = self.umap.isChecked()
8437
+ umap = True
7831
8438
 
7832
8439
  if data is None:
7833
8440
  return
@@ -7963,15 +8570,13 @@ class MergeNodeIdDialog(QDialog):
7963
8570
  result = {key: np.array([d[key] for d in id_dicts]) for key in all_keys}
7964
8571
 
7965
8572
 
7966
- self.parent().format_for_upperright_table(result, 'NodeID', good_list, 'Mean Intensity')
7967
- if umap:
7968
- 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")')
7969
8574
 
7970
8575
 
7971
8576
  QMessageBox.information(
7972
8577
  self,
7973
8578
  "Success",
7974
- "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)"
7975
8580
  )
7976
8581
 
7977
8582
  self.accept()
@@ -7993,6 +8598,89 @@ class MergeNodeIdDialog(QDialog):
7993
8598
  print(traceback.format_exc())
7994
8599
  #print(f"Error: {e}")
7995
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
+
7996
8684
 
7997
8685
  class Show3dDialog(QDialog):
7998
8686
  def __init__(self, parent=None):
@@ -8060,6 +8748,9 @@ class Show3dDialog(QDialog):
8060
8748
  if visible:
8061
8749
  arrays_4d.append(channel)
8062
8750
 
8751
+ if self.parent().thresh_window_ref is not None:
8752
+ self.parent().thresh_window_ref.make_full_highlight()
8753
+
8063
8754
  if self.parent().highlight_overlay is not None or self.parent().mini_overlay_data is not None:
8064
8755
  if self.parent().mini_overlay == True:
8065
8756
  self.parent().create_highlight_overlay(node_indices = self.parent().clicked_values['nodes'], edge_indices = self.parent().clicked_values['edges'])
@@ -8071,6 +8762,11 @@ class Show3dDialog(QDialog):
8071
8762
  self.accept()
8072
8763
 
8073
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
+ )
8074
8770
  print(f"Error: {e}")
8075
8771
  import traceback
8076
8772
  print(traceback.format_exc())
@@ -8086,6 +8782,9 @@ class NetOverlayDialog(QDialog):
8086
8782
 
8087
8783
  layout = QFormLayout(self)
8088
8784
 
8785
+ self.downsample = QLineEdit("")
8786
+ layout.addRow("Downsample Factor While Drawing? (Int - Makes the outputted lines larger):", self.downsample)
8787
+
8089
8788
  # Add Run button
8090
8789
  run_button = QPushButton("Generate (Will go to Overlay 1)")
8091
8790
  run_button.clicked.connect(self.netoverlay)
@@ -8102,7 +8801,16 @@ class NetOverlayDialog(QDialog):
8102
8801
  if my_network.node_centroids is None:
8103
8802
  return
8104
8803
 
8105
- 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
+
8106
8814
 
8107
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()))
8108
8816
 
@@ -8129,6 +8837,9 @@ class IdOverlayDialog(QDialog):
8129
8837
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
8130
8838
  layout.addRow("Execution Mode:", self.mode_selector)
8131
8839
 
8840
+ self.downsample = QLineEdit("")
8841
+ layout.addRow("Downsample Factor While Drawing? (Int - Makes the outputted numbers larger):", self.downsample)
8842
+
8132
8843
  # Add Run button
8133
8844
  run_button = QPushButton("Generate (Will go to Overlay 2)")
8134
8845
  run_button.clicked.connect(self.idoverlay)
@@ -8136,38 +8847,51 @@ class IdOverlayDialog(QDialog):
8136
8847
 
8137
8848
  def idoverlay(self):
8138
8849
 
8139
- accepted_mode = self.mode_selector.currentIndex()
8850
+ try:
8140
8851
 
8141
- if accepted_mode == 0:
8852
+ accepted_mode = self.mode_selector.currentIndex()
8142
8853
 
8143
- 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
8144
8858
 
8145
- self.parent().show_centroid_dialog()
8859
+ if accepted_mode == 0:
8146
8860
 
8147
- if my_network.node_centroids is None:
8148
- return
8861
+ if my_network.node_centroids is None:
8149
8862
 
8150
- elif accepted_mode == 1:
8863
+ self.parent().show_centroid_dialog()
8151
8864
 
8152
- if my_network.edge_centroids is None:
8865
+ if my_network.node_centroids is None:
8866
+ return
8153
8867
 
8154
- self.parent().show_centroid_dialog()
8868
+ elif accepted_mode == 1:
8155
8869
 
8156
- if my_network.edge_centroids is None:
8157
- return
8870
+ if my_network.edge_centroids is None:
8871
+
8872
+ self.parent().show_centroid_dialog()
8158
8873
 
8159
- if accepted_mode == 0:
8874
+ if my_network.edge_centroids is None:
8875
+ return
8160
8876
 
8161
- my_network.id_overlay = my_network.draw_node_indices()
8877
+ if accepted_mode == 0:
8162
8878
 
8163
- elif accepted_mode == 1:
8879
+ my_network.id_overlay = my_network.draw_node_indices(down_factor = downsample)
8164
8880
 
8165
- my_network.id_overlay = my_network.draw_edge_indices()
8881
+ elif accepted_mode == 1:
8166
8882
 
8883
+ my_network.id_overlay = my_network.draw_edge_indices(down_factor = downsample)
8167
8884
 
8168
- 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()))
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}")
8169
8894
 
8170
- self.accept()
8171
8895
 
8172
8896
  class ColorOverlayDialog(QDialog):
8173
8897
 
@@ -8189,7 +8913,7 @@ class ColorOverlayDialog(QDialog):
8189
8913
  layout.addRow("Execution Mode:", self.mode_selector)
8190
8914
 
8191
8915
  self.down_factor = QLineEdit("")
8192
- 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)
8193
8917
 
8194
8918
  # Add Run button
8195
8919
  run_button = QPushButton("Generate (Will go to Overlay 2)")
@@ -8343,11 +9067,6 @@ class NetShowDialog(QDialog):
8343
9067
  self.weighted.setCheckable(True)
8344
9068
  self.weighted.setChecked(True)
8345
9069
  layout.addRow("Use Weighted Network (Only for community graphs):", self.weighted)
8346
-
8347
- # Optional saving:
8348
- self.directory = QLineEdit()
8349
- self.directory.setPlaceholderText("Does not save when empty")
8350
- layout.addRow("Output Directory:", self.directory)
8351
9070
 
8352
9071
  # Add Run button
8353
9072
  run_button = QPushButton("Show Network")
@@ -8363,7 +9082,7 @@ class NetShowDialog(QDialog):
8363
9082
  self.parent().show_centroid_dialog()
8364
9083
  accepted_mode = self.mode_selector.currentIndex() # Convert to 1-based index
8365
9084
  # Get directory (None if empty)
8366
- directory = self.directory.text() if self.directory.text() else None
9085
+ directory = None
8367
9086
 
8368
9087
  weighted = self.weighted.isChecked()
8369
9088
 
@@ -8406,7 +9125,7 @@ class PartitionDialog(QDialog):
8406
9125
 
8407
9126
  # Add mode selection dropdown
8408
9127
  self.mode_selector = QComboBox()
8409
- self.mode_selector.addItems(["Label Propogation", "Louvain"])
9128
+ self.mode_selector.addItems(["Louvain", "Label Propogation"])
8410
9129
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
8411
9130
  layout.addRow("Execution Mode:", self.mode_selector)
8412
9131
 
@@ -8429,6 +9148,10 @@ class PartitionDialog(QDialog):
8429
9148
  self.parent().prev_coms = None
8430
9149
 
8431
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
8432
9155
  weighted = self.weighted.isChecked()
8433
9156
  dostats = self.stats.isChecked()
8434
9157
 
@@ -8600,7 +9323,7 @@ class ComNeighborDialog(QDialog):
8600
9323
 
8601
9324
  mode = self.mode.currentIndex()
8602
9325
 
8603
- 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
8604
9327
 
8605
9328
  limit = int(self.limit.text()) if self.limit.text().strip() else None
8606
9329
 
@@ -8705,9 +9428,6 @@ class RadialDialog(QDialog):
8705
9428
  self.distance = QLineEdit("50")
8706
9429
  layout.addRow("Bucket Distance for Searching For Node Neighbors (automatically scaled by xy and z scales):", self.distance)
8707
9430
 
8708
- self.directory = QLineEdit("")
8709
- layout.addRow("Output Directory:", self.directory)
8710
-
8711
9431
  # Add Run button
8712
9432
  run_button = QPushButton("Get Radial Distribution")
8713
9433
  run_button.clicked.connect(self.radial)
@@ -8719,7 +9439,7 @@ class RadialDialog(QDialog):
8719
9439
 
8720
9440
  distance = float(self.distance.text()) if self.distance.text().strip() else 50
8721
9441
 
8722
- directory = str(self.distance.text()) if self.directory.text().strip() else None
9442
+ directory = None
8723
9443
 
8724
9444
  if my_network.node_centroids is None:
8725
9445
  self.parent().show_centroid_dialog()
@@ -8750,12 +9470,16 @@ class NearNeighDialog(QDialog):
8750
9470
  if my_network.node_identities is not None:
8751
9471
 
8752
9472
  self.root = QComboBox()
8753
- 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)
8754
9477
  self.root.setCurrentIndex(0)
8755
9478
  identities_layout.addRow("Root Identity to Search for Neighbor's IDs?", self.root)
8756
9479
 
8757
9480
  self.targ = QComboBox()
8758
9481
  neighs = list(set(my_network.node_identities.values()))
9482
+ neighs.sort()
8759
9483
  neighs.append("All Others (Excluding Self)")
8760
9484
  self.targ.addItems(neighs)
8761
9485
  self.targ.setCurrentIndex(0)
@@ -8793,6 +9517,11 @@ class NearNeighDialog(QDialog):
8793
9517
  self.numpy.setChecked(False)
8794
9518
  self.numpy.clicked.connect(self.toggle_map)
8795
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)
8796
9525
 
8797
9526
  main_layout.addWidget(heatmap_group)
8798
9527
 
@@ -8880,35 +9609,52 @@ class NearNeighDialog(QDialog):
8880
9609
  except:
8881
9610
  targ = None
8882
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
+
8883
9627
  heatmap = self.map.isChecked()
8884
9628
  threed = self.threed.isChecked()
8885
9629
  numpy = self.numpy.isChecked()
8886
9630
  num = int(self.num.text()) if self.num.text().strip() else 1
8887
9631
  quant = self.quant.isChecked()
8888
9632
  centroids = self.centroids.isChecked()
9633
+
8889
9634
  if not centroids:
9635
+ print("Using 1 nearest neighbor due to not using centroids")
8890
9636
  num = 1
8891
9637
 
8892
9638
  if root is not None and targ is not None:
8893
9639
  title = f"Nearest {num} Neighbor(s) Distance of {targ} from {root}"
8894
- header = f"Shortest Distance to Closest {num} {targ}(s)"
9640
+ header = f"Avg Shortest Distance to Closest {num} {targ}(s)"
8895
9641
  header2 = f"{root} Node ID"
8896
9642
  header3 = f'Theoretical Uniform Distance to Closest {num} {targ}(s)'
8897
9643
  else:
8898
9644
  title = f"Nearest {num} Neighbor(s) Distance Between Nodes"
8899
- header = f"Shortest Distance to Closest {num} Nodes"
9645
+ header = f"Avg Shortest Distance to Closest {num} Nodes"
8900
9646
  header2 = "Root Node ID"
8901
9647
  header3 = f'Simulated Theoretical Uniform Distance to Closest {num} Nodes'
8902
9648
 
8903
- if centroids and my_network.node_centroids is None:
9649
+ if my_network.node_centroids is None:
8904
9650
  self.parent().show_centroid_dialog()
8905
9651
  if my_network.node_centroids is None:
8906
9652
  return
8907
9653
 
8908
9654
  if not numpy:
8909
- 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)
8910
9656
  else:
8911
- 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)
8912
9658
  self.parent().load_channel(3, overlay, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
8913
9659
 
8914
9660
  if quant_overlay is not None:
@@ -8977,9 +9723,6 @@ class NeighborIdentityDialog(QDialog):
8977
9723
  else:
8978
9724
  self.root = None
8979
9725
 
8980
- self.directory = QLineEdit("")
8981
- layout.addRow("Output Directory:", self.directory)
8982
-
8983
9726
  self.mode = QComboBox()
8984
9727
  self.mode.addItems(["From Network - Based on Absolute Connectivity", "Use Labeled Nodes - Based on Morphological Neighborhood Densities"])
8985
9728
  self.mode.setCurrentIndex(0)
@@ -9007,7 +9750,7 @@ class NeighborIdentityDialog(QDialog):
9007
9750
  except:
9008
9751
  pass
9009
9752
 
9010
- directory = self.directory.text() if self.directory.text().strip() else None
9753
+ directory = None
9011
9754
 
9012
9755
  mode = self.mode.currentIndex()
9013
9756
 
@@ -9461,6 +10204,22 @@ class InteractionDialog(QDialog):
9461
10204
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
9462
10205
  layout.addRow("Execution Mode:", self.mode_selector)
9463
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
+
9464
10223
  self.fastdil = QPushButton("Fast Dilate")
9465
10224
  self.fastdil.setCheckable(True)
9466
10225
  self.fastdil.setChecked(False)
@@ -9484,10 +10243,16 @@ class InteractionDialog(QDialog):
9484
10243
 
9485
10244
 
9486
10245
  fastdil = self.fastdil.isChecked()
10246
+ length = self.length.isChecked()
10247
+ auto = self.auto.isChecked()
10248
+
10249
+ result = my_network.interactions(search = node_search, cores = accepted_mode, skele = length, length = length, auto = auto, fastdil = fastdil)
9487
10250
 
9488
- result = my_network.interactions(search = node_search, cores = accepted_mode, fastdil = fastdil)
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')
9489
10255
 
9490
- self.parent().format_for_upperright_table(result, 'Node ID', ['Volume of Nearby Edge (Scaled)', 'Volume of Search Region'], title = 'Node/Edge Interactions')
9491
10256
 
9492
10257
  self.accept()
9493
10258
 
@@ -9499,21 +10264,422 @@ class InteractionDialog(QDialog):
9499
10264
  print(f"Error finding interactions: {e}")
9500
10265
 
9501
10266
 
9502
- class DegreeDialog(QDialog):
9503
-
10267
+ class ViolinDialog(QDialog):
9504
10268
 
9505
10269
  def __init__(self, parent=None):
9506
10270
 
9507
10271
  super().__init__(parent)
9508
- self.setWindowTitle("Degree Parameters")
9509
- self.setModal(True)
9510
-
9511
- layout = QFormLayout(self)
9512
10272
 
9513
- 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:"))
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
+ )
9514
10278
 
9515
- # Add mode selection dropdown
9516
- self.mode_selector = QComboBox()
10279
+ try:
10280
+ try:
10281
+ self.df = self.parent().load_file()
10282
+ except:
10283
+ return
10284
+
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
10292
+
10293
+ self.setWindowTitle("Violin Parameters")
10294
+ self.setModal(False)
10295
+
10296
+ layout = QFormLayout(self)
10297
+
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)
10321
+
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)
10326
+
10327
+ run_button2 = QPushButton("Show Z-score UMAP")
10328
+ run_button2.clicked.connect(self.run2)
10329
+
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)
10334
+
10335
+ layout.addRow(self.mode_selector, run_button2)
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)
10340
+
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))
10346
+
10347
+ # QFormLayout's addRow takes (label/widget, field/widget)
10348
+ layout.addRow(run_button3, self.kmeans_num_input)
10349
+
10350
+ except:
10351
+ import traceback
10352
+ print(traceback.format_exc())
10353
+ QTimer.singleShot(0, self.close)
10354
+
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()
9517
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"])
9518
10684
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
9519
10685
  layout.addRow("Execution Mode:", self.mode_selector)
@@ -9754,6 +10920,9 @@ class MotherDialog(QDialog):
9754
10920
 
9755
10921
  except Exception as e:
9756
10922
 
10923
+ import traceback
10924
+ print(traceback.format_exc())
10925
+
9757
10926
  print(f"Error finding mothers: {e}")
9758
10927
 
9759
10928
 
@@ -9872,7 +11041,7 @@ class ResizeDialog(QDialog):
9872
11041
 
9873
11042
  def run_resize(self, undo = False, upsize = True, special = False):
9874
11043
  try:
9875
- self.parent().resizing = False
11044
+ self.parent().resizing = True
9876
11045
  # Get parameters
9877
11046
  try:
9878
11047
  resize = float(self.resize.text()) if self.resize.text() else None
@@ -9987,6 +11156,7 @@ class ResizeDialog(QDialog):
9987
11156
  if channel is not None:
9988
11157
  self.parent().slice_slider.setMinimum(0)
9989
11158
  self.parent().slice_slider.setMaximum(channel.shape[0] - 1)
11159
+ self.parent().shape = channel.shape
9990
11160
  break
9991
11161
 
9992
11162
  if not special:
@@ -10056,10 +11226,7 @@ class ResizeDialog(QDialog):
10056
11226
  except Exception as e:
10057
11227
  print(f"Error loading edge centroid table: {e}")
10058
11228
 
10059
-
10060
11229
  self.parent().update_display()
10061
- self.reset_fields()
10062
- self.parent().resizing = False
10063
11230
  self.accept()
10064
11231
 
10065
11232
  except Exception as e:
@@ -10068,6 +11235,79 @@ class ResizeDialog(QDialog):
10068
11235
  print(traceback.format_exc())
10069
11236
  QMessageBox.critical(self, "Error", f"Failed to resize: {str(e)}")
10070
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
+
10071
11311
 
10072
11312
  class OverrideDialog(QDialog):
10073
11313
  def __init__(self, parent=None):
@@ -10301,7 +11541,7 @@ class LabelDialog(QDialog):
10301
11541
  class SLabelDialog(QDialog):
10302
11542
  def __init__(self, parent=None):
10303
11543
  super().__init__(parent)
10304
- 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?")
10305
11545
  self.setModal(True)
10306
11546
 
10307
11547
  layout = QFormLayout(self)
@@ -10313,7 +11553,7 @@ class SLabelDialog(QDialog):
10313
11553
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
10314
11554
  layout.addRow("Prelabeled Array:", self.mode_selector)
10315
11555
 
10316
- layout.addRow(QLabel("Will Label Neighborhoods in: "))
11556
+ layout.addRow(QLabel("Will Label Binary Foreground Voxels in: "))
10317
11557
 
10318
11558
  # Add mode selection dropdown
10319
11559
  self.target_selector = QComboBox()
@@ -10359,10 +11599,6 @@ class SLabelDialog(QDialog):
10359
11599
  # Update both the display data and the network object
10360
11600
  binary_array = sdl.smart_label(binary_array, label_array, directory = None, GPU = GPU, predownsample = down_factor, remove_template = True)
10361
11601
 
10362
- label_array = sdl.invert_array(label_array)
10363
-
10364
- binary_array = binary_array * label_array
10365
-
10366
11602
  self.parent().load_channel(accepted_target, binary_array, True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
10367
11603
 
10368
11604
  self.accept()
@@ -10441,12 +11677,11 @@ class ThresholdDialog(QDialog):
10441
11677
  print("Error - please calculate network first")
10442
11678
  return
10443
11679
 
10444
- if self.parent().mini_overlay_data is not None:
10445
- self.parent().mini_overlay_data = None
10446
-
10447
11680
  thresh_window = ThresholdWindow(self.parent(), accepted_mode)
10448
11681
  thresh_window.show() # Non-modal window
10449
11682
  self.highlight_overlay = None
11683
+ #self.mini_overlay = False
11684
+ self.mini_overlay_data = None
10450
11685
  self.accept()
10451
11686
  except:
10452
11687
  import traceback
@@ -11379,6 +12614,7 @@ class ThresholdWindow(QMainWindow):
11379
12614
 
11380
12615
  def __init__(self, parent=None, accepted_mode=0):
11381
12616
  super().__init__(parent)
12617
+ self.parent().thresh_window_ref = self
11382
12618
  self.setWindowTitle("Threshold")
11383
12619
 
11384
12620
  self.accepted_mode = accepted_mode
@@ -11420,16 +12656,23 @@ class ThresholdWindow(QMainWindow):
11420
12656
  self.parent().bounds = False
11421
12657
 
11422
12658
  elif accepted_mode == 0:
11423
- targ_shape = self.parent().channel_data[self.parent().active_channel].shape
11424
- if (targ_shape[0] + targ_shape[1] + targ_shape[2]) > 2500: #Take a simpler histogram on big arrays
11425
- temp_max = np.max(self.parent().channel_data[self.parent().active_channel])
11426
- temp_min = np.min(self.parent().channel_data[self.parent().active_channel])
11427
- temp_array = n3d.downsample(self.parent().channel_data[self.parent().active_channel], 5)
11428
- self.histo_list = temp_array.flatten().tolist()
11429
- self.histo_list.append(temp_min)
11430
- self.histo_list.append(temp_max)
11431
- else: #Otherwise just use full array data
11432
- 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
+
11433
12676
  self.bounds = True
11434
12677
  self.parent().bounds = True
11435
12678
 
@@ -11442,16 +12685,26 @@ class ThresholdWindow(QMainWindow):
11442
12685
  layout.addWidget(self.canvas)
11443
12686
 
11444
12687
  # Pre-compute histogram with numpy
11445
- counts, bin_edges = np.histogram(self.histo_list, bins=50)
11446
- 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]
11447
12700
 
11448
12701
  # Plot pre-computed histogram
11449
12702
  self.ax = fig.add_subplot(111)
11450
12703
  self.ax.bar(bin_centers, counts, width=bin_edges[1] - bin_edges[0], alpha=0.5)
11451
12704
 
11452
12705
  # Add vertical lines for thresholds
11453
- self.min_line = self.ax.axvline(min(self.histo_list), color='r')
11454
- 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')
11455
12708
 
11456
12709
  # Connect events for dragging
11457
12710
  self.canvas.mpl_connect('button_press_event', self.on_press)
@@ -11459,13 +12712,6 @@ class ThresholdWindow(QMainWindow):
11459
12712
  self.canvas.mpl_connect('button_release_event', self.on_release)
11460
12713
 
11461
12714
  self.dragging = None
11462
-
11463
- # Store histogram bounds
11464
- if self.bounds:
11465
- self.data_min = 0
11466
- else:
11467
- self.data_min = min(self.histo_list)
11468
- self.data_max = max(self.histo_list)
11469
12715
 
11470
12716
  # Create form layout for inputs
11471
12717
  form_layout = QFormLayout()
@@ -11498,7 +12744,7 @@ class ThresholdWindow(QMainWindow):
11498
12744
  button_layout.addWidget(run_button)
11499
12745
 
11500
12746
  # Add Cancel button for external dialog use
11501
- cancel_button = QPushButton("Cancel/Skip")
12747
+ cancel_button = QPushButton("Cancel/Skip (Retains Selection)")
11502
12748
  cancel_button.clicked.connect(self.cancel_processing)
11503
12749
  button_layout.addWidget(cancel_button)
11504
12750
 
@@ -11522,10 +12768,8 @@ class ThresholdWindow(QMainWindow):
11522
12768
  self.processing_cancelled.emit()
11523
12769
  self.close()
11524
12770
 
11525
- def closeEvent(self, event):
11526
- self.parent().preview = False
11527
- self.parent().targs = None
11528
- self.parent().bounds = False
12771
+ def make_full_highlight(self):
12772
+
11529
12773
  try: # could probably be refactored but this just handles keeping the highlight elements if the user presses X
11530
12774
  if self.chan == 0:
11531
12775
  if not self.bounds:
@@ -11559,6 +12803,14 @@ class ThresholdWindow(QMainWindow):
11559
12803
  pass
11560
12804
 
11561
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
+
11562
12814
  def get_values_in_range_all_vols(self, chan, min_val, max_val):
11563
12815
  output = []
11564
12816
  if self.accepted_mode == 1:
@@ -11825,36 +13077,33 @@ class SmartDilateDialog(QDialog):
11825
13077
 
11826
13078
 
11827
13079
  class DilateDialog(QDialog):
11828
- def __init__(self, parent=None):
13080
+ def __init__(self, parent=None, args = None):
11829
13081
  super().__init__(parent)
11830
13082
  self.setWindowTitle("Dilate Parameters")
11831
13083
  self.setModal(True)
11832
13084
 
11833
13085
  layout = QFormLayout(self)
11834
13086
 
11835
- self.amount = QLineEdit("1")
11836
- layout.addRow("Dilation Radius:", self.amount)
11837
-
11838
- if my_network.xy_scale is not None:
11839
- xy_scale = f"{my_network.xy_scale}"
13087
+ if args:
13088
+ self.parent().last_dil = args[0]
13089
+ self.index = 1
11840
13090
  else:
11841
- xy_scale = "1"
13091
+ self.parent().last_dil = 1
13092
+ self.index = 0
11842
13093
 
11843
- self.xy_scale = QLineEdit(xy_scale)
11844
- layout.addRow("xy_scale:", self.xy_scale)
13094
+ self.amount = QLineEdit(f"{self.parent().last_dil}")
13095
+ layout.addRow("Dilation Radius:", self.amount)
11845
13096
 
11846
- if my_network.z_scale is not None:
11847
- z_scale = f"{my_network.z_scale}"
11848
- else:
11849
- z_scale = "1"
13097
+ self.xy_scale = QLineEdit("1")
13098
+ layout.addRow("xy_scale:", self.xy_scale)
11850
13099
 
11851
- self.z_scale = QLineEdit(z_scale)
13100
+ self.z_scale = QLineEdit("1")
11852
13101
  layout.addRow("z_scale:", self.z_scale)
11853
13102
 
11854
13103
  # Add mode selection dropdown
11855
13104
  self.mode_selector = QComboBox()
11856
- self.mode_selector.addItems(["Pseudo3D Binary Kernels (For Fast, small dilations)", "Preserve Labels (slower)", "Distance Transform-Based (Slower but more accurate at larger dilations)"])
11857
- 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
11858
13107
  layout.addRow("Execution Mode:", self.mode_selector)
11859
13108
 
11860
13109
  # Add Run button
@@ -11896,13 +13145,15 @@ class DilateDialog(QDialog):
11896
13145
  if active_data is None:
11897
13146
  raise ValueError("No active image selected")
11898
13147
 
13148
+ self.parent().last_dil = amount
13149
+
11899
13150
  if accepted_mode == 1:
11900
13151
  dialog = SmartDilateDialog(self.parent(), [active_data, amount, xy_scale, z_scale])
11901
13152
  dialog.exec()
11902
13153
  self.accept()
11903
13154
  return
11904
13155
 
11905
- if accepted_mode == 2:
13156
+ if accepted_mode == 0:
11906
13157
  result = n3d.dilate_3D_dt(active_data, amount, xy_scaling = xy_scale, z_scaling = z_scale)
11907
13158
  else:
11908
13159
 
@@ -11932,36 +13183,33 @@ class DilateDialog(QDialog):
11932
13183
  )
11933
13184
 
11934
13185
  class ErodeDialog(QDialog):
11935
- def __init__(self, parent=None):
13186
+ def __init__(self, parent=None, args = None):
11936
13187
  super().__init__(parent)
11937
13188
  self.setWindowTitle("Erosion Parameters")
11938
13189
  self.setModal(True)
11939
13190
 
11940
13191
  layout = QFormLayout(self)
11941
13192
 
11942
- self.amount = QLineEdit("1")
11943
- layout.addRow("Erosion Radius:", self.amount)
11944
-
11945
- if my_network.xy_scale is not None:
11946
- xy_scale = f"{my_network.xy_scale}"
13193
+ if args:
13194
+ self.parent().last_ero = args[0]
13195
+ self.index = 1
11947
13196
  else:
11948
- xy_scale = "1"
13197
+ self.parent().last_ero = 1
13198
+ self.index = 0
11949
13199
 
11950
- self.xy_scale = QLineEdit(xy_scale)
11951
- layout.addRow("xy_scale:", self.xy_scale)
13200
+ self.amount = QLineEdit(f"{self.parent().last_ero}")
13201
+ layout.addRow("Erosion Radius:", self.amount)
11952
13202
 
11953
- if my_network.z_scale is not None:
11954
- z_scale = f"{my_network.z_scale}"
11955
- else:
11956
- z_scale = "1"
13203
+ self.xy_scale = QLineEdit("1")
13204
+ layout.addRow("xy_scale:", self.xy_scale)
11957
13205
 
11958
- self.z_scale = QLineEdit(z_scale)
13206
+ self.z_scale = QLineEdit("1")
11959
13207
  layout.addRow("z_scale:", self.z_scale)
11960
13208
 
11961
13209
  # Add mode selection dropdown
11962
13210
  self.mode_selector = QComboBox()
11963
- self.mode_selector.addItems(["Pseudo3D Binary Kernels (For Fast, small erosions)", "Distance Transform-Based (Slower but more accurate at larger dilations)", "Preserve Labels (Slower)"])
11964
- 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
11965
13213
  layout.addRow("Execution Mode:", self.mode_selector)
11966
13214
 
11967
13215
  # Add Run button
@@ -11997,8 +13245,7 @@ class ErodeDialog(QDialog):
11997
13245
 
11998
13246
  mode = self.mode_selector.currentIndex()
11999
13247
 
12000
- if mode == 2:
12001
- mode = 1
13248
+ if mode == 1:
12002
13249
  preserve_labels = True
12003
13250
  else:
12004
13251
  preserve_labels = False
@@ -12020,7 +13267,7 @@ class ErodeDialog(QDialog):
12020
13267
 
12021
13268
 
12022
13269
  self.parent().load_channel(self.parent().active_channel, result, True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
12023
-
13270
+ self.parent().last_ero = amount
12024
13271
  self.accept()
12025
13272
 
12026
13273
  except Exception as e:
@@ -12050,6 +13297,11 @@ class HoleDialog(QDialog):
12050
13297
  self.borders.setChecked(False)
12051
13298
  layout.addRow("Fill Small Holes Along Borders:", self.borders)
12052
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
+
12053
13305
  self.sep_holes = QPushButton("Seperate Hole Mask")
12054
13306
  self.sep_holes.setCheckable(True)
12055
13307
  self.sep_holes.setChecked(False)
@@ -12072,15 +13324,32 @@ class HoleDialog(QDialog):
12072
13324
  borders = self.borders.isChecked()
12073
13325
  headon = self.headon.isChecked()
12074
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:
12075
13332
 
12076
- # Call dilate method with parameters
12077
- result = n3d.fill_holes_3d(
12078
- active_data,
12079
- head_on = headon,
12080
- fill_borders = borders
12081
- )
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
+
12082
13348
 
12083
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
+
12084
13353
  self.parent().load_channel(self.parent().active_channel, result, True)
12085
13354
  else:
12086
13355
  self.parent().load_channel(3, active_data - result, True)
@@ -12425,7 +13694,13 @@ class SkeletonizeDialog(QDialog):
12425
13694
  # auto checkbox (default True)
12426
13695
  self.auto = QPushButton("Auto")
12427
13696
  self.auto.setCheckable(True)
12428
- 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)
12429
13704
  layout.addRow("Attempt to Auto Correct Skeleton Looping:", self.auto)
12430
13705
 
12431
13706
  # Add Run button
@@ -12481,6 +13756,86 @@ class SkeletonizeDialog(QDialog):
12481
13756
  f"Error running skeletonize: {str(e)}"
12482
13757
  )
12483
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
+
12484
13839
  class DistanceDialog(QDialog):
12485
13840
  def __init__(self, parent=None):
12486
13841
  super().__init__(parent)
@@ -12528,16 +13883,58 @@ class GrayWaterDialog(QDialog):
12528
13883
  run_button.clicked.connect(self.run_watershed)
12529
13884
  layout.addRow(run_button)
12530
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
+
12531
13925
  def run_watershed(self):
12532
13926
 
12533
13927
  try:
12534
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
+
12535
13934
  min_intensity = float(self.min_intensity.text()) if self.min_intensity.text().strip() else None
12536
13935
 
12537
13936
  min_peak_distance = int(self.min_peak_distance.text()) if self.min_peak_distance.text().strip() else 1
12538
13937
 
12539
- data = self.parent().channel_data[self.parent().active_channel]
12540
-
12541
13938
  data = n3d.gray_watershed(data, min_peak_distance, min_intensity)
12542
13939
 
12543
13940
  self.parent().load_channel(self.parent().active_channel, data, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
@@ -12557,11 +13954,6 @@ class WatershedDialog(QDialog):
12557
13954
  self.setModal(True)
12558
13955
 
12559
13956
  layout = QFormLayout(self)
12560
-
12561
- # Directory (empty by default)
12562
- self.directory = QLineEdit()
12563
- self.directory.setPlaceholderText("Leave empty for None")
12564
- layout.addRow("Output Directory:", self.directory)
12565
13957
 
12566
13958
  try:
12567
13959
 
@@ -12612,7 +14004,7 @@ class WatershedDialog(QDialog):
12612
14004
  def run_watershed(self):
12613
14005
  try:
12614
14006
  # Get directory (None if empty)
12615
- directory = self.directory.text() if self.directory.text() else None
14007
+ directory = None
12616
14008
 
12617
14009
  # Get proportion (0.1 if empty or invalid)
12618
14010
  try:
@@ -12970,7 +14362,7 @@ class GenNodesDialog(QDialog):
12970
14362
 
12971
14363
  if my_network.edges is None and my_network.nodes is not None:
12972
14364
  self.parent().load_channel(1, my_network.nodes, data = True)
12973
- self.parent().delete_channel(0, True)
14365
+ self.parent().delete_channel(0, False)
12974
14366
  # Get directory (None if empty)
12975
14367
  #directory = self.directory.text() if self.directory.text() else None
12976
14368
 
@@ -13032,7 +14424,6 @@ class GenNodesDialog(QDialog):
13032
14424
  order = order,
13033
14425
  return_skele = True,
13034
14426
  fastdil = fastdil
13035
-
13036
14427
  )
13037
14428
 
13038
14429
  if down_factor > 0 and not self.called:
@@ -13124,7 +14515,7 @@ class BranchDialog(QDialog):
13124
14515
  self.fix3.setChecked(True)
13125
14516
  else:
13126
14517
  self.fix3.setChecked(False)
13127
- 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)
13128
14519
  correction_layout.addWidget(self.fix3, 4, 1)
13129
14520
 
13130
14521
  correction_group.setLayout(correction_layout)
@@ -13152,20 +14543,27 @@ class BranchDialog(QDialog):
13152
14543
  # --- Misc Options Group ---
13153
14544
  misc_group = QGroupBox("Misc Options")
13154
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)
13155
14553
 
13156
14554
  # Nodes checkbox
13157
14555
  self.nodes = QPushButton("Generate Nodes")
13158
14556
  self.nodes.setCheckable(True)
13159
14557
  self.nodes.setChecked(True)
13160
- misc_layout.addWidget(QLabel("Generate nodes from edges? (Skip if already completed):"), 0, 0)
13161
- 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)
13162
14560
 
13163
14561
  # GPU checkbox
13164
14562
  self.GPU = QPushButton("GPU")
13165
14563
  self.GPU.setCheckable(True)
13166
14564
  self.GPU.setChecked(False)
13167
- misc_layout.addWidget(QLabel("Use GPU (May downsample large images):"), 1, 0)
13168
- 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)
13169
14567
 
13170
14568
  misc_group.setLayout(misc_layout)
13171
14569
  main_layout.addWidget(misc_group)
@@ -13199,10 +14597,11 @@ class BranchDialog(QDialog):
13199
14597
  fix3 = self.fix3.isChecked()
13200
14598
  fix_val = float(self.fix_val.text()) if self.fix_val.text() else None
13201
14599
  seed = int(self.seed.text()) if self.seed.text() else None
14600
+ compute = self.compute.isChecked()
13202
14601
 
13203
14602
  if my_network.edges is None and my_network.nodes is not None:
13204
14603
  self.parent().load_channel(1, my_network.nodes, data = True)
13205
- self.parent().delete_channel(0, True)
14604
+ self.parent().delete_channel(0, False)
13206
14605
 
13207
14606
  original_shape = my_network.edges.shape
13208
14607
  original_array = copy.deepcopy(my_network.edges)
@@ -13215,7 +14614,7 @@ class BranchDialog(QDialog):
13215
14614
 
13216
14615
  if my_network.edges is not None and my_network.nodes is not None and my_network.id_overlay is not None:
13217
14616
 
13218
- 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)
13219
14618
 
13220
14619
  if fix2:
13221
14620
 
@@ -13254,6 +14653,19 @@ class BranchDialog(QDialog):
13254
14653
 
13255
14654
  output = self.parent().separate_nontouching_objects(output, max_val=np.max(output))
13256
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
+
13257
14669
 
13258
14670
  if down_factor is not None:
13259
14671
 
@@ -13653,10 +15065,6 @@ class CentroidDialog(QDialog):
13653
15065
 
13654
15066
  layout = QFormLayout(self)
13655
15067
 
13656
- self.directory = QLineEdit()
13657
- self.directory.setPlaceholderText("Leave empty for active directory")
13658
- layout.addRow("Output Directory:", self.directory)
13659
-
13660
15068
  self.downsample = QLineEdit("1")
13661
15069
  layout.addRow("Downsample Factor:", self.downsample)
13662
15070
 
@@ -13686,7 +15094,7 @@ class CentroidDialog(QDialog):
13686
15094
  ignore_empty = self.ignore_empty.isChecked()
13687
15095
 
13688
15096
  # Get directory (None if empty)
13689
- directory = self.directory.text() if self.directory.text() else None
15097
+ directory = None
13690
15098
 
13691
15099
  # Get downsample
13692
15100
  try:
@@ -13767,7 +15175,6 @@ class CentroidDialog(QDialog):
13767
15175
 
13768
15176
  class CalcAllDialog(QDialog):
13769
15177
  # Class variables to store previous settings
13770
- prev_directory = ""
13771
15178
  prev_search = ""
13772
15179
  prev_diledge = ""
13773
15180
  prev_down_factor = ""
@@ -13839,7 +15246,7 @@ class CalcAllDialog(QDialog):
13839
15246
 
13840
15247
  self.down_factor = QLineEdit(self.prev_down_factor)
13841
15248
  self.down_factor.setPlaceholderText("Leave empty for None")
13842
- speedup_layout.addRow("Downsample for Centroids (int):", self.down_factor)
15249
+ speedup_layout.addRow("Downsample for Centroids/Overlays (int):", self.down_factor)
13843
15250
 
13844
15251
  self.GPU_downsample = QLineEdit(self.prev_GPU_downsample)
13845
15252
  self.GPU_downsample.setPlaceholderText("Leave empty for None")
@@ -13861,10 +15268,6 @@ class CalcAllDialog(QDialog):
13861
15268
  output_group = QGroupBox("Output Options")
13862
15269
  output_layout = QFormLayout(output_group)
13863
15270
 
13864
- self.directory = QLineEdit(self.prev_directory)
13865
- self.directory.setPlaceholderText("Will Have to Save Manually If Empty")
13866
- output_layout.addRow("Output Directory:", self.directory)
13867
-
13868
15271
  self.overlays = QPushButton("Overlays")
13869
15272
  self.overlays.setCheckable(True)
13870
15273
  self.overlays.setChecked(self.prev_overlays)
@@ -13886,7 +15289,7 @@ class CalcAllDialog(QDialog):
13886
15289
 
13887
15290
  try:
13888
15291
  # Get directory (None if empty)
13889
- directory = self.directory.text() if self.directory.text() else None
15292
+ directory = None
13890
15293
 
13891
15294
  # Get xy_scale and z_scale (1 if empty or invalid)
13892
15295
  try:
@@ -13963,7 +15366,6 @@ class CalcAllDialog(QDialog):
13963
15366
  )
13964
15367
 
13965
15368
  # Store current values as previous values
13966
- CalcAllDialog.prev_directory = self.directory.text()
13967
15369
  CalcAllDialog.prev_search = self.search.text()
13968
15370
  CalcAllDialog.prev_diledge = self.diledge.text()
13969
15371
  CalcAllDialog.prev_down_factor = self.down_factor.text()
@@ -13997,8 +15399,12 @@ class CalcAllDialog(QDialog):
13997
15399
  directory = 'my_network'
13998
15400
 
13999
15401
  # Generate and update overlays
14000
- my_network.network_overlay = my_network.draw_network(directory=directory)
14001
- 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)
14002
15408
 
14003
15409
  # Update channel data
14004
15410
  self.parent().load_channel(2, my_network.network_overlay, True)
@@ -14111,14 +15517,13 @@ class ProxDialog(QDialog):
14111
15517
  output_group = QGroupBox("Output Options")
14112
15518
  output_layout = QFormLayout(output_group)
14113
15519
 
14114
- self.directory = QLineEdit('')
14115
- self.directory.setPlaceholderText("Leave empty for 'my_network'")
14116
- output_layout.addRow("Output Directory:", self.directory)
14117
-
14118
15520
  self.overlays = QPushButton("Overlays")
14119
15521
  self.overlays.setCheckable(True)
14120
15522
  self.overlays.setChecked(True)
14121
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)
14122
15527
 
14123
15528
  self.populate = QPushButton("Populate Nodes from Centroids?")
14124
15529
  self.populate.setCheckable(True)
@@ -14163,10 +15568,8 @@ class ProxDialog(QDialog):
14163
15568
  else:
14164
15569
  targets = None
14165
15570
 
14166
- try:
14167
- directory = self.directory.text() if self.directory.text() else None
14168
- except:
14169
- directory = None
15571
+ directory = None
15572
+
14170
15573
 
14171
15574
  # Get xy_scale and z_scale (1 if empty or invalid)
14172
15575
  try:
@@ -14190,6 +15593,12 @@ class ProxDialog(QDialog):
14190
15593
  except:
14191
15594
  max_neighbors = None
14192
15595
 
15596
+
15597
+ try:
15598
+ downsample = int(self.downsample.text()) if self.downsample.text() else None
15599
+ except:
15600
+ downsample = None
15601
+
14193
15602
  overlays = self.overlays.isChecked()
14194
15603
  fastdil = self.fastdil.isChecked()
14195
15604
 
@@ -14245,8 +15654,12 @@ class ProxDialog(QDialog):
14245
15654
  directory = 'my_network'
14246
15655
 
14247
15656
  # Generate and update overlays
14248
- my_network.network_overlay = my_network.draw_network(directory=directory)
14249
- 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)
14250
15663
 
14251
15664
  # Update channel data
14252
15665
  self.parent().load_channel(2, channel_data = my_network.network_overlay, data = True)
@@ -14368,31 +15781,81 @@ class HistogramSelector(QWidget):
14368
15781
  """)
14369
15782
  layout.addWidget(button)
14370
15783
 
15784
+
14371
15785
  def shortest_path_histogram(self):
14372
15786
  try:
14373
- shortest_path_lengths = dict(nx.all_pairs_shortest_path_length(self.G))
14374
- diameter = max(nx.eccentricity(self.G, sp=shortest_path_lengths).values())
14375
- path_lengths = np.zeros(diameter + 1, dtype=int)
14376
-
14377
- for pls in shortest_path_lengths.values():
14378
- 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)
14379
15824
  path_lengths[pl] += cnts
14380
-
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)
14381
15840
  freq_percent = 100 * path_lengths[1:] / path_lengths[1:].sum()
14382
-
14383
15841
  fig, ax = plt.subplots(figsize=(15, 8))
14384
- ax.bar(np.arange(1, diameter + 1), height=freq_percent)
15842
+ ax.bar(np.arange(1, max_diameter + 1), height=freq_percent)
14385
15843
  ax.set_title(
14386
- "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"
14387
15846
  )
14388
15847
  ax.set_xlabel("Shortest Path Length", fontdict={"size": 22})
14389
15848
  ax.set_ylabel("Frequency (%)", fontdict={"size": 22})
14390
15849
  plt.show()
14391
15850
 
14392
15851
  freq_dict = {freq: length for length, freq in enumerate(freq_percent, start=1)}
14393
- self.network_analysis.format_for_upperright_table(freq_dict, metric='Frequency (%)',
14394
- value='Shortest Path Length',
14395
- 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
+
14396
15859
  except Exception as e:
14397
15860
  print(f"Error generating shortest path histogram: {e}")
14398
15861
 
@@ -14411,20 +15874,62 @@ class HistogramSelector(QWidget):
14411
15874
  title="Degree Centrality Table")
14412
15875
  except Exception as e:
14413
15876
  print(f"Error generating degree centrality histogram: {e}")
14414
-
15877
+
14415
15878
  def betweenness_centrality_histogram(self):
14416
15879
  try:
14417
- 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)
14418
15915
  plt.figure(figsize=(15, 8))
14419
15916
  plt.hist(betweenness_centrality.values(), bins=100)
14420
15917
  plt.xticks(ticks=[0, 0.02, 0.1, 0.2, 0.3, 0.4, 0.5])
14421
- 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
+ )
14422
15922
  plt.xlabel("Betweenness Centrality", fontdict={"size": 20})
14423
15923
  plt.ylabel("Counts", fontdict={"size": 20})
14424
15924
  plt.show()
14425
- self.network_analysis.format_for_upperright_table(betweenness_centrality, metric='Node',
14426
- value='Betweenness Centrality',
14427
- 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
+
14428
15933
  except Exception as e:
14429
15934
  print(f"Error generating betweenness centrality histogram: {e}")
14430
15935
 
@@ -14477,7 +15982,27 @@ class HistogramSelector(QWidget):
14477
15982
  def bridges_analysis(self):
14478
15983
  try:
14479
15984
  bridges = list(nx.bridges(self.G))
14480
- 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',
14481
16006
  title="Bridges")
14482
16007
  except Exception as e:
14483
16008
  print(f"Error generating bridges analysis: {e}")
@@ -14504,7 +16029,7 @@ class HistogramSelector(QWidget):
14504
16029
  def node_connectivity_histogram(self):
14505
16030
  """Local node connectivity - minimum number of nodes that must be removed to disconnect neighbors"""
14506
16031
  try:
14507
- if self.G.number_of_nodes() > 500: # Skip for large networks (computationally expensive)
16032
+ if self.G.number_of_nodes() > 500:
14508
16033
  print("Note this analysis may be slow for large network (>500 nodes)")
14509
16034
  #return
14510
16035
 
@@ -14582,7 +16107,7 @@ class HistogramSelector(QWidget):
14582
16107
  def load_centrality_histogram(self):
14583
16108
  """Load centrality - fraction of shortest paths passing through each node"""
14584
16109
  try:
14585
- if self.G.number_of_nodes() > 1000: # Skip for very large networks
16110
+ if self.G.number_of_nodes() > 1000:
14586
16111
  print("Note this analysis may be slow for large network (>1000 nodes)")
14587
16112
  #return
14588
16113
 
@@ -14601,21 +16126,67 @@ class HistogramSelector(QWidget):
14601
16126
  def communicability_centrality_histogram(self):
14602
16127
  """Communicability centrality - based on communicability between nodes"""
14603
16128
  try:
14604
- if self.G.number_of_nodes() > 500: # Skip for large networks (memory intensive)
16129
+ if self.G.number_of_nodes() > 500:
14605
16130
  print("Note this analysis may be slow for large network (>500 nodes)")
14606
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.")
14607
16138
 
14608
- # Use the correct function name - it's in the communicability module
14609
- comm_centrality = nx.communicability_betweenness_centrality(self.G)
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)"
16166
+
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)
14610
16173
  plt.figure(figsize=(15, 8))
14611
16174
  plt.hist(comm_centrality.values(), bins=50, alpha=0.7)
14612
- 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
+ )
14613
16179
  plt.xlabel("Communicability Betweenness Centrality", fontdict={"size": 20})
14614
16180
  plt.ylabel("Frequency", fontdict={"size": 20})
14615
16181
  plt.show()
14616
- self.network_analysis.format_for_upperright_table(comm_centrality, metric='Node',
14617
- value='Communicability Betweenness Centrality',
14618
- 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
+
14619
16190
  except Exception as e:
14620
16191
  print(f"Error generating communicability betweenness centrality histogram: {e}")
14621
16192
 
@@ -14638,20 +16209,67 @@ class HistogramSelector(QWidget):
14638
16209
  def current_flow_betweenness_histogram(self):
14639
16210
  """Current flow betweenness - models network as electrical circuit"""
14640
16211
  try:
14641
- if self.G.number_of_nodes() > 500: # Skip for large networks (computationally expensive)
16212
+ if self.G.number_of_nodes() > 500:
14642
16213
  print("Note this analysis may be slow for large network (>500 nodes)")
14643
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 = {}
14644
16224
 
14645
- 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)
14646
16256
  plt.figure(figsize=(15, 8))
14647
16257
  plt.hist(current_flow.values(), bins=50, alpha=0.7)
14648
- 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
+ )
14649
16262
  plt.xlabel("Current Flow Betweenness Centrality", fontdict={"size": 20})
14650
16263
  plt.ylabel("Frequency", fontdict={"size": 20})
14651
16264
  plt.show()
14652
- self.network_analysis.format_for_upperright_table(current_flow, metric='Node',
14653
- value='Current Flow Betweenness',
14654
- 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
+
14655
16273
  except Exception as e:
14656
16274
  print(f"Error generating current flow betweenness histogram: {e}")
14657
16275