nettracer3d 0.7.9__py3-none-any.whl → 0.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,9 @@ try:
30
30
  except:
31
31
  pass
32
32
  from nettracer3d import excelotron
33
+ import threading
34
+ import queue
35
+ from threading import Lock
33
36
 
34
37
 
35
38
 
@@ -44,6 +47,7 @@ class ImageViewerWindow(QMainWindow):
44
47
  self.channel_visible = [False] * 4
45
48
  self.current_slice = 0
46
49
  self.active_channel = 0 # Initialize active channel
50
+ self.node_name = "Root_Nodes"
47
51
 
48
52
  self.color_dictionary = {
49
53
  # Reds
@@ -108,6 +112,9 @@ class ImageViewerWindow(QMainWindow):
108
112
  self.selection_rect = None
109
113
  self.click_start_time = None # Add this to track when click started
110
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
111
118
 
112
119
  # Initialize zoom mode state
113
120
  self.zoom_mode = False
@@ -119,6 +126,15 @@ class ImageViewerWindow(QMainWindow):
119
126
  self.pan_mode = False
120
127
  self.panning = False
121
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
122
138
 
123
139
  #For ML segmenting mode
124
140
  self.brush_mode = False
@@ -157,7 +173,9 @@ class ImageViewerWindow(QMainWindow):
157
173
 
158
174
  self.radii_dict = {
159
175
  0: None,
160
- 1: None
176
+ 1: None,
177
+ 2: None,
178
+ 3: None
161
179
  }
162
180
 
163
181
  self.original_shape = None #For undoing resamples
@@ -423,6 +441,25 @@ class ImageViewerWindow(QMainWindow):
423
441
  self.excel_manager.data_received.connect(self.handle_excel_data)
424
442
  self.prev_coms = None
425
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
+
426
463
  def start_left_scroll(self):
427
464
  """Start scrolling left when left arrow is pressed."""
428
465
  # Single increment first
@@ -794,6 +831,9 @@ class ImageViewerWindow(QMainWindow):
794
831
  try:
795
832
  # Create context menu
796
833
  context_menu = QMenu(self)
834
+
835
+ find = context_menu.addAction("Find Node/Edge")
836
+ find.triggered.connect(self.handle_find)
797
837
 
798
838
  # Create "Show Neighbors" submenu
799
839
  neighbors_menu = QMenu("Show Neighbors", self)
@@ -1450,6 +1490,109 @@ class ImageViewerWindow(QMainWindow):
1450
1490
  except Exception as e:
1451
1491
  print(f"Error showing identities: {e}")
1452
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
+
1453
1596
 
1454
1597
  def handle_select_all(self, nodes = True, edges = False):
1455
1598
 
@@ -1630,63 +1773,32 @@ class ImageViewerWindow(QMainWindow):
1630
1773
  except Exception as e:
1631
1774
  print(f"An error has occured: {e}")
1632
1775
 
1633
- 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")
1782
+
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
1634
1800
 
1635
- import scipy.ndimage as ndi
1636
- from scipy.sparse import csr_matrix
1637
-
1638
- print("Note, this method is a tad slow...")
1639
-
1640
- def separate_nontouching_objects(input_array, max_val = 0):
1641
- """
1642
- Efficiently separate non-touching objects in a labeled array.
1643
-
1644
- Parameters:
1645
- -----------
1646
- input_array : numpy.ndarray
1647
- Input labeled array where each object has a unique label value > 0
1648
-
1649
- Returns:
1650
- --------
1651
- output_array : numpy.ndarray
1652
- Array with new labels where non-touching components have different labels
1653
- """
1654
- # Step 1: Perform connected component labeling on the entire binary mask
1655
- binary_mask = input_array > 0
1656
- structure = np.ones((3,) * input_array.ndim, dtype=bool) # 3x3x3 connectivity for 3D or 3x3 for 2D
1657
- labeled_array, num_features = ndi.label(binary_mask, structure=structure)
1658
-
1659
- # Step 2: Map the original labels to the new connected components
1660
- # Create a sparse matrix to efficiently store label mappings
1661
- coords = np.nonzero(input_array)
1662
- original_values = input_array[coords]
1663
- new_labels = labeled_array[coords]
1664
-
1665
- # Create a mapping of (original_label, new_connected_component) pairs
1666
- label_mapping = {}
1667
- for orig, new in zip(original_values, new_labels):
1668
- if orig not in label_mapping:
1669
- label_mapping[orig] = []
1670
- if new not in label_mapping[orig]:
1671
- label_mapping[orig].append(new)
1672
-
1673
- # Step 3: Create a new output array with unique labels for each connected component
1674
- output_array = np.zeros_like(input_array)
1675
- next_label = 1 + max_val
1676
-
1677
- # Map of (original_label, connected_component) -> new_unique_label
1678
- unique_label_map = {}
1679
-
1680
- for orig_label, cc_list in label_mapping.items():
1681
- for cc in cc_list:
1682
- unique_label_map[(orig_label, cc)] = next_label
1683
- # Create a mask for this original label and connected component
1684
- mask = (input_array == orig_label) & (labeled_array == cc)
1685
- # Assign the new unique label
1686
- output_array[mask] = next_label
1687
- next_label += 1
1688
-
1689
- return output_array
1801
+ def handle_seperate(self):
1690
1802
 
1691
1803
  try:
1692
1804
  # Handle nodes
@@ -1708,7 +1820,7 @@ class ImageViewerWindow(QMainWindow):
1708
1820
  max_val = np.max(non_highlighted)
1709
1821
 
1710
1822
  # Process highlighted part
1711
- processed_highlights = separate_nontouching_objects(highlighted_nodes, max_val)
1823
+ processed_highlights = self.separate_nontouching_objects(highlighted_nodes, max_val)
1712
1824
 
1713
1825
  # Combine back with non-highlighted parts
1714
1826
  my_network.nodes = non_highlighted + processed_highlights
@@ -1729,13 +1841,13 @@ class ImageViewerWindow(QMainWindow):
1729
1841
  # Get non-highlighted part of the array
1730
1842
  non_highlighted = my_network.edges * (~self.highlight_overlay)
1731
1843
 
1732
- if (highlighted_nodes==non_highlighted).all():
1844
+ if (highlighted_edges==non_highlighted).all():
1733
1845
  max_val = 0
1734
1846
  else:
1735
1847
  max_val = np.max(non_highlighted)
1736
1848
 
1737
1849
  # Process highlighted part
1738
- processed_highlights = separate_nontouching_objects(highlighted_edges, max_val)
1850
+ processed_highlights = self.separate_nontouching_objects(highlighted_edges, max_val)
1739
1851
 
1740
1852
  # Combine back with non-highlighted parts
1741
1853
  my_network.edges = non_highlighted + processed_highlights
@@ -2074,8 +2186,11 @@ class ImageViewerWindow(QMainWindow):
2074
2186
 
2075
2187
  self.update_display(preserve_zoom=(current_xlim, current_ylim))
2076
2188
 
2189
+
2077
2190
  def keyPressEvent(self, event):
2078
2191
 
2192
+ """Key press shortcuts for main class"""
2193
+
2079
2194
  if event.key() == Qt.Key_Z and event.modifiers() & Qt.ControlModifier:
2080
2195
  try:
2081
2196
  self.load_channel(self.last_change[1], self.last_change[0], True)
@@ -2091,6 +2206,8 @@ class ImageViewerWindow(QMainWindow):
2091
2206
  self.machine_window.switch_foreground()
2092
2207
  if event.key() == Qt.Key_X:
2093
2208
  self.high_button.click()
2209
+ if event.key() == Qt.Key_F and event.modifiers() == Qt.ShiftModifier:
2210
+ self.handle_find()
2094
2211
  if self.brush_mode and self.machine_window is None:
2095
2212
  if event.key() == Qt.Key_F:
2096
2213
  self.toggle_can()
@@ -2098,6 +2215,7 @@ class ImageViewerWindow(QMainWindow):
2098
2215
  self.toggle_threed()
2099
2216
 
2100
2217
 
2218
+
2101
2219
  def update_brush_cursor(self):
2102
2220
  """Update the cursor to show brush size"""
2103
2221
  if not self.brush_mode:
@@ -2158,7 +2276,7 @@ class ImageViewerWindow(QMainWindow):
2158
2276
  painter.end()
2159
2277
 
2160
2278
  def get_line_points(self, x0, y0, x1, y1):
2161
- """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."""
2162
2280
  points = []
2163
2281
  dx = abs(x1 - x0)
2164
2282
  dy = abs(y1 - y0)
@@ -2211,7 +2329,7 @@ class ImageViewerWindow(QMainWindow):
2211
2329
  return data_coords[0], data_coords[1]
2212
2330
 
2213
2331
  def on_mouse_press(self, event):
2214
- """Handle mouse press events."""
2332
+ """Handle mouse press events - OPTIMIZED VERSION."""
2215
2333
  if event.inaxes != self.ax:
2216
2334
  return
2217
2335
 
@@ -2249,7 +2367,6 @@ class ImageViewerWindow(QMainWindow):
2249
2367
  new_xlim = [xdata - x_range, xdata + x_range]
2250
2368
  new_ylim = [ydata - y_range, ydata + y_range]
2251
2369
 
2252
-
2253
2370
  if (new_xlim[0] <= self.original_xlim[0] or
2254
2371
  new_xlim[1] >= self.original_xlim[1] or
2255
2372
  new_ylim[0] <= self.original_ylim[0] or
@@ -2271,22 +2388,31 @@ class ImageViewerWindow(QMainWindow):
2271
2388
  self.panning = True
2272
2389
  self.pan_start = (event.xdata, event.ydata)
2273
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()
2274
2404
 
2275
2405
  elif self.brush_mode:
2276
2406
  if event.inaxes != self.ax:
2277
2407
  return
2278
2408
 
2279
-
2280
2409
  if event.button == 1 or event.button == 3:
2281
-
2282
2410
  x, y = int(event.xdata), int(event.ydata)
2283
2411
 
2284
-
2285
2412
  if event.button == 1 and self.can:
2286
2413
  self.handle_can(x, y)
2287
2414
  return
2288
2415
 
2289
-
2290
2416
  if event.button == 3:
2291
2417
  self.erase = True
2292
2418
  else:
@@ -2300,27 +2426,24 @@ class ImageViewerWindow(QMainWindow):
2300
2426
  else:
2301
2427
  channel = 2
2302
2428
 
2303
- # Paint at initial position
2304
- self.paint_at_position(x, y, self.erase, channel)
2305
-
2429
+ # Get current zoom to preserve it
2306
2430
  current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2307
2431
  current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2308
2432
 
2309
-
2433
+ # Paint at initial position
2434
+ self.paint_at_position(x, y, self.erase, channel)
2435
+
2310
2436
  self.canvas.draw()
2311
- #self.update_display(preserve_zoom=(current_xlim, current_ylim))
2312
- self.restore_channels = []
2313
2437
 
2438
+ self.restore_channels = []
2439
+ if not self.channel_visible[channel]:
2440
+ self.channel_visible[channel] = True
2314
2441
 
2315
- for i in range(4):
2316
- if i == channel:
2317
- self.channel_visible[i] = True
2318
- elif self.channel_data[i] is not None and self.channel_visible[i] == True:
2319
- self.channel_visible[i] = False
2320
- self.restore_channels.append(i)
2321
- self.update_display(preserve_zoom = (current_xlim, current_ylim), begin_paint = True)
2322
- self.update_display_slice(channel, preserve_zoom=(current_xlim, current_ylim))
2323
-
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))
2324
2447
 
2325
2448
  elif event.button == 3: # Right click (for context menu)
2326
2449
  self.create_context_menu(event)
@@ -2334,7 +2457,7 @@ class ImageViewerWindow(QMainWindow):
2334
2457
  """Paint pixels within brush radius at given position"""
2335
2458
  if self.channel_data[channel] is None:
2336
2459
  return
2337
-
2460
+
2338
2461
  if erase:
2339
2462
  val = 0
2340
2463
  elif self.machine_window is None:
@@ -2343,26 +2466,74 @@ class ImageViewerWindow(QMainWindow):
2343
2466
  val = 1
2344
2467
  else:
2345
2468
  val = 2
2346
-
2347
2469
  height, width = self.channel_data[channel][self.current_slice].shape
2348
2470
  radius = self.brush_size // 2
2349
-
2471
+
2350
2472
  # Calculate brush area
2351
2473
  for y in range(max(0, center_y - radius), min(height, center_y + radius + 1)):
2352
2474
  for x in range(max(0, center_x - radius), min(width, center_x + radius + 1)):
2353
2475
  # Check if point is within circular brush area
2354
- if (x - center_x) ** 2 + (y - center_y) ** 2 <= radius ** 2:
2355
-
2476
+ if (x - center_x) * 2 + (y - center_y) * 2 <= radius ** 2:
2356
2477
  if self.threed and self.threedthresh > 1:
2357
2478
  amount = (self.threedthresh - 1) / 2
2358
2479
  low = max(0, self.current_slice - amount)
2359
2480
  high = min(self.channel_data[channel].shape[0] - 1, self.current_slice + amount)
2360
-
2361
2481
  for i in range(int(low), int(high + 1)):
2362
2482
  self.channel_data[channel][i][y, x] = val
2363
2483
  else:
2364
2484
  self.channel_data[channel][self.current_slice][y, x] = val
2365
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
+
2366
2537
  def handle_can(self, x, y):
2367
2538
 
2368
2539
 
@@ -2425,33 +2596,42 @@ class ImageViewerWindow(QMainWindow):
2425
2596
 
2426
2597
 
2427
2598
  def on_mouse_move(self, event):
2428
- """Handle mouse movement events."""
2429
- if event.inaxes != self.ax:
2599
+ if not event.inaxes or event.xdata is None or event.ydata is None:
2430
2600
  return
2431
-
2601
+
2602
+ current_time = time.time()
2603
+
2432
2604
  if self.selection_start and not self.selecting and not self.pan_mode and not self.zoom_mode and not self.brush_mode:
2433
- # If mouse has moved more than a tiny amount while button is held, start selection
2434
2605
  if (abs(event.xdata - self.selection_start[0]) > 1 or
2435
2606
  abs(event.ydata - self.selection_start[1]) > 1):
2436
2607
  self.selecting = True
2608
+ self.background = self.canvas.copy_from_bbox(self.ax.bbox)
2609
+
2437
2610
  self.selection_rect = plt.Rectangle(
2438
2611
  (self.selection_start[0], self.selection_start[1]), 0, 0,
2439
- fill=False, color='white', linestyle='--'
2612
+ fill=False, color='white', linestyle='--', animated=True
2440
2613
  )
2441
2614
  self.ax.add_patch(self.selection_rect)
2442
2615
 
2443
2616
  if self.selecting and self.selection_rect is not None:
2444
- # Update selection rectangle
2445
- x0 = min(self.selection_start[0], event.xdata)
2446
- 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)
2447
2627
  width = abs(event.xdata - self.selection_start[0])
2448
2628
  height = abs(event.ydata - self.selection_start[1])
2449
2629
 
2450
- self.selection_rect.set_bounds(x0, y0, width, height)
2451
- 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)
2452
2633
 
2453
2634
  elif self.panning and self.pan_start is not None:
2454
-
2455
2635
  # Calculate the movement
2456
2636
  dx = event.xdata - self.pan_start[0]
2457
2637
  dy = event.ydata - self.pan_start[1]
@@ -2464,25 +2644,23 @@ class ImageViewerWindow(QMainWindow):
2464
2644
  new_xlim = [xlim[0] - dx, xlim[1] - dx]
2465
2645
  new_ylim = [ylim[0] - dy, ylim[1] - dy]
2466
2646
 
2467
- # Get image bounds
2468
- if self.channel_data[0] is not None: # Use first channel as reference
2469
- img_height, img_width = self.channel_data[0][self.current_slice].shape
2470
-
2647
+ # Get image bounds using cached dimensions
2648
+ if self.img_width is not None: # Changed from self.channel_data[0] check
2471
2649
  # Ensure new limits don't go beyond image bounds
2472
2650
  if new_xlim[0] < 0:
2473
2651
  new_xlim = [0, xlim[1] - xlim[0]]
2474
- elif new_xlim[1] > img_width:
2475
- 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]
2476
2654
 
2477
2655
  if new_ylim[0] < 0:
2478
2656
  new_ylim = [0, ylim[1] - ylim[0]]
2479
- elif new_ylim[1] > img_height:
2480
- 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]
2481
2659
 
2482
2660
  # Apply new limits
2483
2661
  self.ax.set_xlim(new_xlim)
2484
2662
  self.ax.set_ylim(new_ylim)
2485
- self.canvas.draw()
2663
+ self.canvas.draw_idle() # Changed from draw() to draw_idle()
2486
2664
 
2487
2665
  # Update pan start position
2488
2666
  self.pan_start = (event.xdata, event.ydata)
@@ -2490,43 +2668,365 @@ class ImageViewerWindow(QMainWindow):
2490
2668
  elif self.painting and self.brush_mode:
2491
2669
  if event.inaxes != self.ax:
2492
2670
  return
2493
-
2494
- 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
2495
2679
 
2496
- if self.pen_button.isChecked():
2497
- channel = self.active_channel
2498
- else:
2499
- 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)
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
+ }
2709
+
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)
2500
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
2501
2722
 
2502
- if self.channel_data[channel] is not None:
2503
- current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2504
- current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2505
- height, width = self.channel_data[channel][self.current_slice].shape
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
2506
2740
 
2507
- if hasattr(self, 'last_paint_pos'):
2508
- last_x, last_y = self.last_paint_pos
2509
- points = self.get_line_points(last_x, last_y, x, y)
2741
+ with self.paint_lock:
2742
+ self.execute_paint_operation(paint_op)
2510
2743
 
2511
- # Paint at each point along the line
2744
+ except queue.Empty:
2745
+ continue
2746
+
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
+ )
2512
2782
 
2513
- for px, py in points:
2514
- if 0 <= px < width and 0 <= py < height:
2515
- self.paint_at_position(px, py, self.erase, channel)
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):
2516
2826
 
2517
- 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)
2518
2829
 
2519
- self.canvas.draw()
2520
- #self.update_display(preserve_zoom=(current_xlim, current_ylim))
2521
- 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
2522
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()
2523
3011
 
2524
3012
  def on_mouse_release(self, event):
2525
- """Handle mouse release events."""
3013
+ """Handle mouse release events - OPTIMIZED VERSION."""
2526
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
+
2527
3019
  self.panning = False
2528
3020
  self.pan_start = None
2529
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))
2530
3030
  elif event.button == 1: # Left button release
2531
3031
  if self.selecting and self.selection_rect is not None:
2532
3032
  # Get the rectangle bounds
@@ -2585,18 +3085,15 @@ class ImageViewerWindow(QMainWindow):
2585
3085
  # Try to highlight the last selected value in tables
2586
3086
  if self.clicked_values['edges']:
2587
3087
  self.highlight_value_in_tables(self.clicked_values['edges'][-1])
2588
-
2589
3088
 
2590
3089
  elif not self.selecting and self.selection_start: # If we had a click but never started selection
2591
3090
  # Handle as a normal click
2592
3091
  self.on_mouse_click(event)
2593
-
2594
3092
 
2595
- # Clean up
3093
+ # Clean up selection
2596
3094
  self.selection_start = None
2597
3095
  self.selecting = False
2598
3096
 
2599
-
2600
3097
  if self.selection_rect is not None:
2601
3098
  try:
2602
3099
  self.selection_rect.remove()
@@ -2605,13 +3102,28 @@ class ImageViewerWindow(QMainWindow):
2605
3102
  self.selection_rect = None
2606
3103
  self.canvas.draw()
2607
3104
 
2608
- 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:
2609
3107
  self.painting = False
3108
+
3109
+ # Restore hidden channels
2610
3110
  try:
2611
3111
  for i in self.restore_channels:
2612
3112
  self.channel_visible[i] = True
3113
+ self.restore_channels = []
2613
3114
  except:
2614
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
2615
3127
  current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2616
3128
  current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2617
3129
  self.update_display(preserve_zoom=(current_xlim, current_ylim))
@@ -2886,6 +3398,8 @@ class ImageViewerWindow(QMainWindow):
2886
3398
  load_action.triggered.connect(lambda: self.load_misc('Edge Centroids'))
2887
3399
  load_action = misc_menu.addAction("Merge Nodes")
2888
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)
2889
3403
 
2890
3404
 
2891
3405
  # Analysis menu
@@ -2918,6 +3432,8 @@ class ImageViewerWindow(QMainWindow):
2918
3432
  ripley_action.triggered.connect(self.show_ripley_dialog)
2919
3433
  heatmap_action = stats_menu.addAction("Community Cluster Heatmap")
2920
3434
  heatmap_action.triggered.connect(self.show_heatmap_dialog)
3435
+ nearneigh_action = stats_menu.addAction("Average Nearest Neighbors")
3436
+ nearneigh_action.triggered.connect(self.show_nearneigh_dialog)
2921
3437
  vol_action = stats_menu.addAction("Calculate Volumes")
2922
3438
  vol_action.triggered.connect(self.volumes)
2923
3439
  rad_action = stats_menu.addAction("Calculate Radii")
@@ -3267,6 +3783,11 @@ class ImageViewerWindow(QMainWindow):
3267
3783
  except:
3268
3784
  pass
3269
3785
 
3786
+ def show_merge_node_id_dialog(self):
3787
+
3788
+ dialog = MergeNodeIdDialog(self)
3789
+ dialog.exec()
3790
+
3270
3791
 
3271
3792
  def show_watershed_dialog(self):
3272
3793
  """Show the watershed parameter dialog."""
@@ -3585,7 +4106,7 @@ class ImageViewerWindow(QMainWindow):
3585
4106
  f"Failed to load {sort}: {str(e)}"
3586
4107
  )
3587
4108
 
3588
- else:
4109
+ elif sort == 'Merge Nodes':
3589
4110
  try:
3590
4111
 
3591
4112
  if len(np.unique(my_network.nodes)) < 3:
@@ -3623,7 +4144,7 @@ class ImageViewerWindow(QMainWindow):
3623
4144
  if dialog.exec() == QFileDialog.DialogCode.Accepted:
3624
4145
  selected_path = dialog.directory().absolutePath()
3625
4146
 
3626
- my_network.merge_nodes(selected_path)
4147
+ my_network.merge_nodes(selected_path, root_id = self.node_name)
3627
4148
  self.load_channel(0, my_network.nodes, True)
3628
4149
 
3629
4150
 
@@ -3641,6 +4162,7 @@ class ImageViewerWindow(QMainWindow):
3641
4162
  )
3642
4163
 
3643
4164
 
4165
+
3644
4166
  # Modify load_from_network_obj method
3645
4167
  def load_from_network_obj(self):
3646
4168
  try:
@@ -3952,11 +4474,15 @@ class ImageViewerWindow(QMainWindow):
3952
4474
  return
3953
4475
 
3954
4476
  file_extension = filename.lower().split('.')[-1]
4477
+
4478
+ if channel_index == 0:
4479
+ self.node_name = filename
3955
4480
 
3956
4481
  try:
3957
4482
  if file_extension in ['tif', 'tiff']:
3958
4483
  import tifffile
3959
4484
  self.channel_data[channel_index] = tifffile.imread(filename)
4485
+
3960
4486
 
3961
4487
  elif file_extension == 'nii':
3962
4488
  import nibabel as nib
@@ -4094,6 +4620,9 @@ class ImageViewerWindow(QMainWindow):
4094
4620
 
4095
4621
  self.shape = self.channel_data[channel_index].shape
4096
4622
 
4623
+ self.img_height, self.img_width = self.shape[1], self.shape[2]
4624
+
4625
+
4097
4626
  self.update_display(reset_resize = reset_resize)
4098
4627
 
4099
4628
 
@@ -4554,36 +5083,55 @@ class ImageViewerWindow(QMainWindow):
4554
5083
  import traceback
4555
5084
  print(traceback.format_exc())
4556
5085
 
4557
- def update_display_slice(self, channel, preserve_zoom=None):
4558
- """Ultra minimal update that only changes the paint channel's data"""
5086
+ def update_display_slice_optimized(self, channel, preserve_zoom=None):
5087
+ """Ultra minimal update that only changes the paint channel's data - OPTIMIZED VERSION"""
4559
5088
  if not self.channel_visible[channel]:
4560
5089
  return
4561
-
5090
+
4562
5091
  if preserve_zoom:
4563
5092
  current_xlim, current_ylim = preserve_zoom
4564
5093
  if current_xlim is not None and current_ylim is not None:
4565
5094
  self.ax.set_xlim(current_xlim)
4566
5095
  self.ax.set_ylim(current_ylim)
4567
-
4568
-
5096
+
4569
5097
  # Find the existing image for channel (paint channel)
4570
5098
  channel_image = None
4571
5099
  for img in self.ax.images:
4572
5100
  if img.cmap.name == f'custom_{channel}':
4573
5101
  channel_image = img
4574
5102
  break
4575
-
5103
+
4576
5104
  if channel_image is not None:
4577
- # Update the data of the existing image
4578
- channel_image.set_array(self.channel_data[channel][self.current_slice])
5105
+ # Update the data of the existing image with thread safety
5106
+ with self.paint_lock:
5107
+ channel_image.set_array(self.channel_data[channel][self.current_slice])
4579
5108
 
4580
5109
  # Restore the static background (all other channels) at current zoom level
4581
- self.canvas.restore_region(self.static_background)
4582
- # Draw just our paint channel
4583
- self.ax.draw_artist(channel_image)
4584
- # Blit everything
4585
- self.canvas.blit(self.ax.bbox)
4586
- self.canvas.flush_events()
5110
+ # This is the key - use static_background from update_display, not paint_background
5111
+ if hasattr(self, 'static_background') and self.static_background is not None:
5112
+ self.canvas.restore_region(self.static_background)
5113
+ # Draw just our paint channel
5114
+ self.ax.draw_artist(channel_image)
5115
+ # Blit everything
5116
+ self.canvas.blit(self.ax.bbox)
5117
+ self.canvas.flush_events()
5118
+ else:
5119
+ # Fallback to full draw if no static background
5120
+ self.canvas.draw()
5121
+ else:
5122
+ # Fallback if channel image not found
5123
+ self.canvas.draw()
5124
+
5125
+ def get_channel_image(self, channel):
5126
+ """Find the matplotlib image object for a specific channel."""
5127
+ if not hasattr(self.ax, 'images'):
5128
+ return None
5129
+
5130
+ for img in self.ax.images:
5131
+ if hasattr(img, 'cmap') and hasattr(img.cmap, 'name'):
5132
+ if img.cmap.name == f'custom_{channel}':
5133
+ return img
5134
+ return None
4587
5135
 
4588
5136
  def show_netshow_dialog(self):
4589
5137
  dialog = NetShowDialog(self)
@@ -4666,6 +5214,10 @@ class ImageViewerWindow(QMainWindow):
4666
5214
  dialog = HeatmapDialog(self)
4667
5215
  dialog.exec()
4668
5216
 
5217
+ def show_nearneigh_dialog(self):
5218
+ dialog = NearNeighDialog(self)
5219
+ dialog.exec()
5220
+
4669
5221
  def show_random_dialog(self):
4670
5222
  dialog = RandomDialog(self)
4671
5223
  dialog.exec()
@@ -5480,6 +6032,10 @@ class PropertiesDialog(QDialog):
5480
6032
  run_button.clicked.connect(self.run_properties)
5481
6033
  layout.addWidget(run_button)
5482
6034
 
6035
+ report_button = QPushButton("Report Properties (Show in Top Right Tables)")
6036
+ report_button.clicked.connect(self.report)
6037
+ layout.addWidget(report_button)
6038
+
5483
6039
  def check_checked(self, ques):
5484
6040
 
5485
6041
  if ques is None:
@@ -5517,11 +6073,35 @@ class PropertiesDialog(QDialog):
5517
6073
  except Exception as e:
5518
6074
  print(f"Error: {e}")
5519
6075
 
5520
- class BrightnessContrastDialog(QDialog):
5521
- def __init__(self, parent=None):
5522
- super().__init__(parent)
5523
- self.setWindowTitle("Brightness/Contrast Controls")
5524
- self.setModal(False) # Allows interaction with main window while open
6076
+ def report(self):
6077
+
6078
+ try:
6079
+
6080
+ self.parent().format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity', 'Node Identities')
6081
+ except:
6082
+ pass
6083
+ try:
6084
+
6085
+ self.parent().format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
6086
+ except:
6087
+ pass
6088
+
6089
+ try:
6090
+ self.parent().format_for_upperright_table(my_network.edge_centroids, 'EdgeID', ['Z', 'Y', 'X'], 'Edge Centroids')
6091
+ except:
6092
+ pass
6093
+ try:
6094
+ self.parent().format_for_upperright_table(my_network.communities, 'NodeID', 'Community', 'Node Communities')
6095
+ except:
6096
+ pass
6097
+
6098
+
6099
+
6100
+ class BrightnessContrastDialog(QDialog):
6101
+ def __init__(self, parent=None):
6102
+ super().__init__(parent)
6103
+ self.setWindowTitle("Brightness/Contrast Controls")
6104
+ self.setModal(False) # Allows interaction with main window while open
5525
6105
 
5526
6106
  layout = QVBoxLayout(self)
5527
6107
 
@@ -5896,6 +6476,72 @@ class ArbitraryDialog(QDialog):
5896
6476
  except Exception as e:
5897
6477
  QMessageBox.critical(self, "Error", f"Error processing selections: {str(e)}")
5898
6478
 
6479
+ class MergeNodeIdDialog(QDialog):
6480
+
6481
+ def __init__(self, parent=None):
6482
+ super().__init__(parent)
6483
+ 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.")
6484
+ self.setModal(True)
6485
+
6486
+ layout = QFormLayout(self)
6487
+
6488
+ self.search = QLineEdit("")
6489
+ layout.addRow("Step-out distance (from current nodes image - ignore if you dilated them previously or don't want):", self.search)
6490
+
6491
+ self.xy_scale = QLineEdit(f"{my_network.xy_scale}")
6492
+ layout.addRow("xy_scale:", self.xy_scale)
6493
+
6494
+ self.z_scale = QLineEdit(f"{my_network.z_scale}")
6495
+ layout.addRow("z_scale:", self.z_scale)
6496
+
6497
+ # Add Run button
6498
+ run_button = QPushButton("Get Directory")
6499
+ run_button.clicked.connect(self.run)
6500
+ layout.addWidget(run_button)
6501
+
6502
+ def run(self):
6503
+
6504
+ try:
6505
+
6506
+ search = float(self.search.text()) if self.search.text().strip() else 0
6507
+ xy_scale = float(self.xy_scale.text()) if self.xy_scale.text().strip() else 1
6508
+ z_scale = float(self.z_scale.text()) if self.z_scale.text().strip() else 1
6509
+
6510
+
6511
+ data = self.parent().channel_data[0]
6512
+
6513
+ if data is None:
6514
+ return
6515
+
6516
+
6517
+
6518
+ dialog = QFileDialog(self)
6519
+ dialog.setOption(QFileDialog.Option.DontUseNativeDialog)
6520
+ dialog.setOption(QFileDialog.Option.ReadOnly)
6521
+ dialog.setFileMode(QFileDialog.FileMode.Directory)
6522
+ dialog.setViewMode(QFileDialog.ViewMode.Detail)
6523
+
6524
+ if dialog.exec() == QFileDialog.DialogCode.Accepted:
6525
+ selected_path = dialog.directory().absolutePath()
6526
+
6527
+ if search > 0:
6528
+ 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)
6529
+
6530
+ my_network.merge_node_ids(selected_path, data)
6531
+
6532
+ self.parent().format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity')
6533
+
6534
+ QMessageBox.critical(
6535
+ self,
6536
+ "Success",
6537
+ "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)"
6538
+ )
6539
+
6540
+ self.accept()
6541
+
6542
+ except Exception as e:
6543
+ print(f"Error: {e}")
6544
+
5899
6545
 
5900
6546
  class Show3dDialog(QDialog):
5901
6547
  def __init__(self, parent=None):
@@ -6629,6 +7275,174 @@ class DegreeDistDialog(QDialog):
6629
7275
  except Exception as e:
6630
7276
  print(f"An error occurred: {e}")
6631
7277
 
7278
+ class NearNeighDialog(QDialog):
7279
+ def __init__(self, parent=None):
7280
+ super().__init__(parent)
7281
+ self.setWindowTitle(f"Nearest Neighborhood Averages (Using Centroids)")
7282
+ self.setModal(True)
7283
+
7284
+ # Main layout
7285
+ main_layout = QVBoxLayout(self)
7286
+
7287
+ # Identities group box (only if node_identities exists)
7288
+ identities_group = QGroupBox("Identities")
7289
+ identities_layout = QFormLayout(identities_group)
7290
+
7291
+ if my_network.node_identities is not None:
7292
+
7293
+ self.root = QComboBox()
7294
+ self.root.addItems(list(set(my_network.node_identities.values())))
7295
+ self.root.setCurrentIndex(0)
7296
+ identities_layout.addRow("Root Identity to Search for Neighbor's IDs?", self.root)
7297
+
7298
+ self.targ = QComboBox()
7299
+ neighs = list(set(my_network.node_identities.values()))
7300
+ neighs.append("All Others (Excluding Self)")
7301
+ self.targ.addItems(neighs)
7302
+ self.targ.setCurrentIndex(0)
7303
+ identities_layout.addRow("Neighbor Identities to Search For?", self.targ)
7304
+ else:
7305
+ self.root = None
7306
+ self.targ = None
7307
+
7308
+ self.num = QLineEdit("1")
7309
+ identities_layout.addRow("Number of Nearest Neighbors to Evaluate Per Node?:", self.num)
7310
+
7311
+
7312
+ main_layout.addWidget(identities_group)
7313
+
7314
+
7315
+ # Optional Heatmap group box
7316
+ heatmap_group = QGroupBox("Optional Heatmap")
7317
+ heatmap_layout = QFormLayout(heatmap_group)
7318
+
7319
+ self.map = QPushButton("(If getting distribution): Generate Heatmap?")
7320
+ self.map.setCheckable(True)
7321
+ self.map.setChecked(False)
7322
+ heatmap_layout.addRow("Heatmap:", self.map)
7323
+
7324
+ self.threed = QPushButton("(For above): Return 3D map? (uncheck for 2D): ")
7325
+ self.threed.setCheckable(True)
7326
+ self.threed.setChecked(True)
7327
+ heatmap_layout.addRow("3D:", self.threed)
7328
+
7329
+ self.numpy = QPushButton("(For heatmap): Return image overlay instead of graph? (Goes in Overlay 2): ")
7330
+ self.numpy.setCheckable(True)
7331
+ self.numpy.setChecked(False)
7332
+ self.numpy.clicked.connect(self.toggle_map)
7333
+ heatmap_layout.addRow("Overlay:", self.numpy)
7334
+
7335
+ main_layout.addWidget(heatmap_group)
7336
+
7337
+ # Get Distribution group box
7338
+ distribution_group = QGroupBox("Get Distribution")
7339
+ distribution_layout = QVBoxLayout(distribution_group)
7340
+
7341
+ run_button = QPushButton("Get Average Nearest Neighbor (Plus Distribution)")
7342
+ run_button.clicked.connect(self.run)
7343
+ distribution_layout.addWidget(run_button)
7344
+
7345
+ main_layout.addWidget(distribution_group)
7346
+
7347
+ # Get All Averages group box (only if node_identities exists)
7348
+ if my_network.node_identities is not None:
7349
+ averages_group = QGroupBox("Get All Averages")
7350
+ averages_layout = QVBoxLayout(averages_group)
7351
+
7352
+ run_button2 = QPushButton("Get Average Nearest All ID Combinations (No Distribution, No Heatmap)")
7353
+ run_button2.clicked.connect(self.run2)
7354
+ averages_layout.addWidget(run_button2)
7355
+
7356
+ main_layout.addWidget(averages_group)
7357
+
7358
+ def toggle_map(self):
7359
+
7360
+ if self.numpy.isChecked():
7361
+
7362
+ if not self.map.isChecked():
7363
+
7364
+ self.map.click()
7365
+
7366
+ def run(self):
7367
+
7368
+ try:
7369
+
7370
+ try:
7371
+ root = self.root.currentText()
7372
+ except:
7373
+ root = None
7374
+ try:
7375
+ targ = self.targ.currentText()
7376
+ except:
7377
+ targ = None
7378
+
7379
+ heatmap = self.map.isChecked()
7380
+ threed = self.threed.isChecked()
7381
+ numpy = self.numpy.isChecked()
7382
+ num = int(self.num.text()) if self.num.text().strip() else 1
7383
+
7384
+ if root is not None and targ is not None:
7385
+ title = f"Nearest {num} Neighbor(s) Distance of {targ} from {root}"
7386
+ header = f"Shortest Distance to Closest {num} {targ}(s)"
7387
+ header2 = f"{root} Node ID"
7388
+ else:
7389
+ title = f"Nearest {num} Neighbor(s) Distance Between Nodes"
7390
+ header = f"Shortest Distance to Closest {num} Nodes"
7391
+ header2 = "Root Node ID"
7392
+
7393
+ if my_network.node_centroids is None:
7394
+ self.parent().show_centroid_dialog()
7395
+ if my_network.node_centroids is None:
7396
+ return
7397
+
7398
+ if not numpy:
7399
+ avg, output = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num, heatmap = heatmap, threed = threed)
7400
+ else:
7401
+ avg, output, overlay = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num, heatmap = heatmap, threed = threed, numpy = True)
7402
+ self.parent().load_channel(3, overlay, data = True)
7403
+
7404
+ self.parent().format_for_upperright_table([avg], metric = f'Avg {title}', title = f'Avg {title}')
7405
+ self.parent().format_for_upperright_table(output, header2, header, title = title)
7406
+
7407
+ self.accept()
7408
+
7409
+ except Exception as e:
7410
+ import traceback
7411
+ print(traceback.format_exc())
7412
+
7413
+ print(f"Error: {e}")
7414
+
7415
+ def run2(self):
7416
+
7417
+ try:
7418
+
7419
+ available = list(set(my_network.node_identities.values()))
7420
+
7421
+ num = int(self.num.text()) if self.num.text().strip() else 1
7422
+
7423
+ output_dict = {}
7424
+
7425
+ while len(available) > 1:
7426
+
7427
+ root = available[0]
7428
+
7429
+ for targ in available:
7430
+
7431
+ avg, _ = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num)
7432
+
7433
+ output_dict[f"{root} vs {targ}"] = avg
7434
+
7435
+ del available[0]
7436
+
7437
+ self.parent().format_for_upperright_table(output_dict, "ID Combo", "Avg Distance to Nearest", title = "Average Distance to Nearest Neighbors for All ID Combos")
7438
+
7439
+ self.accept()
7440
+
7441
+ except Exception as e:
7442
+
7443
+ print(f"Error: {e}")
7444
+
7445
+
6632
7446
  class NeighborIdentityDialog(QDialog):
6633
7447
 
6634
7448
  def __init__(self, parent=None):
@@ -6815,8 +7629,7 @@ class RipleyDialog(QDialog):
6815
7629
  "Error:",
6816
7630
  f"Failed to preform cluster analysis: {str(e)}"
6817
7631
  )
6818
- import traceback
6819
- print(traceback.format_exc())
7632
+
6820
7633
  print(f"Error: {e}")
6821
7634
 
6822
7635
  class HeatmapDialog(QDialog):
@@ -6839,6 +7652,11 @@ class HeatmapDialog(QDialog):
6839
7652
  self.is3d.setChecked(True)
6840
7653
  layout.addRow("Use 3D Plot (uncheck for 2D)?:", self.is3d)
6841
7654
 
7655
+ self.numpy = QPushButton("(For heatmap): Return image overlay instead of graph? (Goes in Overlay 2): ")
7656
+ self.numpy.setCheckable(True)
7657
+ self.numpy.setChecked(False)
7658
+ layout.addRow("Overlay:", self.numpy)
7659
+
6842
7660
 
6843
7661
  # Add Run button
6844
7662
  run_button = QPushButton("Run")
@@ -6847,25 +7665,40 @@ class HeatmapDialog(QDialog):
6847
7665
 
6848
7666
  def run(self):
6849
7667
 
6850
- nodecount = int(self.nodecount.text()) if self.nodecount.text().strip() else None
7668
+ try:
6851
7669
 
6852
- is3d = self.is3d.isChecked()
7670
+ nodecount = int(self.nodecount.text()) if self.nodecount.text().strip() else None
7671
+
7672
+ is3d = self.is3d.isChecked()
6853
7673
 
6854
7674
 
6855
- if my_network.communities is None:
6856
- if my_network.network is not None:
6857
- self.parent().show_partition_dialog()
6858
- else:
6859
- self.parent().handle_com_cell()
6860
7675
  if my_network.communities is None:
6861
- return
7676
+ if my_network.network is not None:
7677
+ self.parent().show_partition_dialog()
7678
+ else:
7679
+ self.parent().handle_com_cell()
7680
+ if my_network.communities is None:
7681
+ return
6862
7682
 
6863
- heat_dict = my_network.community_heatmap(num_nodes = nodecount, is3d = is3d)
7683
+ numpy = self.numpy.isChecked()
6864
7684
 
6865
- self.parent().format_for_upperright_table(heat_dict, metric='Community', value='ln(Predicted Community Nodecount/Actual)', title="Community Heatmap")
7685
+ if not numpy:
6866
7686
 
6867
- self.accept()
7687
+ heat_dict = my_network.community_heatmap(num_nodes = nodecount, is3d = is3d)
7688
+
7689
+ else:
6868
7690
 
7691
+ heat_dict, overlay = my_network.community_heatmap(num_nodes = nodecount, is3d = is3d, numpy = True)
7692
+ self.parent().load_channel(3, overlay, data = True)
7693
+
7694
+
7695
+ self.parent().format_for_upperright_table(heat_dict, metric='Community', value='ln(Predicted Community Nodecount/Actual)', title="Community Heatmap")
7696
+
7697
+ self.accept()
7698
+
7699
+ except Exception as e:
7700
+
7701
+ print(f"Error: {e}")
6869
7702
 
6870
7703
 
6871
7704
 
@@ -7018,6 +7851,10 @@ class RadDialog(QDialog):
7018
7851
  self.parent().radii_dict[0] = radii
7019
7852
  elif self.parent().active_channel == 1:
7020
7853
  self.parent().radii_dict[1] = radii
7854
+ elif self.parent().active_channel == 2:
7855
+ self.parent().radii_dict[2] = radii
7856
+ elif self.parent().active_channel == 3:
7857
+ self.parent().radii_dict[3] = radii
7021
7858
 
7022
7859
  self.parent().format_for_upperright_table(radii, title = 'Largest Radii of Objects', metric='ObjectID', value='Largest Radius (Scaled)')
7023
7860
 
@@ -7105,7 +7942,7 @@ class DegreeDialog(QDialog):
7105
7942
 
7106
7943
  # Add mode selection dropdown
7107
7944
  self.mode_selector = QComboBox()
7108
- 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... slower)"])
7945
+ 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"])
7109
7946
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
7110
7947
  layout.addRow("Execution Mode:", self.mode_selector)
7111
7948
 
@@ -7126,6 +7963,14 @@ class DegreeDialog(QDialog):
7126
7963
 
7127
7964
  accepted_mode = self.mode_selector.currentIndex()
7128
7965
 
7966
+ if accepted_mode == 3:
7967
+ degree_dict, overlay = my_network.get_degrees(heatmap = True)
7968
+ self.parent().format_for_upperright_table(degree_dict, 'Node ID', 'Degree', title = 'Degrees of nodes')
7969
+ self.parent().load_channel(3, channel_data = overlay, data = True)
7970
+ self.accept()
7971
+ return
7972
+
7973
+
7129
7974
  try:
7130
7975
  down_factor = float(self.down_factor.text()) if self.down_factor.text() else 1
7131
7976
  except ValueError:
@@ -7964,7 +8809,7 @@ class ThresholdDialog(QDialog):
7964
8809
 
7965
8810
  # Add mode selection dropdown
7966
8811
  self.mode_selector = QComboBox()
7967
- self.mode_selector.addItems(["Using Label/Brightness", "Using Volumes"])
8812
+ self.mode_selector.addItems(["Using Label/Brightness", "Using Volumes", "Using Radii", "Using Node Degree"])
7968
8813
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
7969
8814
  layout.addRow("Execution Mode:", self.mode_selector)
7970
8815
 
@@ -7997,6 +8842,22 @@ class ThresholdDialog(QDialog):
7997
8842
  if self.parent().volume_dict[self.parent().active_channel] is None:
7998
8843
  self.parent().volumes()
7999
8844
 
8845
+ elif accepted_mode == 2:
8846
+ if len(np.unique(self.parent().channel_data[self.parent().active_channel])) < 3:
8847
+ self.parent().show_label_dialog()
8848
+
8849
+ if self.parent().radii_dict[self.parent().active_channel] is None:
8850
+ self.parent().show_rad_dialog()
8851
+
8852
+ if self.parent().radii_dict[self.parent().active_channel] is None:
8853
+ return
8854
+
8855
+ elif accepted_mode == 3:
8856
+
8857
+ if my_network.nodes is None or my_network.network is None:
8858
+ print("Error - please calculate network first")
8859
+ return
8860
+
8000
8861
  if self.parent().mini_overlay_data is not None:
8001
8862
  self.parent().mini_overlay_data = None
8002
8863
 
@@ -8005,6 +8866,8 @@ class ThresholdDialog(QDialog):
8005
8866
  self.highlight_overlay = None
8006
8867
  self.accept()
8007
8868
  except:
8869
+ import traceback
8870
+ traceback.print_exc()
8008
8871
  pass
8009
8872
 
8010
8873
  def start_ml(self, GPU = False):
@@ -8696,7 +9559,11 @@ class MachineWindow(QMainWindow):
8696
9559
 
8697
9560
  print("Segmenting entire volume with model...")
8698
9561
  #foreground_coords, background_coords = self.segmenter.segment_volume(array = self.parent().highlight_overlay)
8699
- self.parent().highlight_overlay = self.segmenter.segment_volume(array = self.parent().highlight_overlay)
9562
+ try:
9563
+ self.parent().highlight_overlay = self.segmenter.segment_volume(array = self.parent().highlight_overlay)
9564
+ except Exception as e:
9565
+ print(f"Error segmenting (Perhaps retrain the model...): {e}")
9566
+ return
8700
9567
 
8701
9568
  # Clean up when done
8702
9569
  self.segmenter.cleanup()
@@ -8717,23 +9584,27 @@ class MachineWindow(QMainWindow):
8717
9584
  print("Finished segmentation moved to Overlay 2. Use File -> Save(As) for disk saving.")
8718
9585
 
8719
9586
  def closeEvent(self, event):
8720
- if self.parent().isVisible():
8721
- if self.confirm_close_dialog():
8722
- # Clean up resources before closing
8723
- if self.brush_button.isChecked():
8724
- self.silence_button()
8725
- self.toggle_brush_mode()
8726
-
8727
- self.parent().pen_button.setEnabled(True)
8728
- self.parent().brush_mode = False
8729
-
8730
- # Kill the segmentation thread and wait for it to finish
8731
- self.kill_segmentation()
8732
- time.sleep(0.2) # Give additional time for cleanup
8733
-
8734
- self.parent().machine_window = None
8735
- else:
8736
- event.ignore()
9587
+
9588
+ try:
9589
+ if self.parent().isVisible():
9590
+ if self.confirm_close_dialog():
9591
+ # Clean up resources before closing
9592
+ if self.brush_button.isChecked():
9593
+ self.silence_button()
9594
+ self.toggle_brush_mode()
9595
+
9596
+ self.parent().pen_button.setEnabled(True)
9597
+ self.parent().brush_mode = False
9598
+
9599
+ # Kill the segmentation thread and wait for it to finish
9600
+ self.kill_segmentation()
9601
+ time.sleep(0.2) # Give additional time for cleanup
9602
+
9603
+ self.parent().machine_window = None
9604
+ else:
9605
+ event.ignore()
9606
+ except:
9607
+ pass
8737
9608
 
8738
9609
 
8739
9610
 
@@ -8846,6 +9717,8 @@ class ThresholdWindow(QMainWindow):
8846
9717
  def __init__(self, parent=None, accepted_mode=0):
8847
9718
  super().__init__(parent)
8848
9719
  self.setWindowTitle("Threshold")
9720
+
9721
+ self.accepted_mode = accepted_mode
8849
9722
 
8850
9723
  # Create central widget and layout
8851
9724
  central_widget = QWidget()
@@ -8857,6 +9730,27 @@ class ThresholdWindow(QMainWindow):
8857
9730
  self.histo_list = list(self.parent().volume_dict[self.parent().active_channel].values())
8858
9731
  self.bounds = False
8859
9732
  self.parent().bounds = False
9733
+ elif accepted_mode == 2:
9734
+ self.histo_list = list(self.parent().radii_dict[self.parent().active_channel].values())
9735
+ self.bounds = False
9736
+ self.parent().bounds = False
9737
+ elif accepted_mode == 3:
9738
+ self.parent().degree_dict = {}
9739
+ self.parent().set_active_channel(0)
9740
+ nodes = list(my_network.network.nodes())
9741
+ img_nodes = list(np.unique(my_network.nodes))
9742
+ if 0 in img_nodes:
9743
+ del img_nodes[0]
9744
+ for node in img_nodes:
9745
+ if node in nodes:
9746
+ self.parent().degree_dict[int(node)] = my_network.network.degree(node)
9747
+ else:
9748
+ self.parent().degree_dict[int(node)] = 0
9749
+
9750
+ self.histo_list = list(self.parent().degree_dict.values())
9751
+ self.bounds = False
9752
+ self.parent().bounds = False
9753
+
8860
9754
  elif accepted_mode == 0:
8861
9755
  targ_shape = self.parent().channel_data[self.parent().active_channel].shape
8862
9756
  if (targ_shape[0] + targ_shape[1] + targ_shape[2]) > 2500: #Take a simpler histogram on big arrays
@@ -8976,17 +9870,35 @@ class ThresholdWindow(QMainWindow):
8976
9870
 
8977
9871
  def get_values_in_range_all_vols(self, chan, min_val, max_val):
8978
9872
  output = []
8979
- for node, vol in self.parent().volume_dict[chan].items():
8980
- if min_val <= vol <= max_val:
8981
- output.append(node)
9873
+ if self.accepted_mode == 1:
9874
+ for node, vol in self.parent().volume_dict[chan].items():
9875
+ if min_val <= vol <= max_val:
9876
+ output.append(node)
9877
+ elif self.accepted_mode == 2:
9878
+ for node, vol in self.parent().radii_dict[chan].items():
9879
+ if min_val <= vol <= max_val:
9880
+ output.append(node)
9881
+ elif self.accepted_mode == 3:
9882
+ for node, vol in self.parent().degree_dict.items():
9883
+ if min_val <= vol <= max_val:
9884
+ output.append(node)
8982
9885
  return output
8983
9886
 
8984
9887
  def get_values_in_range(self, lst, min_val, max_val):
8985
9888
  values = [x for x in lst if min_val <= x <= max_val]
8986
9889
  output = []
8987
- for item in self.parent().volume_dict[self.parent().active_channel]:
8988
- if self.parent().volume_dict[self.parent().active_channel][item] in values:
8989
- output.append(item)
9890
+ if self.accepted_mode == 1:
9891
+ for item in self.parent().volume_dict[self.parent().active_channel]:
9892
+ if self.parent().volume_dict[self.parent().active_channel][item] in values:
9893
+ output.append(item)
9894
+ elif self.accepted_mode == 2:
9895
+ for item in self.parent().radii_dict[self.parent().active_channel]:
9896
+ if self.parent().radii_dict[self.parent().active_channel][item] in values:
9897
+ output.append(item)
9898
+ elif self.accepted_mode == 3:
9899
+ for item in self.parent().degree_dict:
9900
+ if self.parent().degree_dict[item] in values:
9901
+ output.append(item)
8990
9902
  return output
8991
9903
 
8992
9904
 
@@ -9421,15 +10333,20 @@ class HoleDialog(QDialog):
9421
10333
  # auto checkbox (default True)
9422
10334
  self.headon = QPushButton("Head-on")
9423
10335
  self.headon.setCheckable(True)
9424
- self.headon.setChecked(False)
10336
+ self.headon.setChecked(True)
9425
10337
  layout.addRow("Only Use 2D Slicing Dimension:", self.headon)
9426
10338
 
9427
10339
  # auto checkbox (default True)
9428
10340
  self.borders = QPushButton("Borders")
9429
10341
  self.borders.setCheckable(True)
9430
- self.borders.setChecked(True)
10342
+ self.borders.setChecked(False)
9431
10343
  layout.addRow("Fill Small Holes Along Borders:", self.borders)
9432
10344
 
10345
+ self.sep_holes = QPushButton("Seperate Hole Mask")
10346
+ self.sep_holes.setCheckable(True)
10347
+ self.sep_holes.setChecked(False)
10348
+ layout.addRow("Place Hole Mask in Overlay 2 (Instead of Filling):", self.sep_holes)
10349
+
9433
10350
  # Add Run button
9434
10351
  run_button = QPushButton("Run Fill Holes")
9435
10352
  run_button.clicked.connect(self.run_holes)
@@ -9446,6 +10363,7 @@ class HoleDialog(QDialog):
9446
10363
 
9447
10364
  borders = self.borders.isChecked()
9448
10365
  headon = self.headon.isChecked()
10366
+ sep_holes = self.sep_holes.isChecked()
9449
10367
 
9450
10368
  # Call dilate method with parameters
9451
10369
  result = n3d.fill_holes_3d(
@@ -9454,7 +10372,11 @@ class HoleDialog(QDialog):
9454
10372
  fill_borders = borders
9455
10373
  )
9456
10374
 
9457
- self.parent().load_channel(self.parent().active_channel, result, True)
10375
+ if not sep_holes:
10376
+ self.parent().load_channel(self.parent().active_channel, result, True)
10377
+ else:
10378
+ self.parent().load_channel(3, active_data - result, True)
10379
+
9458
10380
 
9459
10381
  self.parent().update_display()
9460
10382
  self.accept()
@@ -9553,7 +10475,7 @@ class CropDialog(QDialog):
9553
10475
  try:
9554
10476
 
9555
10477
  super().__init__(parent)
9556
- self.setWindowTitle("Crop Image?")
10478
+ self.setWindowTitle("Crop Image (Will transpose any centroids)?")
9557
10479
  self.setModal(True)
9558
10480
 
9559
10481
  layout = QFormLayout(self)
@@ -9609,10 +10531,70 @@ class CropDialog(QDialog):
9609
10531
 
9610
10532
  self.parent().load_channel(i, array, data = True)
9611
10533
 
10534
+ print("Transposing centroids...")
10535
+
10536
+ try:
10537
+
10538
+ if my_network.node_centroids is not None:
10539
+ nodes = list(my_network.node_centroids.keys())
10540
+ centroids = np.array(list(my_network.node_centroids.values()))
10541
+
10542
+ # Transform all at once
10543
+ transformed = centroids - np.array([zmin, ymin, xmin])
10544
+ transformed = transformed.astype(int)
10545
+
10546
+ # Boolean mask for valid coordinates
10547
+ valid_mask = ((transformed >= 0) &
10548
+ (transformed <= np.array([zmax, ymax, xmax]))).all(axis=1)
10549
+
10550
+ # Rebuild dictionary with only valid entries
10551
+ my_network.node_centroids = {
10552
+ nodes[int(i)]: [int(transformed[i, 0]), int(transformed[i, 1]), int(transformed[i, 2])]
10553
+ for i in range(len(nodes)) if valid_mask[i]
10554
+ }
10555
+
10556
+ self.parent().format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
10557
+
10558
+ except Exception as e:
10559
+
10560
+ print(f"Error transposing node centroids: {e}")
10561
+
10562
+ try:
10563
+
10564
+ if my_network.edge_centroids is not None:
10565
+
10566
+ if my_network.edge_centroids is not None:
10567
+ nodes = list(my_network.edge_centroids.keys())
10568
+ centroids = np.array(list(my_network.edge_centroids.values()))
10569
+
10570
+ # Transform all at once
10571
+ transformed = centroids - np.array([zmin, ymin, xmin])
10572
+ transformed = transformed.astype(int)
10573
+
10574
+ # Boolean mask for valid coordinates
10575
+ valid_mask = ((transformed >= 0) &
10576
+ (transformed <= np.array([zmax, ymax, xmax]))).all(axis=1)
10577
+
10578
+ # Rebuild dictionary with only valid entries
10579
+ my_network.edge_centroids = {
10580
+ nodes[int(i)]: [int(transformed[i, 0]), int(transformed[i, 1]), int(transformed[i, 2])]
10581
+ for i in range(len(nodes)) if valid_mask[i]
10582
+ }
10583
+
10584
+ self.parent().format_for_upperright_table(my_network.edge_centroids, 'EdgeID', ['Z', 'Y', 'X'], 'Edge Centroids')
10585
+
10586
+ except Exception as e:
10587
+
10588
+ print(f"Error transposing edge centroids: {e}")
10589
+
10590
+
9612
10591
  self.accept()
9613
10592
 
9614
10593
  except Exception as e:
9615
10594
 
10595
+ import traceback
10596
+ print(traceback.format_exc())
10597
+
9616
10598
  print(f"Error cropping: {e}")
9617
10599
 
9618
10600
 
@@ -10037,7 +11019,7 @@ class CentroidNodeDialog(QDialog):
10037
11019
 
10038
11020
  else:
10039
11021
 
10040
- my_network.nodes, my_network.centroids = my_network.centroid_array(clip = True)
11022
+ my_network.nodes, my_network.node_centroids = my_network.centroid_array(clip = True)
10041
11023
 
10042
11024
  self.parent().format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
10043
11025
 
@@ -10315,6 +11297,12 @@ class BranchDialog(QDialog):
10315
11297
  self.fix2.setChecked(True)
10316
11298
  correction_layout.addWidget(QLabel("Auto-Correct Branches by Collapsing Internal Labels: "), 3, 0)
10317
11299
  correction_layout.addWidget(self.fix2, 3, 1)
11300
+
11301
+ self.fix3 = QPushButton("Split Nontouching Branches?")
11302
+ self.fix3.setCheckable(True)
11303
+ self.fix3.setChecked(True)
11304
+ correction_layout.addWidget(QLabel("Split Nontouching Branches? (Useful if branch pruning - may want to threshold out small, split branches after): "), 4, 0)
11305
+ correction_layout.addWidget(self.fix3, 4, 1)
10318
11306
 
10319
11307
  correction_group.setLayout(correction_layout)
10320
11308
  main_layout.addWidget(correction_group)
@@ -10385,6 +11373,7 @@ class BranchDialog(QDialog):
10385
11373
  cubic = self.cubic.isChecked()
10386
11374
  fix = self.fix.isChecked()
10387
11375
  fix2 = self.fix2.isChecked()
11376
+ fix3 = self.fix3.isChecked()
10388
11377
  fix_val = float(self.fix_val.text()) if self.fix_val.text() else None
10389
11378
  seed = int(self.seed.text()) if self.seed.text() else None
10390
11379
 
@@ -10434,6 +11423,10 @@ class BranchDialog(QDialog):
10434
11423
 
10435
11424
  output = temp_network.nodes
10436
11425
 
11426
+ if fix3:
11427
+
11428
+ output = self.parent().separate_nontouching_objects(output, max_val=np.max(output))
11429
+
10437
11430
 
10438
11431
  if down_factor is not None:
10439
11432
 
@@ -10946,11 +11939,11 @@ class CalcAllDialog(QDialog):
10946
11939
 
10947
11940
  self.search = QLineEdit(self.prev_search)
10948
11941
  self.search.setPlaceholderText("Leave empty for None")
10949
- important_layout.addRow("Node Search (float):", self.search)
11942
+ important_layout.addRow("Node Search (float - Does not merge nodes):", self.search)
10950
11943
 
10951
11944
  self.diledge = QLineEdit(self.prev_diledge)
10952
11945
  self.diledge.setPlaceholderText("Leave empty for None")
10953
- important_layout.addRow("Edge Reconnection Distance (float):", self.diledge)
11946
+ important_layout.addRow("Edge Search (float - Note that edges that find each other will merge):", self.diledge)
10954
11947
 
10955
11948
  self.label_nodes = QPushButton("Label")
10956
11949
  self.label_nodes.setCheckable(True)