nettracer3d 0.7.8__py3-none-any.whl → 0.8.0__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.
@@ -25,8 +25,14 @@ import multiprocessing as mp
25
25
  from concurrent.futures import ThreadPoolExecutor
26
26
  from functools import partial
27
27
  from nettracer3d import segmenter
28
- from nettracer3d import segmenter_GPU
28
+ try:
29
+ from nettracer3d import segmenter_GPU as seg_GPU
30
+ except:
31
+ pass
29
32
  from nettracer3d import excelotron
33
+ import threading
34
+ import queue
35
+ from threading import Lock
30
36
 
31
37
 
32
38
 
@@ -41,6 +47,7 @@ class ImageViewerWindow(QMainWindow):
41
47
  self.channel_visible = [False] * 4
42
48
  self.current_slice = 0
43
49
  self.active_channel = 0 # Initialize active channel
50
+ self.node_name = "Root_Nodes"
44
51
 
45
52
  self.color_dictionary = {
46
53
  # Reds
@@ -105,6 +112,9 @@ class ImageViewerWindow(QMainWindow):
105
112
  self.selection_rect = None
106
113
  self.click_start_time = None # Add this to track when click started
107
114
  self.selection_threshold = 1.0 # Time in seconds before starting rectangle selection
115
+ self.background = None
116
+ self.last_update_time = 0
117
+ self.update_interval = 0.016 # 60 FPS
108
118
 
109
119
  # Initialize zoom mode state
110
120
  self.zoom_mode = False
@@ -116,6 +126,15 @@ class ImageViewerWindow(QMainWindow):
116
126
  self.pan_mode = False
117
127
  self.panning = False
118
128
  self.pan_start = None
129
+ self.img_width = None
130
+ self.img_height = None
131
+ self.pre_pan_channel_state = None # Store which channels were visible before pan
132
+ self.pan_background = None # Store the static background image during pan
133
+ self.is_pan_preview = False # Track if we're in pan preview mode
134
+ self.pre_pan_channel_state = None # Store which channels were visible before pan
135
+ self.pan_background_image = None # Store the rendered composite image
136
+ self.pan_zoom_state = None # Store zoom state when pan began
137
+ self.is_pan_preview = False # Track if we're in pan preview mode
119
138
 
120
139
  #For ML segmenting mode
121
140
  self.brush_mode = False
@@ -154,7 +173,9 @@ class ImageViewerWindow(QMainWindow):
154
173
 
155
174
  self.radii_dict = {
156
175
  0: None,
157
- 1: None
176
+ 1: None,
177
+ 2: None,
178
+ 3: None
158
179
  }
159
180
 
160
181
  self.original_shape = None #For undoing resamples
@@ -420,6 +441,25 @@ class ImageViewerWindow(QMainWindow):
420
441
  self.excel_manager.data_received.connect(self.handle_excel_data)
421
442
  self.prev_coms = None
422
443
 
444
+ self.paint_timer = QTimer()
445
+ self.paint_timer.timeout.connect(self.flush_paint_updates)
446
+ self.paint_timer.setSingleShot(True)
447
+ self.pending_paint_update = False
448
+
449
+ # Threading for paint operations
450
+ self.paint_queue = queue.Queue()
451
+ self.paint_lock = Lock()
452
+ self.paint_worker = threading.Thread(target=self.paint_worker_loop, daemon=True)
453
+ self.paint_worker.start()
454
+
455
+ # Background caching for blitting
456
+ self.paint_background = None
457
+ self.paint_session_active = False
458
+
459
+ # Batch paint operations
460
+ self.paint_batch = []
461
+ self.last_paint_pos = None
462
+
423
463
  def start_left_scroll(self):
424
464
  """Start scrolling left when left arrow is pressed."""
425
465
  # Single increment first
@@ -791,6 +831,9 @@ class ImageViewerWindow(QMainWindow):
791
831
  try:
792
832
  # Create context menu
793
833
  context_menu = QMenu(self)
834
+
835
+ find = context_menu.addAction("Find Node/Edge")
836
+ find.triggered.connect(self.handle_find)
794
837
 
795
838
  # Create "Show Neighbors" submenu
796
839
  neighbors_menu = QMenu("Show Neighbors", self)
@@ -1447,6 +1490,109 @@ class ImageViewerWindow(QMainWindow):
1447
1490
  except Exception as e:
1448
1491
  print(f"Error showing identities: {e}")
1449
1492
 
1493
+ def handle_find(self):
1494
+
1495
+ class FindDialog(QDialog):
1496
+ def __init__(self, parent=None):
1497
+ super().__init__(parent)
1498
+ self.setWindowTitle("Find Node (or edge?)")
1499
+ self.setModal(True)
1500
+
1501
+ layout = QFormLayout(self)
1502
+
1503
+ self.targ = QLineEdit("")
1504
+ layout.addRow("Node/Edge ID:", self.targ)
1505
+
1506
+ run_button = QPushButton("Enter")
1507
+ run_button.clicked.connect(self.run)
1508
+ layout.addWidget(run_button)
1509
+
1510
+ def run(self):
1511
+
1512
+ try:
1513
+
1514
+ value = int(self.targ.text()) if self.targ.text().strip() else None
1515
+
1516
+ if value is None:
1517
+ return
1518
+
1519
+ if self.parent().active_channel == 1:
1520
+
1521
+ mode = 'edges'
1522
+
1523
+ if my_network.edge_centroids is None:
1524
+ self.parent().show_centroid_dialog()
1525
+
1526
+ num = (self.parent().channel_data[1].shape[0] * self.parent().channel_data[1].shape[1] * self.parent().channel_data[1].shape[2])
1527
+
1528
+ if value in my_network.edge_centroids:
1529
+
1530
+ # Get centroid coordinates (Z, Y, X)
1531
+ centroid = my_network.edge_centroids[value]
1532
+ # Set the active channel to edges (1)
1533
+ self.parent().set_active_channel(1)
1534
+ # Toggle on the edges channel if it's not already visible
1535
+ if not self.parent().channel_visible[1]:
1536
+ self.parent().channel_buttons[1].setChecked(True)
1537
+ self.parent().toggle_channel(1)
1538
+ # Navigate to the Z-slice
1539
+ self.parent().slice_slider.setValue(int(centroid[0]))
1540
+ print(f"Found edge {value} at Z-slice {centroid[0]}")
1541
+
1542
+ else:
1543
+ print(f"Edge {value} not found in centroids dictionary")
1544
+
1545
+
1546
+ else:
1547
+
1548
+ mode = 'nodes'
1549
+
1550
+ if my_network.node_centroids is None:
1551
+ self.parent().show_centroid_dialog()
1552
+
1553
+ num = (self.parent().channel_data[0].shape[0] * self.parent().channel_data[0].shape[1] * self.parent().channel_data[0].shape[2])
1554
+
1555
+ if value in my_network.node_centroids:
1556
+ # Get centroid coordinates (Z, Y, X)
1557
+ centroid = my_network.node_centroids[value]
1558
+ # Set the active channel to nodes (0)
1559
+ self.parent().set_active_channel(0)
1560
+ # Toggle on the nodes channel if it's not already visible
1561
+ if not self.parent().channel_visible[0]:
1562
+ self.parent().channel_buttons[0].setChecked(True)
1563
+ self.parent().toggle_channel(0)
1564
+ # Navigate to the Z-slice
1565
+ self.parent().slice_slider.setValue(int(centroid[0]))
1566
+ print(f"Found node {value} at Z-slice {centroid[0]}")
1567
+
1568
+ else:
1569
+ print(f"Node {value} not found in centroids dictionary")
1570
+
1571
+ self.parent().clicked_values[mode] = [value]
1572
+
1573
+ if num > self.parent().mini_thresh:
1574
+ self.parent().mini_overlay = True
1575
+ self.parent().create_mini_overlay(node_indices = self.parent().clicked_values['nodes'], edge_indices = self.parent().clicked_values['edges'])
1576
+ else:
1577
+ self.parent().create_highlight_overlay(
1578
+ node_indices=self.parent().clicked_values['nodes'],
1579
+ edge_indices=self.parent().clicked_values['edges']
1580
+ )
1581
+
1582
+
1583
+
1584
+ # Close the dialog after processing
1585
+ self.accept()
1586
+
1587
+ except Exception as e:
1588
+
1589
+ print(f"Error: {e}")
1590
+
1591
+ dialog = FindDialog(self)
1592
+ dialog.exec()
1593
+
1594
+
1595
+
1450
1596
 
1451
1597
  def handle_select_all(self, nodes = True, edges = False):
1452
1598
 
@@ -1627,63 +1773,32 @@ class ImageViewerWindow(QMainWindow):
1627
1773
  except Exception as e:
1628
1774
  print(f"An error has occured: {e}")
1629
1775
 
1630
- def handle_seperate(self):
1776
+ def separate_nontouching_objects(self, input_array, max_val=0):
1777
+ """
1778
+ optimized version using advanced indexing.
1779
+ """
1780
+
1781
+ print("Splitting nontouching objects")
1631
1782
 
1632
- import scipy.ndimage as ndi
1633
- from scipy.sparse import csr_matrix
1634
-
1635
- print("Note, this method is a tad slow...")
1636
-
1637
- def separate_nontouching_objects(input_array, max_val = 0):
1638
- """
1639
- Efficiently separate non-touching objects in a labeled array.
1640
-
1641
- Parameters:
1642
- -----------
1643
- input_array : numpy.ndarray
1644
- Input labeled array where each object has a unique label value > 0
1645
-
1646
- Returns:
1647
- --------
1648
- output_array : numpy.ndarray
1649
- Array with new labels where non-touching components have different labels
1650
- """
1651
- # Step 1: Perform connected component labeling on the entire binary mask
1652
- binary_mask = input_array > 0
1653
- structure = np.ones((3,) * input_array.ndim, dtype=bool) # 3x3x3 connectivity for 3D or 3x3 for 2D
1654
- labeled_array, num_features = ndi.label(binary_mask, structure=structure)
1655
-
1656
- # Step 2: Map the original labels to the new connected components
1657
- # Create a sparse matrix to efficiently store label mappings
1658
- coords = np.nonzero(input_array)
1659
- original_values = input_array[coords]
1660
- new_labels = labeled_array[coords]
1661
-
1662
- # Create a mapping of (original_label, new_connected_component) pairs
1663
- label_mapping = {}
1664
- for orig, new in zip(original_values, new_labels):
1665
- if orig not in label_mapping:
1666
- label_mapping[orig] = []
1667
- if new not in label_mapping[orig]:
1668
- label_mapping[orig].append(new)
1669
-
1670
- # Step 3: Create a new output array with unique labels for each connected component
1671
- output_array = np.zeros_like(input_array)
1672
- next_label = 1 + max_val
1673
-
1674
- # Map of (original_label, connected_component) -> new_unique_label
1675
- unique_label_map = {}
1676
-
1677
- for orig_label, cc_list in label_mapping.items():
1678
- for cc in cc_list:
1679
- unique_label_map[(orig_label, cc)] = next_label
1680
- # Create a mask for this original label and connected component
1681
- mask = (input_array == orig_label) & (labeled_array == cc)
1682
- # Assign the new unique label
1683
- output_array[mask] = next_label
1684
- next_label += 1
1685
-
1686
- return output_array
1783
+ binary_mask = input_array > 0
1784
+ labeled_array, _ = n3d.label_objects(binary_mask)
1785
+
1786
+ # Create a compound key for each (original_label, connected_component) pair
1787
+ # This avoids the need for explicit mapping
1788
+ mask = binary_mask
1789
+ compound_key = input_array[mask] * (labeled_array.max() + 1) + labeled_array[mask]
1790
+
1791
+ # Get unique compound keys and create new labels
1792
+ unique_keys, inverse_indices = np.unique(compound_key, return_inverse=True)
1793
+ new_labels = np.arange(max_val + 1, max_val + 1 + len(unique_keys))
1794
+
1795
+ # Create output array
1796
+ output_array = np.zeros_like(input_array)
1797
+ output_array[mask] = new_labels[inverse_indices]
1798
+
1799
+ return output_array
1800
+
1801
+ def handle_seperate(self):
1687
1802
 
1688
1803
  try:
1689
1804
  # Handle nodes
@@ -1705,7 +1820,7 @@ class ImageViewerWindow(QMainWindow):
1705
1820
  max_val = np.max(non_highlighted)
1706
1821
 
1707
1822
  # Process highlighted part
1708
- processed_highlights = separate_nontouching_objects(highlighted_nodes, max_val)
1823
+ processed_highlights = self.separate_nontouching_objects(highlighted_nodes, max_val)
1709
1824
 
1710
1825
  # Combine back with non-highlighted parts
1711
1826
  my_network.nodes = non_highlighted + processed_highlights
@@ -1726,13 +1841,13 @@ class ImageViewerWindow(QMainWindow):
1726
1841
  # Get non-highlighted part of the array
1727
1842
  non_highlighted = my_network.edges * (~self.highlight_overlay)
1728
1843
 
1729
- if (highlighted_nodes==non_highlighted).all():
1844
+ if (highlighted_edges==non_highlighted).all():
1730
1845
  max_val = 0
1731
1846
  else:
1732
1847
  max_val = np.max(non_highlighted)
1733
1848
 
1734
1849
  # Process highlighted part
1735
- processed_highlights = separate_nontouching_objects(highlighted_edges, max_val)
1850
+ processed_highlights = self.separate_nontouching_objects(highlighted_edges, max_val)
1736
1851
 
1737
1852
  # Combine back with non-highlighted parts
1738
1853
  my_network.edges = non_highlighted + processed_highlights
@@ -2071,8 +2186,11 @@ class ImageViewerWindow(QMainWindow):
2071
2186
 
2072
2187
  self.update_display(preserve_zoom=(current_xlim, current_ylim))
2073
2188
 
2189
+
2074
2190
  def keyPressEvent(self, event):
2075
2191
 
2192
+ """Key press shortcuts for main class"""
2193
+
2076
2194
  if event.key() == Qt.Key_Z and event.modifiers() & Qt.ControlModifier:
2077
2195
  try:
2078
2196
  self.load_channel(self.last_change[1], self.last_change[0], True)
@@ -2088,6 +2206,8 @@ class ImageViewerWindow(QMainWindow):
2088
2206
  self.machine_window.switch_foreground()
2089
2207
  if event.key() == Qt.Key_X:
2090
2208
  self.high_button.click()
2209
+ if event.key() == Qt.Key_F and event.modifiers() == Qt.ShiftModifier:
2210
+ self.handle_find()
2091
2211
  if self.brush_mode and self.machine_window is None:
2092
2212
  if event.key() == Qt.Key_F:
2093
2213
  self.toggle_can()
@@ -2095,6 +2215,7 @@ class ImageViewerWindow(QMainWindow):
2095
2215
  self.toggle_threed()
2096
2216
 
2097
2217
 
2218
+
2098
2219
  def update_brush_cursor(self):
2099
2220
  """Update the cursor to show brush size"""
2100
2221
  if not self.brush_mode:
@@ -2155,7 +2276,7 @@ class ImageViewerWindow(QMainWindow):
2155
2276
  painter.end()
2156
2277
 
2157
2278
  def get_line_points(self, x0, y0, x1, y1):
2158
- """Get all points in a line between (x0,y0) and (x1,y1) using Bresenham's algorithm"""
2279
+ """Get all points in a line between (x0,y0) and (x1,y1) using Bresenham's algorithm."""
2159
2280
  points = []
2160
2281
  dx = abs(x1 - x0)
2161
2282
  dy = abs(y1 - y0)
@@ -2208,7 +2329,7 @@ class ImageViewerWindow(QMainWindow):
2208
2329
  return data_coords[0], data_coords[1]
2209
2330
 
2210
2331
  def on_mouse_press(self, event):
2211
- """Handle mouse press events."""
2332
+ """Handle mouse press events - OPTIMIZED VERSION."""
2212
2333
  if event.inaxes != self.ax:
2213
2334
  return
2214
2335
 
@@ -2246,7 +2367,6 @@ class ImageViewerWindow(QMainWindow):
2246
2367
  new_xlim = [xdata - x_range, xdata + x_range]
2247
2368
  new_ylim = [ydata - y_range, ydata + y_range]
2248
2369
 
2249
-
2250
2370
  if (new_xlim[0] <= self.original_xlim[0] or
2251
2371
  new_xlim[1] >= self.original_xlim[1] or
2252
2372
  new_ylim[0] <= self.original_ylim[0] or
@@ -2268,22 +2388,31 @@ class ImageViewerWindow(QMainWindow):
2268
2388
  self.panning = True
2269
2389
  self.pan_start = (event.xdata, event.ydata)
2270
2390
  self.canvas.setCursor(Qt.CursorShape.ClosedHandCursor)
2391
+
2392
+ # Store current channel visibility state
2393
+ self.pre_pan_channel_state = self.channel_visible.copy()
2394
+
2395
+ # Create static background from currently visible channels
2396
+ self.create_pan_background()
2397
+
2398
+ # Hide all channels and show only the background
2399
+ self.channel_visible = [False] * 4
2400
+ self.is_pan_preview = True
2401
+
2402
+ # Update display to show only background
2403
+ self.update_display_pan_mode()
2271
2404
 
2272
2405
  elif self.brush_mode:
2273
2406
  if event.inaxes != self.ax:
2274
2407
  return
2275
2408
 
2276
-
2277
2409
  if event.button == 1 or event.button == 3:
2278
-
2279
2410
  x, y = int(event.xdata), int(event.ydata)
2280
2411
 
2281
-
2282
2412
  if event.button == 1 and self.can:
2283
2413
  self.handle_can(x, y)
2284
2414
  return
2285
2415
 
2286
-
2287
2416
  if event.button == 3:
2288
2417
  self.erase = True
2289
2418
  else:
@@ -2297,27 +2426,24 @@ class ImageViewerWindow(QMainWindow):
2297
2426
  else:
2298
2427
  channel = 2
2299
2428
 
2300
- # Paint at initial position
2301
- self.paint_at_position(x, y, self.erase, channel)
2302
-
2429
+ # Get current zoom to preserve it
2303
2430
  current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2304
2431
  current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2305
2432
 
2306
-
2433
+ # Paint at initial position
2434
+ self.paint_at_position(x, y, self.erase, channel)
2435
+
2307
2436
  self.canvas.draw()
2308
- #self.update_display(preserve_zoom=(current_xlim, current_ylim))
2309
- self.restore_channels = []
2310
-
2311
2437
 
2312
- for i in range(4):
2313
- if i == channel:
2314
- self.channel_visible[i] = True
2315
- elif self.channel_data[i] is not None and self.channel_visible[i] == True:
2316
- self.channel_visible[i] = False
2317
- self.restore_channels.append(i)
2318
- self.update_display(preserve_zoom = (current_xlim, current_ylim), begin_paint = True)
2319
- self.update_display_slice(channel, preserve_zoom=(current_xlim, current_ylim))
2438
+ self.restore_channels = []
2439
+ if not self.channel_visible[channel]:
2440
+ self.channel_visible[channel] = True
2320
2441
 
2442
+ # No need to hide other channels or track restore_channels
2443
+ self.restore_channels = []
2444
+
2445
+ self.update_display(preserve_zoom=(current_xlim, current_ylim), begin_paint=True)
2446
+ self.update_display_slice_optimized(channel, preserve_zoom=(current_xlim, current_ylim))
2321
2447
 
2322
2448
  elif event.button == 3: # Right click (for context menu)
2323
2449
  self.create_context_menu(event)
@@ -2331,7 +2457,7 @@ class ImageViewerWindow(QMainWindow):
2331
2457
  """Paint pixels within brush radius at given position"""
2332
2458
  if self.channel_data[channel] is None:
2333
2459
  return
2334
-
2460
+
2335
2461
  if erase:
2336
2462
  val = 0
2337
2463
  elif self.machine_window is None:
@@ -2340,26 +2466,74 @@ class ImageViewerWindow(QMainWindow):
2340
2466
  val = 1
2341
2467
  else:
2342
2468
  val = 2
2343
-
2344
2469
  height, width = self.channel_data[channel][self.current_slice].shape
2345
2470
  radius = self.brush_size // 2
2346
-
2471
+
2347
2472
  # Calculate brush area
2348
2473
  for y in range(max(0, center_y - radius), min(height, center_y + radius + 1)):
2349
2474
  for x in range(max(0, center_x - radius), min(width, center_x + radius + 1)):
2350
2475
  # Check if point is within circular brush area
2351
- if (x - center_x) ** 2 + (y - center_y) ** 2 <= radius ** 2:
2352
-
2476
+ if (x - center_x) * 2 + (y - center_y) * 2 <= radius ** 2:
2353
2477
  if self.threed and self.threedthresh > 1:
2354
2478
  amount = (self.threedthresh - 1) / 2
2355
2479
  low = max(0, self.current_slice - amount)
2356
2480
  high = min(self.channel_data[channel].shape[0] - 1, self.current_slice + amount)
2357
-
2358
2481
  for i in range(int(low), int(high + 1)):
2359
2482
  self.channel_data[channel][i][y, x] = val
2360
2483
  else:
2361
2484
  self.channel_data[channel][self.current_slice][y, x] = val
2362
2485
 
2486
+ def paint_at_position_vectorized(self, center_x, center_y, erase=False, channel=2,
2487
+ slice_idx=None, brush_size=None, threed=False,
2488
+ threedthresh=1, foreground=True, machine_window=None):
2489
+ """Vectorized paint operation for better performance."""
2490
+ if self.channel_data[channel] is None:
2491
+ return
2492
+
2493
+ # Use provided parameters or fall back to instance variables
2494
+ slice_idx = slice_idx if slice_idx is not None else self.current_slice
2495
+ brush_size = brush_size if brush_size is not None else getattr(self, 'brush_size', 5)
2496
+
2497
+ # Determine paint value
2498
+ if erase:
2499
+ val = 0
2500
+ elif machine_window is None:
2501
+ val = 255
2502
+ elif foreground:
2503
+ val = 1
2504
+ else:
2505
+ val = 2
2506
+
2507
+ height, width = self.channel_data[channel][slice_idx].shape
2508
+ radius = brush_size // 2
2509
+
2510
+ # Calculate affected region bounds
2511
+ y_min = max(0, center_y - radius)
2512
+ y_max = min(height, center_y + radius + 1)
2513
+ x_min = max(0, center_x - radius)
2514
+ x_max = min(width, center_x + radius + 1)
2515
+
2516
+ if y_min >= y_max or x_min >= x_max:
2517
+ return # No valid region to paint
2518
+
2519
+ # Create coordinate grids for the affected region
2520
+ y_coords, x_coords = np.mgrid[y_min:y_max, x_min:x_max]
2521
+
2522
+ # Calculate distances squared (avoid sqrt for performance)
2523
+ distances_sq = (x_coords - center_x) ** 2 + (y_coords - center_y) ** 2
2524
+ mask = distances_sq <= radius ** 2
2525
+
2526
+ # Apply paint to affected slices
2527
+ if threed and threedthresh > 1:
2528
+ amount = (threedthresh - 1) / 2
2529
+ low = max(0, int(slice_idx - amount))
2530
+ high = min(self.channel_data[channel].shape[0] - 1, int(slice_idx + amount))
2531
+
2532
+ for i in range(low, high + 1):
2533
+ self.channel_data[channel][i][y_min:y_max, x_min:x_max][mask] = val
2534
+ else:
2535
+ self.channel_data[channel][slice_idx][y_min:y_max, x_min:x_max][mask] = val
2536
+
2363
2537
  def handle_can(self, x, y):
2364
2538
 
2365
2539
 
@@ -2422,33 +2596,42 @@ class ImageViewerWindow(QMainWindow):
2422
2596
 
2423
2597
 
2424
2598
  def on_mouse_move(self, event):
2425
- """Handle mouse movement events."""
2426
- if event.inaxes != self.ax:
2599
+ if not event.inaxes or event.xdata is None or event.ydata is None:
2427
2600
  return
2428
-
2601
+
2602
+ current_time = time.time()
2603
+
2429
2604
  if self.selection_start and not self.selecting and not self.pan_mode and not self.zoom_mode and not self.brush_mode:
2430
- # If mouse has moved more than a tiny amount while button is held, start selection
2431
2605
  if (abs(event.xdata - self.selection_start[0]) > 1 or
2432
2606
  abs(event.ydata - self.selection_start[1]) > 1):
2433
2607
  self.selecting = True
2608
+ self.background = self.canvas.copy_from_bbox(self.ax.bbox)
2609
+
2434
2610
  self.selection_rect = plt.Rectangle(
2435
2611
  (self.selection_start[0], self.selection_start[1]), 0, 0,
2436
- fill=False, color='white', linestyle='--'
2612
+ fill=False, color='white', linestyle='--', animated=True
2437
2613
  )
2438
2614
  self.ax.add_patch(self.selection_rect)
2439
2615
 
2440
2616
  if self.selecting and self.selection_rect is not None:
2441
- # Update selection rectangle
2442
- x0 = min(self.selection_start[0], event.xdata)
2443
- y0 = min(self.selection_start[1], event.ydata)
2617
+ # Throttle updates
2618
+ if current_time - self.last_update_time < self.update_interval:
2619
+ return
2620
+ self.last_update_time = current_time
2621
+
2622
+ # Use blitting
2623
+ self.canvas.restore_region(self.background)
2624
+
2625
+ x_min = min(self.selection_start[0], event.xdata)
2626
+ y_min = min(self.selection_start[1], event.ydata)
2444
2627
  width = abs(event.xdata - self.selection_start[0])
2445
2628
  height = abs(event.ydata - self.selection_start[1])
2446
2629
 
2447
- self.selection_rect.set_bounds(x0, y0, width, height)
2448
- self.canvas.draw()
2630
+ self.selection_rect.set_bounds(x_min, y_min, width, height)
2631
+ self.ax.draw_artist(self.selection_rect)
2632
+ self.canvas.blit(self.ax.bbox)
2449
2633
 
2450
2634
  elif self.panning and self.pan_start is not None:
2451
-
2452
2635
  # Calculate the movement
2453
2636
  dx = event.xdata - self.pan_start[0]
2454
2637
  dy = event.ydata - self.pan_start[1]
@@ -2461,25 +2644,23 @@ class ImageViewerWindow(QMainWindow):
2461
2644
  new_xlim = [xlim[0] - dx, xlim[1] - dx]
2462
2645
  new_ylim = [ylim[0] - dy, ylim[1] - dy]
2463
2646
 
2464
- # Get image bounds
2465
- if self.channel_data[0] is not None: # Use first channel as reference
2466
- img_height, img_width = self.channel_data[0][self.current_slice].shape
2467
-
2647
+ # Get image bounds using cached dimensions
2648
+ if self.img_width is not None: # Changed from self.channel_data[0] check
2468
2649
  # Ensure new limits don't go beyond image bounds
2469
2650
  if new_xlim[0] < 0:
2470
2651
  new_xlim = [0, xlim[1] - xlim[0]]
2471
- elif new_xlim[1] > img_width:
2472
- new_xlim = [img_width - (xlim[1] - xlim[0]), img_width]
2652
+ elif new_xlim[1] > self.img_width: # Changed from img_width variable lookup
2653
+ new_xlim = [self.img_width - (xlim[1] - xlim[0]), self.img_width]
2473
2654
 
2474
2655
  if new_ylim[0] < 0:
2475
2656
  new_ylim = [0, ylim[1] - ylim[0]]
2476
- elif new_ylim[1] > img_height:
2477
- new_ylim = [img_height - (ylim[1] - ylim[0]), img_height]
2657
+ elif new_ylim[1] > self.img_height: # Changed from img_height variable lookup
2658
+ new_ylim = [self.img_height - (ylim[1] - ylim[0]), self.img_height]
2478
2659
 
2479
2660
  # Apply new limits
2480
2661
  self.ax.set_xlim(new_xlim)
2481
2662
  self.ax.set_ylim(new_ylim)
2482
- self.canvas.draw()
2663
+ self.canvas.draw_idle() # Changed from draw() to draw_idle()
2483
2664
 
2484
2665
  # Update pan start position
2485
2666
  self.pan_start = (event.xdata, event.ydata)
@@ -2487,43 +2668,365 @@ class ImageViewerWindow(QMainWindow):
2487
2668
  elif self.painting and self.brush_mode:
2488
2669
  if event.inaxes != self.ax:
2489
2670
  return
2490
-
2491
- x, y = int(event.xdata), int(event.ydata)
2671
+
2672
+ # OPTIMIZED: Queue paint operation instead of immediate execution
2673
+ self.queue_paint_operation(event)
2674
+
2675
+ # OPTIMIZED: Schedule display update at controlled frequency
2676
+ if not self.pending_paint_update:
2677
+ self.pending_paint_update = True
2678
+ self.paint_timer.start(16) # ~60fps max update rate
2492
2679
 
2493
- if self.pen_button.isChecked():
2494
- channel = self.active_channel
2495
- else:
2496
- channel = 2
2680
+ def queue_paint_operation(self, event):
2681
+ """Queue a paint operation for background processing."""
2682
+ x, y = int(event.xdata), int(event.ydata)
2497
2683
 
2684
+ if self.pen_button.isChecked():
2685
+ channel = self.active_channel
2686
+ else:
2687
+ channel = 2
2688
+
2689
+ if self.channel_data[channel] is not None:
2690
+ # Prepare paint session if needed
2691
+ if not self.paint_session_active:
2692
+ self.prepare_paint_session(channel)
2693
+
2694
+ # Create paint operation
2695
+ paint_op = {
2696
+ 'type': 'stroke',
2697
+ 'x': x,
2698
+ 'y': y,
2699
+ 'last_pos': getattr(self, 'last_paint_pos', None),
2700
+ 'brush_size': self.brush_size,
2701
+ 'erase': self.erase,
2702
+ 'channel': channel,
2703
+ 'slice': self.current_slice,
2704
+ 'threed': getattr(self, 'threed', False),
2705
+ 'threedthresh': getattr(self, 'threedthresh', 1),
2706
+ 'foreground': getattr(self, 'foreground', True),
2707
+ 'machine_window': getattr(self, 'machine_window', None)
2708
+ }
2498
2709
 
2499
- if self.channel_data[channel] is not None:
2500
- current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2501
- current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2502
- height, width = self.channel_data[channel][self.current_slice].shape
2710
+ # Queue the operation
2711
+ try:
2712
+ self.paint_queue.put_nowait(paint_op)
2713
+ except queue.Full:
2714
+ pass # Skip if queue is full to avoid blocking
2715
+
2716
+ self.last_paint_pos = (x, y)
2717
+
2718
+ def prepare_paint_session(self, channel):
2719
+ """Prepare optimized background for blitting during paint session."""
2720
+ if self.paint_session_active:
2721
+ return
2722
+
2723
+ # IMPORTANT: Don't capture background here - let the main display update handle it
2724
+ # We'll capture the background after the proper channel visibility setup
2725
+ self.paint_session_active = True
2726
+
2727
+ def end_paint_session(self):
2728
+ """Clean up after paint session."""
2729
+ self.paint_session_active = False
2730
+ self.paint_background = None
2731
+ self.last_paint_pos = None
2732
+
2733
+ def paint_worker_loop(self):
2734
+ """Background thread for processing paint operations."""
2735
+ while True:
2736
+ try:
2737
+ paint_op = self.paint_queue.get(timeout=1.0)
2738
+ if paint_op is None: # Shutdown signal
2739
+ break
2503
2740
 
2504
- if hasattr(self, 'last_paint_pos'):
2505
- last_x, last_y = self.last_paint_pos
2506
- points = self.get_line_points(last_x, last_y, x, y)
2741
+ with self.paint_lock:
2742
+ self.execute_paint_operation(paint_op)
2507
2743
 
2508
- # Paint at each point along the line
2744
+ except queue.Empty:
2745
+ continue
2509
2746
 
2510
- for px, py in points:
2511
- if 0 <= px < width and 0 <= py < height:
2512
- self.paint_at_position(px, py, self.erase, channel)
2747
+ def shutdown(self):
2748
+ """Clean shutdown of worker thread."""
2749
+ self.paint_queue.put(None) # Signal worker to stop
2750
+ if hasattr(self, 'paint_worker'):
2751
+ self.paint_worker.join(timeout=1.0)
2752
+
2753
+ def execute_paint_operation(self, paint_op):
2754
+ """Execute a single paint operation on the data arrays."""
2755
+ if paint_op['type'] == 'stroke':
2756
+ channel = paint_op['channel']
2757
+ x, y = paint_op['x'], paint_op['y']
2758
+ last_pos = paint_op['last_pos']
2759
+
2760
+ if last_pos is not None:
2761
+ # Paint line from last position to current
2762
+ points = self.get_line_points(last_pos[0], last_pos[1], x, y)
2763
+ for px, py in points:
2764
+ height, width = self.channel_data[channel][paint_op['slice']].shape
2765
+ if 0 <= px < width and 0 <= py < height:
2766
+ self.paint_at_position_vectorized(
2767
+ px, py, paint_op['erase'], paint_op['channel'],
2768
+ paint_op['slice'], paint_op['brush_size'],
2769
+ paint_op['threed'], paint_op['threedthresh'],
2770
+ paint_op['foreground'], paint_op['machine_window']
2771
+ )
2772
+ else:
2773
+ # Single point paint
2774
+ height, width = self.channel_data[channel][paint_op['slice']].shape
2775
+ if 0 <= x < width and 0 <= y < height:
2776
+ self.paint_at_position_vectorized(
2777
+ x, y, paint_op['erase'], paint_op['channel'],
2778
+ paint_op['slice'], paint_op['brush_size'],
2779
+ paint_op['threed'], paint_op['threedthresh'],
2780
+ paint_op['foreground'], paint_op['machine_window']
2781
+ )
2782
+
2783
+ def flush_paint_updates(self):
2784
+ """Update the display with batched paint changes."""
2785
+ self.pending_paint_update = False
2786
+
2787
+ # Determine which channel to update
2788
+ channel = self.active_channel if hasattr(self, 'pen_button') and self.pen_button.isChecked() else 2
2789
+
2790
+ # Get current zoom to preserve it
2791
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2792
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2793
+
2794
+ # Update display
2795
+ self.update_display_slice_optimized(channel, preserve_zoom=(current_xlim, current_ylim))
2796
+
2797
+ def create_pan_background(self):
2798
+ """Create a static background image from currently visible channels with proper rendering"""
2799
+ # Store current zoom state
2800
+ current_xlim = self.ax.get_xlim()
2801
+ current_ylim = self.ax.get_ylim()
2802
+
2803
+ # Render all visible channels with proper colors/brightness into a single composite
2804
+ self.pan_background_image = self.create_composite_for_pan()
2805
+ self.pan_zoom_state = (current_xlim, current_ylim)
2806
+
2807
+ def create_composite_for_pan(self):
2808
+ """Create a properly rendered composite image for panning"""
2809
+ # Get active channels and dimensions (copied from update_display)
2810
+ active_channels = [i for i in range(4) if self.channel_data[i] is not None]
2811
+ if active_channels:
2812
+ dims = [(self.channel_data[i].shape[1:3] if len(self.channel_data[i].shape) >= 3 else
2813
+ self.channel_data[i].shape) for i in active_channels]
2814
+ min_height = min(d[0] for d in dims)
2815
+ min_width = min(d[1] for d in dims)
2816
+ else:
2817
+ return None
2818
+
2819
+ # Create a blank RGBA composite to accumulate all channels
2820
+ composite = np.zeros((min_height, min_width, 4), dtype=np.float32)
2821
+
2822
+ # Process each visible channel exactly like update_display does
2823
+ for channel in range(4):
2824
+ if (self.channel_visible[channel] and
2825
+ self.channel_data[channel] is not None):
2513
2826
 
2514
- self.last_paint_pos = (x, y)
2827
+ # Get current slice data (same logic as update_display)
2828
+ is_rgb = len(self.channel_data[channel].shape) == 4 and (self.channel_data[channel].shape[-1] == 3 or self.channel_data[channel].shape[-1] == 4)
2515
2829
 
2516
- self.canvas.draw()
2517
- #self.update_display(preserve_zoom=(current_xlim, current_ylim))
2518
- self.update_display_slice(channel, preserve_zoom=(current_xlim, current_ylim))
2830
+ if len(self.channel_data[channel].shape) == 3 and not is_rgb:
2831
+ current_image = self.channel_data[channel][self.current_slice, :, :]
2832
+ elif is_rgb:
2833
+ current_image = self.channel_data[channel][self.current_slice]
2834
+ else:
2835
+ current_image = self.channel_data[channel]
2836
+
2837
+ if is_rgb and self.channel_data[channel].shape[-1] == 3:
2838
+ # RGB image - convert to RGBA and blend
2839
+ rgb_alpha = np.ones((*current_image.shape[:2], 4), dtype=np.float32)
2840
+ rgb_alpha[:, :, :3] = current_image.astype(np.float32) / 255.0
2841
+ rgb_alpha[:, :, 3] = 0.7 # Same alpha as update_display
2842
+ composite = self.blend_layers(composite, rgb_alpha)
2843
+
2844
+ elif is_rgb and self.channel_data[channel].shape[-1] == 4:
2845
+ # RGBA image - blend directly
2846
+ rgba_image = current_image.astype(np.float32) / 255.0
2847
+ composite = self.blend_layers(composite, rgba_image)
2848
+
2849
+ else:
2850
+ # Regular channel processing (same logic as update_display)
2851
+ if self.min_max[channel][0] == None:
2852
+ self.min_max[channel][0] = np.min(current_image)
2853
+ if self.min_max[channel][1] == None:
2854
+ self.min_max[channel][1] = np.max(current_image)
2855
+
2856
+ img_min = self.min_max[channel][0]
2857
+ img_max = self.min_max[channel][1]
2858
+
2859
+ if img_min == img_max:
2860
+ vmin = img_min
2861
+ vmax = img_min + 1
2862
+ else:
2863
+ vmin = img_min + (img_max - img_min) * self.channel_brightness[channel]['min']
2864
+ vmax = img_min + (img_max - img_min) * self.channel_brightness[channel]['max']
2865
+
2866
+ # Normalize the image
2867
+ if vmin == vmax:
2868
+ normalized_image = np.zeros_like(current_image)
2869
+ else:
2870
+ normalized_image = np.clip((current_image - vmin) / (vmax - vmin), 0, 1)
2871
+
2872
+ # Apply channel color and alpha
2873
+ if channel == 2 and self.machine_window is not None:
2874
+ # Special case for machine window channel 2
2875
+ channel_rgba = self.apply_machine_colormap(current_image)
2876
+ else:
2877
+ # Regular channel with custom color
2878
+ color = self.base_colors[channel]
2879
+ channel_rgba = np.zeros((*normalized_image.shape, 4), dtype=np.float32)
2880
+ channel_rgba[:, :, 0] = normalized_image * color[0] # R
2881
+ channel_rgba[:, :, 1] = normalized_image * color[1] # G
2882
+ channel_rgba[:, :, 2] = normalized_image * color[2] # B
2883
+ channel_rgba[:, :, 3] = normalized_image * 0.7 # A (same alpha as update_display)
2884
+
2885
+ # Blend this channel into the composite
2886
+ composite = self.blend_layers(composite, channel_rgba)
2887
+
2888
+ # Add highlight overlays if they exist (same logic as update_display)
2889
+ if self.mini_overlay and self.highlight and self.machine_window is None:
2890
+ highlight_rgba = self.create_highlight_rgba(self.mini_overlay_data, yellow=True)
2891
+ composite = self.blend_layers(composite, highlight_rgba)
2892
+ elif self.highlight_overlay is not None and self.highlight:
2893
+ highlight_slice = self.highlight_overlay[self.current_slice]
2894
+ if self.machine_window is None:
2895
+ highlight_rgba = self.create_highlight_rgba(highlight_slice, yellow=True)
2896
+ else:
2897
+ highlight_rgba = self.create_highlight_rgba(highlight_slice, yellow=False)
2898
+ composite = self.blend_layers(composite, highlight_rgba)
2899
+
2900
+ # Convert to 0-255 range for display
2901
+ return (composite * 255).astype(np.uint8)
2902
+
2903
+ def apply_machine_colormap(self, image):
2904
+ """Apply the special machine window colormap for channel 2"""
2905
+ rgba = np.zeros((*image.shape, 4), dtype=np.float32)
2906
+
2907
+ # Transparent for 0
2908
+ mask_0 = (image == 0)
2909
+ rgba[mask_0] = [0, 0, 0, 0]
2910
+
2911
+ # Light green for 1
2912
+ mask_1 = (image == 1)
2913
+ rgba[mask_1] = [0.5, 1, 0.5, 0.7]
2914
+
2915
+ # Light red for 2
2916
+ mask_2 = (image == 2)
2917
+ rgba[mask_2] = [1, 0.5, 0.5, 0.7]
2918
+
2919
+ return rgba
2519
2920
 
2921
+ def create_highlight_rgba(self, highlight_data, yellow=True):
2922
+ """Create RGBA highlight overlay"""
2923
+ rgba = np.zeros((*highlight_data.shape, 4), dtype=np.float32)
2924
+
2925
+ if yellow:
2926
+ # Yellow highlight
2927
+ mask = highlight_data > 0
2928
+ rgba[mask] = [1, 1, 0, 0.5] # Yellow with alpha 0.5
2929
+ else:
2930
+ # Multi-color highlight for machine window
2931
+ mask_1 = (highlight_data == 1)
2932
+ mask_2 = (highlight_data == 2)
2933
+ rgba[mask_1] = [1, 1, 0, 0.5] # Yellow for 1
2934
+ rgba[mask_2] = [0, 0.7, 1, 0.5] # Blue for 2
2935
+
2936
+ return rgba
2937
+
2938
+ def blend_layers(self, base, overlay):
2939
+ """Alpha blend two RGBA layers"""
2940
+ # Standard alpha blending formula
2941
+ alpha_overlay = overlay[:, :, 3:4]
2942
+ alpha_base = base[:, :, 3:4]
2943
+
2944
+ # Calculate output alpha
2945
+ alpha_out = alpha_overlay + alpha_base * (1 - alpha_overlay)
2946
+
2947
+ # Calculate output RGB
2948
+ rgb_out = np.zeros_like(base[:, :, :3])
2949
+ mask = alpha_out[:, :, 0] > 0
2950
+
2951
+ rgb_out[mask] = (overlay[mask, :3] * alpha_overlay[mask] +
2952
+ base[mask, :3] * alpha_base[mask] * (1 - alpha_overlay[mask])) / alpha_out[mask]
2953
+
2954
+ # Combine RGB and alpha
2955
+ result = np.zeros_like(base)
2956
+ result[:, :, :3] = rgb_out
2957
+ result[:, :, 3:4] = alpha_out
2958
+
2959
+ return result
2960
+
2961
+ def update_display_pan_mode(self):
2962
+ """Lightweight display update for pan preview mode"""
2963
+ if self.is_pan_preview and self.pan_background_image is not None:
2964
+ # Clear and setup axes
2965
+ self.ax.clear()
2966
+ self.ax.set_facecolor('black')
2967
+
2968
+ # Get dimensions
2969
+ height, width = self.pan_background_image.shape[:2]
2970
+
2971
+ # Display the composite background with preserved zoom
2972
+ self.ax.imshow(self.pan_background_image,
2973
+ extent=(-0.5, width-0.5, height-0.5, -0.5),
2974
+ aspect='equal')
2975
+
2976
+ # Restore the zoom state from when pan began
2977
+ if hasattr(self, 'pan_zoom_state'):
2978
+ self.ax.set_xlim(self.pan_zoom_state[0])
2979
+ self.ax.set_ylim(self.pan_zoom_state[1])
2980
+
2981
+ # Style the axes (same as update_display)
2982
+ self.ax.set_xlabel('X')
2983
+ self.ax.set_ylabel('Y')
2984
+ self.ax.set_title(f'Slice {self.current_slice}')
2985
+ self.ax.xaxis.label.set_color('black')
2986
+ self.ax.yaxis.label.set_color('black')
2987
+ self.ax.title.set_color('black')
2988
+ self.ax.tick_params(colors='black')
2989
+ for spine in self.ax.spines.values():
2990
+ spine.set_color('black')
2991
+
2992
+ # Add measurement points if they exist (same as update_display)
2993
+ for point in self.measurement_points:
2994
+ x1, y1, z1 = point['point1']
2995
+ x2, y2, z2 = point['point2']
2996
+ pair_idx = point['pair_index']
2997
+
2998
+ if z1 == self.current_slice:
2999
+ self.ax.plot(x1, y1, 'yo', markersize=8)
3000
+ self.ax.text(x1, y1+5, str(pair_idx),
3001
+ color='white', ha='center', va='bottom')
3002
+ if z2 == self.current_slice:
3003
+ self.ax.plot(x2, y2, 'yo', markersize=8)
3004
+ self.ax.text(x2, y2+5, str(pair_idx),
3005
+ color='white', ha='center', va='bottom')
3006
+
3007
+ if z1 == z2 == self.current_slice:
3008
+ self.ax.plot([x1, x2], [y1, y2], 'r--', alpha=0.5)
3009
+
3010
+ self.canvas.draw_idle()
2520
3011
 
2521
3012
  def on_mouse_release(self, event):
2522
- """Handle mouse release events."""
3013
+ """Handle mouse release events - OPTIMIZED VERSION."""
2523
3014
  if self.pan_mode:
3015
+ # Get current view limits before restoring channels
3016
+ current_xlim = self.ax.get_xlim()
3017
+ current_ylim = self.ax.get_ylim()
3018
+
2524
3019
  self.panning = False
2525
3020
  self.pan_start = None
2526
3021
  self.canvas.setCursor(Qt.CursorShape.OpenHandCursor)
3022
+
3023
+ # Restore previously visible channels
3024
+ self.channel_visible = self.pre_pan_channel_state.copy()
3025
+ self.is_pan_preview = False
3026
+ self.pan_background = None
3027
+
3028
+ # Update display with preserved zoom at new position
3029
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
2527
3030
  elif event.button == 1: # Left button release
2528
3031
  if self.selecting and self.selection_rect is not None:
2529
3032
  # Get the rectangle bounds
@@ -2582,18 +3085,15 @@ class ImageViewerWindow(QMainWindow):
2582
3085
  # Try to highlight the last selected value in tables
2583
3086
  if self.clicked_values['edges']:
2584
3087
  self.highlight_value_in_tables(self.clicked_values['edges'][-1])
2585
-
2586
3088
 
2587
3089
  elif not self.selecting and self.selection_start: # If we had a click but never started selection
2588
3090
  # Handle as a normal click
2589
3091
  self.on_mouse_click(event)
2590
-
2591
3092
 
2592
- # Clean up
3093
+ # Clean up selection
2593
3094
  self.selection_start = None
2594
3095
  self.selecting = False
2595
3096
 
2596
-
2597
3097
  if self.selection_rect is not None:
2598
3098
  try:
2599
3099
  self.selection_rect.remove()
@@ -2602,13 +3102,28 @@ class ImageViewerWindow(QMainWindow):
2602
3102
  self.selection_rect = None
2603
3103
  self.canvas.draw()
2604
3104
 
2605
- if self.brush_mode:
3105
+ # OPTIMIZED: Handle brush mode cleanup with paint session management
3106
+ if self.brush_mode and hasattr(self, 'painting') and self.painting:
2606
3107
  self.painting = False
3108
+
3109
+ # Restore hidden channels
2607
3110
  try:
2608
3111
  for i in self.restore_channels:
2609
3112
  self.channel_visible[i] = True
3113
+ self.restore_channels = []
2610
3114
  except:
2611
3115
  pass
3116
+
3117
+ # OPTIMIZED: End paint session and ensure all operations complete
3118
+ self.end_paint_session()
3119
+
3120
+ # OPTIMIZED: Stop timer and process any pending paint operations
3121
+ if hasattr(self, 'paint_timer'):
3122
+ self.paint_timer.stop()
3123
+ if hasattr(self, 'pending_paint_update') and self.pending_paint_update:
3124
+ self.flush_paint_updates()
3125
+
3126
+ # Get current zoom and do final display update
2612
3127
  current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2613
3128
  current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2614
3129
  self.update_display(preserve_zoom=(current_xlim, current_ylim))
@@ -2883,6 +3398,8 @@ class ImageViewerWindow(QMainWindow):
2883
3398
  load_action.triggered.connect(lambda: self.load_misc('Edge Centroids'))
2884
3399
  load_action = misc_menu.addAction("Merge Nodes")
2885
3400
  load_action.triggered.connect(lambda: self.load_misc('Merge Nodes'))
3401
+ load_action = misc_menu.addAction("Merge Node IDs from Images")
3402
+ load_action.triggered.connect(self.show_merge_node_id_dialog)
2886
3403
 
2887
3404
 
2888
3405
  # Analysis menu
@@ -3264,6 +3781,11 @@ class ImageViewerWindow(QMainWindow):
3264
3781
  except:
3265
3782
  pass
3266
3783
 
3784
+ def show_merge_node_id_dialog(self):
3785
+
3786
+ dialog = MergeNodeIdDialog(self)
3787
+ dialog.exec()
3788
+
3267
3789
 
3268
3790
  def show_watershed_dialog(self):
3269
3791
  """Show the watershed parameter dialog."""
@@ -3582,7 +4104,7 @@ class ImageViewerWindow(QMainWindow):
3582
4104
  f"Failed to load {sort}: {str(e)}"
3583
4105
  )
3584
4106
 
3585
- else:
4107
+ elif sort == 'Merge Nodes':
3586
4108
  try:
3587
4109
 
3588
4110
  if len(np.unique(my_network.nodes)) < 3:
@@ -3620,7 +4142,7 @@ class ImageViewerWindow(QMainWindow):
3620
4142
  if dialog.exec() == QFileDialog.DialogCode.Accepted:
3621
4143
  selected_path = dialog.directory().absolutePath()
3622
4144
 
3623
- my_network.merge_nodes(selected_path)
4145
+ my_network.merge_nodes(selected_path, root_id = self.node_name)
3624
4146
  self.load_channel(0, my_network.nodes, True)
3625
4147
 
3626
4148
 
@@ -3638,6 +4160,7 @@ class ImageViewerWindow(QMainWindow):
3638
4160
  )
3639
4161
 
3640
4162
 
4163
+
3641
4164
  # Modify load_from_network_obj method
3642
4165
  def load_from_network_obj(self):
3643
4166
  try:
@@ -3949,11 +4472,15 @@ class ImageViewerWindow(QMainWindow):
3949
4472
  return
3950
4473
 
3951
4474
  file_extension = filename.lower().split('.')[-1]
4475
+
4476
+ if channel_index == 0:
4477
+ self.node_name = filename
3952
4478
 
3953
4479
  try:
3954
4480
  if file_extension in ['tif', 'tiff']:
3955
4481
  import tifffile
3956
4482
  self.channel_data[channel_index] = tifffile.imread(filename)
4483
+
3957
4484
 
3958
4485
  elif file_extension == 'nii':
3959
4486
  import nibabel as nib
@@ -4091,6 +4618,9 @@ class ImageViewerWindow(QMainWindow):
4091
4618
 
4092
4619
  self.shape = self.channel_data[channel_index].shape
4093
4620
 
4621
+ self.img_height, self.img_width = self.shape[1], self.shape[2]
4622
+
4623
+
4094
4624
  self.update_display(reset_resize = reset_resize)
4095
4625
 
4096
4626
 
@@ -4551,36 +5081,55 @@ class ImageViewerWindow(QMainWindow):
4551
5081
  import traceback
4552
5082
  print(traceback.format_exc())
4553
5083
 
4554
- def update_display_slice(self, channel, preserve_zoom=None):
4555
- """Ultra minimal update that only changes the paint channel's data"""
5084
+ def update_display_slice_optimized(self, channel, preserve_zoom=None):
5085
+ """Ultra minimal update that only changes the paint channel's data - OPTIMIZED VERSION"""
4556
5086
  if not self.channel_visible[channel]:
4557
5087
  return
4558
-
5088
+
4559
5089
  if preserve_zoom:
4560
5090
  current_xlim, current_ylim = preserve_zoom
4561
5091
  if current_xlim is not None and current_ylim is not None:
4562
5092
  self.ax.set_xlim(current_xlim)
4563
5093
  self.ax.set_ylim(current_ylim)
4564
-
4565
-
5094
+
4566
5095
  # Find the existing image for channel (paint channel)
4567
5096
  channel_image = None
4568
5097
  for img in self.ax.images:
4569
5098
  if img.cmap.name == f'custom_{channel}':
4570
5099
  channel_image = img
4571
5100
  break
4572
-
5101
+
4573
5102
  if channel_image is not None:
4574
- # Update the data of the existing image
4575
- channel_image.set_array(self.channel_data[channel][self.current_slice])
5103
+ # Update the data of the existing image with thread safety
5104
+ with self.paint_lock:
5105
+ channel_image.set_array(self.channel_data[channel][self.current_slice])
4576
5106
 
4577
5107
  # Restore the static background (all other channels) at current zoom level
4578
- self.canvas.restore_region(self.static_background)
4579
- # Draw just our paint channel
4580
- self.ax.draw_artist(channel_image)
4581
- # Blit everything
4582
- self.canvas.blit(self.ax.bbox)
4583
- self.canvas.flush_events()
5108
+ # This is the key - use static_background from update_display, not paint_background
5109
+ if hasattr(self, 'static_background') and self.static_background is not None:
5110
+ self.canvas.restore_region(self.static_background)
5111
+ # Draw just our paint channel
5112
+ self.ax.draw_artist(channel_image)
5113
+ # Blit everything
5114
+ self.canvas.blit(self.ax.bbox)
5115
+ self.canvas.flush_events()
5116
+ else:
5117
+ # Fallback to full draw if no static background
5118
+ self.canvas.draw()
5119
+ else:
5120
+ # Fallback if channel image not found
5121
+ self.canvas.draw()
5122
+
5123
+ def get_channel_image(self, channel):
5124
+ """Find the matplotlib image object for a specific channel."""
5125
+ if not hasattr(self.ax, 'images'):
5126
+ return None
5127
+
5128
+ for img in self.ax.images:
5129
+ if hasattr(img, 'cmap') and hasattr(img.cmap, 'name'):
5130
+ if img.cmap.name == f'custom_{channel}':
5131
+ return img
5132
+ return None
4584
5133
 
4585
5134
  def show_netshow_dialog(self):
4586
5135
  dialog = NetShowDialog(self)
@@ -5477,6 +6026,10 @@ class PropertiesDialog(QDialog):
5477
6026
  run_button.clicked.connect(self.run_properties)
5478
6027
  layout.addWidget(run_button)
5479
6028
 
6029
+ report_button = QPushButton("Report Properties (Show in Top Right Tables)")
6030
+ report_button.clicked.connect(self.report)
6031
+ layout.addWidget(report_button)
6032
+
5480
6033
  def check_checked(self, ques):
5481
6034
 
5482
6035
  if ques is None:
@@ -5514,6 +6067,30 @@ class PropertiesDialog(QDialog):
5514
6067
  except Exception as e:
5515
6068
  print(f"Error: {e}")
5516
6069
 
6070
+ def report(self):
6071
+
6072
+ try:
6073
+
6074
+ self.parent().format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity', 'Node Identities')
6075
+ except:
6076
+ pass
6077
+ try:
6078
+
6079
+ self.parent().format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
6080
+ except:
6081
+ pass
6082
+
6083
+ try:
6084
+ self.parent().format_for_upperright_table(my_network.edge_centroids, 'EdgeID', ['Z', 'Y', 'X'], 'Edge Centroids')
6085
+ except:
6086
+ pass
6087
+ try:
6088
+ self.parent().format_for_upperright_table(my_network.communities, 'NodeID', 'Community', 'Node Communities')
6089
+ except:
6090
+ pass
6091
+
6092
+
6093
+
5517
6094
  class BrightnessContrastDialog(QDialog):
5518
6095
  def __init__(self, parent=None):
5519
6096
  super().__init__(parent)
@@ -5893,6 +6470,72 @@ class ArbitraryDialog(QDialog):
5893
6470
  except Exception as e:
5894
6471
  QMessageBox.critical(self, "Error", f"Error processing selections: {str(e)}")
5895
6472
 
6473
+ class MergeNodeIdDialog(QDialog):
6474
+
6475
+ def __init__(self, parent=None):
6476
+ super().__init__(parent)
6477
+ self.setWindowTitle("Merging Node Identities From Folder Dialog.\nNote that you should prelabel or prewatershed your current node objects before doing this. (See Process -> Image) It does not label them for you.")
6478
+ self.setModal(True)
6479
+
6480
+ layout = QFormLayout(self)
6481
+
6482
+ self.search = QLineEdit("")
6483
+ layout.addRow("Step-out distance (from current nodes image - ignore if you dilated them previously or don't want):", self.search)
6484
+
6485
+ self.xy_scale = QLineEdit(f"{my_network.xy_scale}")
6486
+ layout.addRow("xy_scale:", self.xy_scale)
6487
+
6488
+ self.z_scale = QLineEdit(f"{my_network.z_scale}")
6489
+ layout.addRow("z_scale:", self.z_scale)
6490
+
6491
+ # Add Run button
6492
+ run_button = QPushButton("Get Directory")
6493
+ run_button.clicked.connect(self.run)
6494
+ layout.addWidget(run_button)
6495
+
6496
+ def run(self):
6497
+
6498
+ try:
6499
+
6500
+ search = float(self.search.text()) if self.search.text().strip() else 0
6501
+ xy_scale = float(self.xy_scale.text()) if self.xy_scale.text().strip() else 1
6502
+ z_scale = float(self.z_scale.text()) if self.z_scale.text().strip() else 1
6503
+
6504
+
6505
+ data = self.parent().channel_data[0]
6506
+
6507
+ if data is None:
6508
+ return
6509
+
6510
+
6511
+
6512
+ dialog = QFileDialog(self)
6513
+ dialog.setOption(QFileDialog.Option.DontUseNativeDialog)
6514
+ dialog.setOption(QFileDialog.Option.ReadOnly)
6515
+ dialog.setFileMode(QFileDialog.FileMode.Directory)
6516
+ dialog.setViewMode(QFileDialog.ViewMode.Detail)
6517
+
6518
+ if dialog.exec() == QFileDialog.DialogCode.Accepted:
6519
+ selected_path = dialog.directory().absolutePath()
6520
+
6521
+ if search > 0:
6522
+ data = sdl.smart_dilate(data, 1, 1, GPU = False, fast_dil = False, use_dt_dil_amount = search, xy_scale = xy_scale, z_scale = z_scale)
6523
+
6524
+ my_network.merge_node_ids(selected_path, data)
6525
+
6526
+ self.parent().format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity')
6527
+
6528
+ QMessageBox.critical(
6529
+ self,
6530
+ "Success",
6531
+ "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)"
6532
+ )
6533
+
6534
+ self.accept()
6535
+
6536
+ except Exception as e:
6537
+ print(f"Error: {e}")
6538
+
5896
6539
 
5897
6540
  class Show3dDialog(QDialog):
5898
6541
  def __init__(self, parent=None):
@@ -7015,6 +7658,10 @@ class RadDialog(QDialog):
7015
7658
  self.parent().radii_dict[0] = radii
7016
7659
  elif self.parent().active_channel == 1:
7017
7660
  self.parent().radii_dict[1] = radii
7661
+ elif self.parent().active_channel == 2:
7662
+ self.parent().radii_dict[2] = radii
7663
+ elif self.parent().active_channel == 3:
7664
+ self.parent().radii_dict[3] = radii
7018
7665
 
7019
7666
  self.parent().format_for_upperright_table(radii, title = 'Largest Radii of Objects', metric='ObjectID', value='Largest Radius (Scaled)')
7020
7667
 
@@ -7961,7 +8608,7 @@ class ThresholdDialog(QDialog):
7961
8608
 
7962
8609
  # Add mode selection dropdown
7963
8610
  self.mode_selector = QComboBox()
7964
- self.mode_selector.addItems(["Using Label/Brightness", "Using Volumes"])
8611
+ self.mode_selector.addItems(["Using Label/Brightness", "Using Volumes", "Using Radii", "Using Node Degree"])
7965
8612
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
7966
8613
  layout.addRow("Execution Mode:", self.mode_selector)
7967
8614
 
@@ -7994,6 +8641,22 @@ class ThresholdDialog(QDialog):
7994
8641
  if self.parent().volume_dict[self.parent().active_channel] is None:
7995
8642
  self.parent().volumes()
7996
8643
 
8644
+ elif accepted_mode == 2:
8645
+ if len(np.unique(self.parent().channel_data[self.parent().active_channel])) < 3:
8646
+ self.parent().show_label_dialog()
8647
+
8648
+ if self.parent().radii_dict[self.parent().active_channel] is None:
8649
+ self.parent().show_rad_dialog()
8650
+
8651
+ if self.parent().radii_dict[self.parent().active_channel] is None:
8652
+ return
8653
+
8654
+ elif accepted_mode == 3:
8655
+
8656
+ if my_network.nodes is None or my_network.network is None:
8657
+ print("Error - please calculate network first")
8658
+ return
8659
+
7997
8660
  if self.parent().mini_overlay_data is not None:
7998
8661
  self.parent().mini_overlay_data = None
7999
8662
 
@@ -8002,6 +8665,8 @@ class ThresholdDialog(QDialog):
8002
8665
  self.highlight_overlay = None
8003
8666
  self.accept()
8004
8667
  except:
8668
+ import traceback
8669
+ traceback.print_exc()
8005
8670
  pass
8006
8671
 
8007
8672
  def start_ml(self, GPU = False):
@@ -8290,7 +8955,7 @@ class MachineWindow(QMainWindow):
8290
8955
  if not GPU:
8291
8956
  self.segmenter = segmenter.InteractiveSegmenter(active_data, use_gpu=False)
8292
8957
  else:
8293
- self.segmenter = segmenter_GPU.InteractiveSegmenter(active_data)
8958
+ self.segmenter = seg_GPU.InteractiveSegmenter(active_data)
8294
8959
 
8295
8960
  self.segmentation_worker = None
8296
8961
 
@@ -8396,7 +9061,7 @@ class MachineWindow(QMainWindow):
8396
9061
  if self.GPU.isChecked():
8397
9062
 
8398
9063
  try:
8399
- self.segmenter = segmenter_GPU.InteractiveSegmenter(active_data)
9064
+ self.segmenter = seg_GPU.InteractiveSegmenter(active_data)
8400
9065
  print("Using GPU")
8401
9066
  except:
8402
9067
  self.GPU.setChecked(False)
@@ -8693,7 +9358,11 @@ class MachineWindow(QMainWindow):
8693
9358
 
8694
9359
  print("Segmenting entire volume with model...")
8695
9360
  #foreground_coords, background_coords = self.segmenter.segment_volume(array = self.parent().highlight_overlay)
8696
- self.parent().highlight_overlay = self.segmenter.segment_volume(array = self.parent().highlight_overlay)
9361
+ try:
9362
+ self.parent().highlight_overlay = self.segmenter.segment_volume(array = self.parent().highlight_overlay)
9363
+ except Exception as e:
9364
+ print(f"Error segmenting (Perhaps retrain the model...): {e}")
9365
+ return
8697
9366
 
8698
9367
  # Clean up when done
8699
9368
  self.segmenter.cleanup()
@@ -8714,23 +9383,27 @@ class MachineWindow(QMainWindow):
8714
9383
  print("Finished segmentation moved to Overlay 2. Use File -> Save(As) for disk saving.")
8715
9384
 
8716
9385
  def closeEvent(self, event):
8717
- if self.parent().isVisible():
8718
- if self.confirm_close_dialog():
8719
- # Clean up resources before closing
8720
- if self.brush_button.isChecked():
8721
- self.silence_button()
8722
- self.toggle_brush_mode()
8723
-
8724
- self.parent().pen_button.setEnabled(True)
8725
- self.parent().brush_mode = False
8726
-
8727
- # Kill the segmentation thread and wait for it to finish
8728
- self.kill_segmentation()
8729
- time.sleep(0.2) # Give additional time for cleanup
8730
-
8731
- self.parent().machine_window = None
8732
- else:
8733
- event.ignore()
9386
+
9387
+ try:
9388
+ if self.parent().isVisible():
9389
+ if self.confirm_close_dialog():
9390
+ # Clean up resources before closing
9391
+ if self.brush_button.isChecked():
9392
+ self.silence_button()
9393
+ self.toggle_brush_mode()
9394
+
9395
+ self.parent().pen_button.setEnabled(True)
9396
+ self.parent().brush_mode = False
9397
+
9398
+ # Kill the segmentation thread and wait for it to finish
9399
+ self.kill_segmentation()
9400
+ time.sleep(0.2) # Give additional time for cleanup
9401
+
9402
+ self.parent().machine_window = None
9403
+ else:
9404
+ event.ignore()
9405
+ except:
9406
+ pass
8734
9407
 
8735
9408
 
8736
9409
 
@@ -8843,6 +9516,8 @@ class ThresholdWindow(QMainWindow):
8843
9516
  def __init__(self, parent=None, accepted_mode=0):
8844
9517
  super().__init__(parent)
8845
9518
  self.setWindowTitle("Threshold")
9519
+
9520
+ self.accepted_mode = accepted_mode
8846
9521
 
8847
9522
  # Create central widget and layout
8848
9523
  central_widget = QWidget()
@@ -8854,6 +9529,27 @@ class ThresholdWindow(QMainWindow):
8854
9529
  self.histo_list = list(self.parent().volume_dict[self.parent().active_channel].values())
8855
9530
  self.bounds = False
8856
9531
  self.parent().bounds = False
9532
+ elif accepted_mode == 2:
9533
+ self.histo_list = list(self.parent().radii_dict[self.parent().active_channel].values())
9534
+ self.bounds = False
9535
+ self.parent().bounds = False
9536
+ elif accepted_mode == 3:
9537
+ self.parent().degree_dict = {}
9538
+ self.parent().set_active_channel(0)
9539
+ nodes = list(my_network.network.nodes())
9540
+ img_nodes = list(np.unique(my_network.nodes))
9541
+ if 0 in img_nodes:
9542
+ del img_nodes[0]
9543
+ for node in img_nodes:
9544
+ if node in nodes:
9545
+ self.parent().degree_dict[int(node)] = my_network.network.degree(node)
9546
+ else:
9547
+ self.parent().degree_dict[int(node)] = 0
9548
+
9549
+ self.histo_list = list(self.parent().degree_dict.values())
9550
+ self.bounds = False
9551
+ self.parent().bounds = False
9552
+
8857
9553
  elif accepted_mode == 0:
8858
9554
  targ_shape = self.parent().channel_data[self.parent().active_channel].shape
8859
9555
  if (targ_shape[0] + targ_shape[1] + targ_shape[2]) > 2500: #Take a simpler histogram on big arrays
@@ -8973,17 +9669,35 @@ class ThresholdWindow(QMainWindow):
8973
9669
 
8974
9670
  def get_values_in_range_all_vols(self, chan, min_val, max_val):
8975
9671
  output = []
8976
- for node, vol in self.parent().volume_dict[chan].items():
8977
- if min_val <= vol <= max_val:
8978
- output.append(node)
9672
+ if self.accepted_mode == 1:
9673
+ for node, vol in self.parent().volume_dict[chan].items():
9674
+ if min_val <= vol <= max_val:
9675
+ output.append(node)
9676
+ elif self.accepted_mode == 2:
9677
+ for node, vol in self.parent().radii_dict[chan].items():
9678
+ if min_val <= vol <= max_val:
9679
+ output.append(node)
9680
+ elif self.accepted_mode == 3:
9681
+ for node, vol in self.parent().degree_dict.items():
9682
+ if min_val <= vol <= max_val:
9683
+ output.append(node)
8979
9684
  return output
8980
9685
 
8981
9686
  def get_values_in_range(self, lst, min_val, max_val):
8982
9687
  values = [x for x in lst if min_val <= x <= max_val]
8983
9688
  output = []
8984
- for item in self.parent().volume_dict[self.parent().active_channel]:
8985
- if self.parent().volume_dict[self.parent().active_channel][item] in values:
8986
- output.append(item)
9689
+ if self.accepted_mode == 1:
9690
+ for item in self.parent().volume_dict[self.parent().active_channel]:
9691
+ if self.parent().volume_dict[self.parent().active_channel][item] in values:
9692
+ output.append(item)
9693
+ elif self.accepted_mode == 2:
9694
+ for item in self.parent().radii_dict[self.parent().active_channel]:
9695
+ if self.parent().radii_dict[self.parent().active_channel][item] in values:
9696
+ output.append(item)
9697
+ elif self.accepted_mode == 3:
9698
+ for item in self.parent().degree_dict:
9699
+ if self.parent().degree_dict[item] in values:
9700
+ output.append(item)
8987
9701
  return output
8988
9702
 
8989
9703
 
@@ -9418,15 +10132,20 @@ class HoleDialog(QDialog):
9418
10132
  # auto checkbox (default True)
9419
10133
  self.headon = QPushButton("Head-on")
9420
10134
  self.headon.setCheckable(True)
9421
- self.headon.setChecked(False)
10135
+ self.headon.setChecked(True)
9422
10136
  layout.addRow("Only Use 2D Slicing Dimension:", self.headon)
9423
10137
 
9424
10138
  # auto checkbox (default True)
9425
10139
  self.borders = QPushButton("Borders")
9426
10140
  self.borders.setCheckable(True)
9427
- self.borders.setChecked(True)
10141
+ self.borders.setChecked(False)
9428
10142
  layout.addRow("Fill Small Holes Along Borders:", self.borders)
9429
10143
 
10144
+ self.sep_holes = QPushButton("Seperate Hole Mask")
10145
+ self.sep_holes.setCheckable(True)
10146
+ self.sep_holes.setChecked(False)
10147
+ layout.addRow("Place Hole Mask in Overlay 2 (Instead of Filling):", self.sep_holes)
10148
+
9430
10149
  # Add Run button
9431
10150
  run_button = QPushButton("Run Fill Holes")
9432
10151
  run_button.clicked.connect(self.run_holes)
@@ -9443,6 +10162,7 @@ class HoleDialog(QDialog):
9443
10162
 
9444
10163
  borders = self.borders.isChecked()
9445
10164
  headon = self.headon.isChecked()
10165
+ sep_holes = self.sep_holes.isChecked()
9446
10166
 
9447
10167
  # Call dilate method with parameters
9448
10168
  result = n3d.fill_holes_3d(
@@ -9451,7 +10171,11 @@ class HoleDialog(QDialog):
9451
10171
  fill_borders = borders
9452
10172
  )
9453
10173
 
9454
- self.parent().load_channel(self.parent().active_channel, result, True)
10174
+ if not sep_holes:
10175
+ self.parent().load_channel(self.parent().active_channel, result, True)
10176
+ else:
10177
+ self.parent().load_channel(3, active_data - result, True)
10178
+
9455
10179
 
9456
10180
  self.parent().update_display()
9457
10181
  self.accept()
@@ -10034,7 +10758,7 @@ class CentroidNodeDialog(QDialog):
10034
10758
 
10035
10759
  else:
10036
10760
 
10037
- my_network.nodes, my_network.centroids = my_network.centroid_array(clip = True)
10761
+ my_network.nodes, my_network.node_centroids = my_network.centroid_array(clip = True)
10038
10762
 
10039
10763
  self.parent().format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
10040
10764
 
@@ -10312,6 +11036,12 @@ class BranchDialog(QDialog):
10312
11036
  self.fix2.setChecked(True)
10313
11037
  correction_layout.addWidget(QLabel("Auto-Correct Branches by Collapsing Internal Labels: "), 3, 0)
10314
11038
  correction_layout.addWidget(self.fix2, 3, 1)
11039
+
11040
+ self.fix3 = QPushButton("Split Nontouching Branches?")
11041
+ self.fix3.setCheckable(True)
11042
+ self.fix3.setChecked(True)
11043
+ correction_layout.addWidget(QLabel("Split Nontouching Branches? (Useful if branch pruning - may want to threshold out small, split branches after): "), 4, 0)
11044
+ correction_layout.addWidget(self.fix3, 4, 1)
10315
11045
 
10316
11046
  correction_group.setLayout(correction_layout)
10317
11047
  main_layout.addWidget(correction_group)
@@ -10382,6 +11112,7 @@ class BranchDialog(QDialog):
10382
11112
  cubic = self.cubic.isChecked()
10383
11113
  fix = self.fix.isChecked()
10384
11114
  fix2 = self.fix2.isChecked()
11115
+ fix3 = self.fix3.isChecked()
10385
11116
  fix_val = float(self.fix_val.text()) if self.fix_val.text() else None
10386
11117
  seed = int(self.seed.text()) if self.seed.text() else None
10387
11118
 
@@ -10431,6 +11162,10 @@ class BranchDialog(QDialog):
10431
11162
 
10432
11163
  output = temp_network.nodes
10433
11164
 
11165
+ if fix3:
11166
+
11167
+ output = self.parent().separate_nontouching_objects(output, max_val=np.max(output))
11168
+
10434
11169
 
10435
11170
  if down_factor is not None:
10436
11171
 
@@ -10943,11 +11678,11 @@ class CalcAllDialog(QDialog):
10943
11678
 
10944
11679
  self.search = QLineEdit(self.prev_search)
10945
11680
  self.search.setPlaceholderText("Leave empty for None")
10946
- important_layout.addRow("Node Search (float):", self.search)
11681
+ important_layout.addRow("Node Search (float - Does not merge nodes):", self.search)
10947
11682
 
10948
11683
  self.diledge = QLineEdit(self.prev_diledge)
10949
11684
  self.diledge.setPlaceholderText("Leave empty for None")
10950
- important_layout.addRow("Edge Reconnection Distance (float):", self.diledge)
11685
+ important_layout.addRow("Edge Search (float - Note that edges that find each other will merge):", self.diledge)
10951
11686
 
10952
11687
  self.label_nodes = QPushButton("Label")
10953
11688
  self.label_nodes.setCheckable(True)