nettracer3d 0.8.3__py3-none-any.whl → 0.8.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,7 @@ from PyQt6.QtGui import (QFont, QCursor, QColor, QPixmap, QFontMetrics, QPainter
22
22
  import tifffile
23
23
  import copy
24
24
  import multiprocessing as mp
25
- from concurrent.futures import ThreadPoolExecutor
25
+ from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
26
26
  from functools import partial
27
27
  from nettracer3d import segmenter
28
28
  try:
@@ -33,6 +33,9 @@ from nettracer3d import excelotron
33
33
  import threading
34
34
  import queue
35
35
  from threading import Lock
36
+ from scipy import ndimage
37
+ import os
38
+ from . import painting
36
39
 
37
40
 
38
41
 
@@ -208,7 +211,7 @@ class ImageViewerWindow(QMainWindow):
208
211
  buttons_widget = QWidget()
209
212
  buttons_layout = QHBoxLayout(buttons_widget)
210
213
 
211
- # Create zoom button
214
+ # "Create" zoom button
212
215
  self.zoom_button = QPushButton("🔍")
213
216
  self.zoom_button.setCheckable(True)
214
217
  self.zoom_button.setFixedSize(40, 40)
@@ -291,6 +294,14 @@ class ImageViewerWindow(QMainWindow):
291
294
 
292
295
  control_layout.addWidget(channel_container)
293
296
 
297
+ self.show_channels = QPushButton("✓")
298
+ self.show_channels.setCheckable(True)
299
+ self.show_channels.setChecked(True)
300
+ self.show_channels.setFixedSize(20, 20)
301
+ self.show_channels.clicked.connect(self.toggle_chan_load)
302
+ control_layout.addWidget(self.show_channels)
303
+ self.chan_load = True
304
+
294
305
  # Create the main widget and layout
295
306
  main_widget = QWidget()
296
307
  self.setCentralWidget(main_widget)
@@ -386,7 +397,7 @@ class ImageViewerWindow(QMainWindow):
386
397
  # Create both table views
387
398
  self.network_table = CustomTableView(self)
388
399
  self.selection_table = CustomTableView(self)
389
- empty_df = pd.DataFrame(columns=['Node 1A', 'Node 1B', 'Edge 1C'])
400
+ empty_df = pd.DataFrame(columns=['Node A', 'Node B', 'Edge C'])
390
401
  self.selection_table.setModel(PandasModel(empty_df))
391
402
  self.network_table.setAlternatingRowColors(True)
392
403
  self.selection_table.setAlternatingRowColors(True)
@@ -444,18 +455,8 @@ class ImageViewerWindow(QMainWindow):
444
455
  self.excel_manager.data_received.connect(self.handle_excel_data)
445
456
  self.prev_coms = None
446
457
 
447
- self.paint_timer = QTimer()
448
- self.paint_timer.timeout.connect(self.flush_paint_updates)
449
- self.paint_timer.setSingleShot(True)
450
- self.pending_paint_update = False
451
458
  self.static_background = None
452
459
 
453
- # Threading for paint operations
454
- self.paint_queue = queue.Queue()
455
- self.paint_lock = Lock()
456
- self.paint_worker = threading.Thread(target=self.paint_worker_loop, daemon=True)
457
- self.paint_worker.start()
458
-
459
460
  # Background caching for blitting
460
461
  self.paint_session_active = False
461
462
 
@@ -1773,7 +1774,7 @@ class ImageViewerWindow(QMainWindow):
1773
1774
  my_network.network_lists = my_network.network_lists
1774
1775
 
1775
1776
  if not hasattr(my_network, 'network_lists') or my_network.network_lists is None:
1776
- empty_df = pd.DataFrame(columns=['Node 1A', 'Node 1B', 'Edge 1C'])
1777
+ empty_df = pd.DataFrame(columns=['Node A', 'Node B', 'Edge C'])
1777
1778
  model = PandasModel(empty_df)
1778
1779
  self.network_table.setModel(model)
1779
1780
  else:
@@ -1795,58 +1796,149 @@ class ImageViewerWindow(QMainWindow):
1795
1796
  except Exception as e:
1796
1797
  print(f"An error has occured: {e}")
1797
1798
 
1798
- def separate_nontouching_objects(self, input_array, max_val=0):
1799
+
1800
+ def process_single_label_bbox(args):
1799
1801
  """
1800
- optimized version using advanced indexing.
1802
+ Worker function to process a single label within its bounding box
1803
+ This function will run in parallel
1801
1804
  """
1805
+ label_subarray, original_label, bbox_slices, start_new_label = args
1806
+
1807
+ try:
1808
+ # Create binary mask for this label only
1809
+ binary_mask = label_subarray == original_label
1810
+
1811
+ if not np.any(binary_mask):
1812
+ return None, start_new_label, bbox_slices
1813
+
1814
+ # Find connected components in the subarray
1815
+ labeled_cc, num_cc = n3d.label_objects(binary_mask)
1816
+
1817
+ if num_cc == 0:
1818
+ return None, start_new_label, bbox_slices
1819
+
1820
+ # Create output subarray with new labels
1821
+ output_subarray = np.zeros_like(label_subarray)
1822
+
1823
+ # Assign new consecutive labels starting from start_new_label
1824
+ for cc_id in range(1, num_cc + 1):
1825
+ cc_mask = labeled_cc == cc_id
1826
+ new_label = start_new_label + cc_id - 1
1827
+ output_subarray[cc_mask] = new_label
1828
+
1829
+ # Return the processed subarray, number of components created, and bbox info
1830
+ return output_subarray, start_new_label + num_cc, bbox_slices
1831
+
1832
+ except Exception as e:
1833
+ print(f"Error processing label {original_label}: {e}")
1834
+ return None, start_new_label, bbox_slices
1802
1835
 
1836
+ def separate_nontouching_objects(self, input_array, max_val=0):
1837
+ """
1838
+ Ultra-optimized version using find_objects directly without remapping
1839
+ """
1803
1840
  print("Splitting nontouching objects")
1804
-
1841
+
1805
1842
  binary_mask = input_array > 0
1806
- labeled_array, _ = n3d.label_objects(binary_mask)
1843
+ if not np.any(binary_mask):
1844
+ return np.zeros_like(input_array)
1807
1845
 
1808
- # Create a compound key for each (original_label, connected_component) pair
1809
- # This avoids the need for explicit mapping
1810
- mask = binary_mask
1811
- compound_key = input_array[mask] * (labeled_array.max() + 1) + labeled_array[mask]
1846
+ unique_labels = np.unique(input_array[binary_mask])
1847
+ print(f"Processing {len(unique_labels)} unique labels")
1812
1848
 
1813
- # Get unique compound keys and create new labels
1814
- unique_keys, inverse_indices = np.unique(compound_key, return_inverse=True)
1815
- new_labels = np.arange(max_val + 1, max_val + 1 + len(unique_keys))
1849
+ # Get all bounding boxes at once - this is very fast
1850
+ bounding_boxes = ndimage.find_objects(input_array)
1816
1851
 
1817
- # Create output array
1818
- output_array = np.zeros_like(input_array)
1819
- output_array[mask] = new_labels[inverse_indices]
1852
+ # Prepare work items - just check if bounding box exists for each label
1853
+ work_items = []
1854
+ for orig_label in unique_labels:
1855
+ # find_objects returns list where index = label - 1
1856
+ bbox_index = orig_label - 1
1857
+
1858
+ if (bbox_index >= 0 and
1859
+ bbox_index < len(bounding_boxes) and
1860
+ bounding_boxes[bbox_index] is not None):
1861
+
1862
+ bbox = bounding_boxes[bbox_index]
1863
+ work_items.append((orig_label, bbox))
1864
+
1865
+ print(f"Created {len(work_items)} work items")
1866
+
1867
+ # If we have work items, process them
1868
+ if len(work_items) == 0:
1869
+ print("No valid work items found!")
1870
+ return np.zeros_like(input_array)
1871
+
1872
+ def process_label_minimal(item):
1873
+ orig_label, bbox = item
1874
+ try:
1875
+ subarray = input_array[bbox]
1876
+ binary_sub = subarray == orig_label
1877
+
1878
+ if not np.any(binary_sub):
1879
+ return orig_label, bbox, None, 0
1880
+
1881
+ labeled_sub, num_cc = n3d.label_objects(binary_sub)
1882
+ return orig_label, bbox, labeled_sub, num_cc
1883
+
1884
+ except Exception as e:
1885
+ print(f"Error processing label {orig_label}: {e}")
1886
+ return orig_label, bbox, None, 0
1887
+
1888
+ # Execute in parallel
1889
+ max_workers = min(mp.cpu_count(), len(work_items))
1890
+
1891
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
1892
+ results = list(executor.map(process_label_minimal, work_items))
1820
1893
 
1894
+ # Reconstruct output array
1895
+ output_array = np.zeros_like(input_array)
1896
+ current_label = max_val + 1
1897
+ total_components = 0
1898
+
1899
+ for orig_label, bbox, labeled_sub, num_cc in results:
1900
+ if num_cc > 0 and labeled_sub is not None:
1901
+ print(f"Label {orig_label}: {num_cc} components")
1902
+ # Remap labels and place in output
1903
+ for cc_id in range(1, num_cc + 1):
1904
+ mask = labeled_sub == cc_id
1905
+ output_array[bbox][mask] = current_label
1906
+ current_label += 1
1907
+ total_components += 1
1908
+
1909
+ print(f"Total components created: {total_components}")
1821
1910
  return output_array
1822
1911
 
1823
1912
  def handle_seperate(self):
1824
-
1913
+ """
1914
+ Fixed version with proper mask handling and debugging
1915
+ """
1825
1916
  try:
1826
1917
  # Handle nodes
1827
1918
  if len(self.clicked_values['nodes']) > 0:
1828
- self.create_highlight_overlay(node_indices=self.clicked_values['nodes'])
1829
-
1830
- # Create a boolean mask for highlighted values
1831
- self.highlight_overlay = self.highlight_overlay != 0
1832
1919
 
1833
- # Create array with just the highlighted values
1834
- highlighted_nodes = self.highlight_overlay * my_network.nodes
1920
+ # Create highlight overlay (this should preserve original label values)
1921
+ self.create_highlight_overlay(node_indices=self.clicked_values['nodes'])
1922
+
1923
+ # DON'T convert to boolean yet - we need the original labels!
1924
+ # Create a boolean mask for where we have highlighted values
1925
+ highlight_mask = self.highlight_overlay != 0
1835
1926
 
1927
+ # Create array with just the highlighted values (preserving original labels)
1928
+ highlighted_nodes = np.where(highlight_mask, my_network.nodes, 0)
1929
+
1836
1930
  # Get non-highlighted part of the array
1837
- non_highlighted = my_network.nodes * (~self.highlight_overlay)
1838
-
1839
- if (highlighted_nodes==non_highlighted).all():
1840
- max_val = 0
1841
- else:
1842
- max_val = np.max(non_highlighted)
1931
+ non_highlighted = np.where(highlight_mask, 0, my_network.nodes)
1843
1932
 
1933
+ # Calculate max_val properly
1934
+ max_val = np.max(non_highlighted) if np.any(non_highlighted) else 0
1935
+
1844
1936
  # Process highlighted part
1845
1937
  processed_highlights = self.separate_nontouching_objects(highlighted_nodes, max_val)
1846
-
1938
+
1847
1939
  # Combine back with non-highlighted parts
1848
1940
  my_network.nodes = non_highlighted + processed_highlights
1849
-
1941
+
1850
1942
  self.load_channel(0, my_network.nodes, True)
1851
1943
 
1852
1944
  # Handle edges
@@ -1855,18 +1947,15 @@ class ImageViewerWindow(QMainWindow):
1855
1947
  self.create_highlight_overlay(edge_indices=self.clicked_values['edges'])
1856
1948
 
1857
1949
  # Create a boolean mask for highlighted values
1858
- self.highlight_overlay = self.highlight_overlay != 0
1950
+ highlight_mask = self.highlight_overlay != 0
1859
1951
 
1860
1952
  # Create array with just the highlighted values
1861
- highlighted_edges = self.highlight_overlay * my_network.edges
1953
+ highlighted_edges = np.where(highlight_mask, my_network.edges, 0)
1862
1954
 
1863
1955
  # Get non-highlighted part of the array
1864
- non_highlighted = my_network.edges * (~self.highlight_overlay)
1956
+ non_highlighted = np.where(highlight_mask, 0, my_network.edges)
1865
1957
 
1866
- if (highlighted_edges==non_highlighted).all():
1867
- max_val = 0
1868
- else:
1869
- max_val = np.max(non_highlighted)
1958
+ max_val = np.max(non_highlighted) if np.any(non_highlighted) else 0
1870
1959
 
1871
1960
  # Process highlighted part
1872
1961
  processed_highlights = self.separate_nontouching_objects(highlighted_edges, max_val)
@@ -1920,7 +2009,7 @@ class ImageViewerWindow(QMainWindow):
1920
2009
 
1921
2010
 
1922
2011
  if not hasattr(my_network, 'network_lists') or my_network.network_lists is None:
1923
- empty_df = pd.DataFrame(columns=['Node 1A', 'Node 1B', 'Edge 1C'])
2012
+ empty_df = pd.DataFrame(columns=['Node A', 'Node B', 'Edge C'])
1924
2013
  model = PandasModel(empty_df)
1925
2014
  self.network_table.setModel(model)
1926
2015
  else:
@@ -1961,7 +2050,7 @@ class ImageViewerWindow(QMainWindow):
1961
2050
 
1962
2051
  # Update the table
1963
2052
  if not hasattr(my_network, 'network_lists') or my_network.network_lists is None:
1964
- empty_df = pd.DataFrame(columns=['Node 1A', 'Node 1B', 'Edge 1C'])
2053
+ empty_df = pd.DataFrame(columns=['Node A', 'Node B', 'Edge C'])
1965
2054
  model = PandasModel(empty_df)
1966
2055
  self.network_table.setModel(model)
1967
2056
  else:
@@ -1993,7 +2082,7 @@ class ImageViewerWindow(QMainWindow):
1993
2082
  my_network.network_lists = my_network.network_lists
1994
2083
 
1995
2084
  if not hasattr(my_network, 'network_lists') or my_network.network_lists is None:
1996
- empty_df = pd.DataFrame(columns=['Node 1A', 'Node 1B', 'Edge 1C'])
2085
+ empty_df = pd.DataFrame(columns=['Node A', 'Node B', 'Edge C'])
1997
2086
  model = PandasModel(empty_df)
1998
2087
  self.network_table.setModel(model)
1999
2088
  else:
@@ -2044,6 +2133,12 @@ class ImageViewerWindow(QMainWindow):
2044
2133
  print(f"Error: {e}")
2045
2134
 
2046
2135
 
2136
+ def toggle_chan_load(self):
2137
+
2138
+ if self.show_channels.isChecked():
2139
+ self.chan_load = True
2140
+ else:
2141
+ self.chan_load = False
2047
2142
 
2048
2143
  def toggle_highlight(self):
2049
2144
  self.highlight = self.high_button.isChecked()
@@ -2066,11 +2161,6 @@ class ImageViewerWindow(QMainWindow):
2066
2161
  if self.zoom_mode:
2067
2162
  self.pan_button.setChecked(False)
2068
2163
 
2069
- if self.pan_mode or self.brush_mode:
2070
- current_xlim = self.ax.get_xlim()
2071
- current_ylim = self.ax.get_ylim()
2072
- self.update_display(preserve_zoom=(current_xlim, current_ylim))
2073
-
2074
2164
  self.pen_button.setChecked(False)
2075
2165
  self.pan_mode = False
2076
2166
  self.brush_mode = False
@@ -2080,6 +2170,11 @@ class ImageViewerWindow(QMainWindow):
2080
2170
  if self.machine_window is not None:
2081
2171
  self.machine_window.silence_button()
2082
2172
  self.canvas.setCursor(Qt.CursorShape.CrossCursor)
2173
+ if self.pan_mode or self.brush_mode:
2174
+ current_xlim = self.ax.get_xlim()
2175
+ current_ylim = self.ax.get_ylim()
2176
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
2177
+
2083
2178
  else:
2084
2179
  if self.machine_window is None:
2085
2180
  self.canvas.setCursor(Qt.CursorShape.ArrowCursor)
@@ -2091,10 +2186,6 @@ class ImageViewerWindow(QMainWindow):
2091
2186
  """Toggle pan mode on/off."""
2092
2187
  self.pan_mode = self.pan_button.isChecked()
2093
2188
  if self.pan_mode:
2094
- if self.brush_mode:
2095
- current_xlim = self.ax.get_xlim()
2096
- current_ylim = self.ax.get_ylim()
2097
- self.update_display(preserve_zoom=(current_xlim, current_ylim))
2098
2189
 
2099
2190
  self.zoom_button.setChecked(False)
2100
2191
  self.pen_button.setChecked(False)
@@ -2106,6 +2197,10 @@ class ImageViewerWindow(QMainWindow):
2106
2197
  if self.machine_window is not None:
2107
2198
  self.machine_window.silence_button()
2108
2199
  self.canvas.setCursor(Qt.CursorShape.OpenHandCursor)
2200
+ if self.brush_mode:
2201
+ current_xlim = self.ax.get_xlim()
2202
+ current_ylim = self.ax.get_ylim()
2203
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
2109
2204
  else:
2110
2205
  current_xlim = self.ax.get_xlim()
2111
2206
  current_ylim = self.ax.get_ylim()
@@ -2120,6 +2215,20 @@ class ImageViewerWindow(QMainWindow):
2120
2215
  self.brush_mode = self.pen_button.isChecked()
2121
2216
  if self.brush_mode:
2122
2217
 
2218
+ self.pm = painting.PaintManager(parent = self)
2219
+
2220
+ # Start virtual paint session
2221
+ # Get current zoom to preserve it
2222
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2223
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2224
+
2225
+ if self.pen_button.isChecked():
2226
+ channel = self.active_channel
2227
+ else:
2228
+ channel = 2
2229
+
2230
+ self.pm.initiate_paint_session(channel, current_xlim, current_ylim)
2231
+
2123
2232
  if self.pan_mode:
2124
2233
  current_xlim = self.ax.get_xlim()
2125
2234
  current_ylim = self.ax.get_ylim()
@@ -2339,37 +2448,6 @@ class ImageViewerWindow(QMainWindow):
2339
2448
 
2340
2449
  painter.end()
2341
2450
 
2342
- def get_line_points(self, x0, y0, x1, y1):
2343
- """Get all points in a line between (x0,y0) and (x1,y1) using Bresenham's algorithm."""
2344
- points = []
2345
- dx = abs(x1 - x0)
2346
- dy = abs(y1 - y0)
2347
- x, y = x0, y0
2348
- sx = 1 if x0 < x1 else -1
2349
- sy = 1 if y0 < y1 else -1
2350
-
2351
- if dx > dy:
2352
- err = dx / 2.0
2353
- while x != x1:
2354
- points.append((x, y))
2355
- err -= dy
2356
- if err < 0:
2357
- y += sy
2358
- err += dx
2359
- x += sx
2360
- else:
2361
- err = dy / 2.0
2362
- while y != y1:
2363
- points.append((x, y))
2364
- err -= dx
2365
- if err < 0:
2366
- x += sx
2367
- err += dy
2368
- y += sy
2369
-
2370
- points.append((x, y))
2371
- return points
2372
-
2373
2451
  def get_current_mouse_position(self):
2374
2452
  # Get the main application's current mouse position
2375
2453
  cursor_pos = QCursor.pos()
@@ -2382,15 +2460,25 @@ class ImageViewerWindow(QMainWindow):
2382
2460
  0 <= canvas_pos.y() < self.canvas.height()):
2383
2461
  return 0, 0 # Mouse is outside of the matplotlib canvas
2384
2462
 
2385
- # Convert from canvas widget coordinates to matplotlib data coordinates
2386
- x = canvas_pos.x()
2387
- y = canvas_pos.y()
2388
-
2389
- # Transform display coordinates to data coordinates
2390
- inv = self.ax.transData.inverted()
2391
- data_coords = inv.transform((x, y))
2392
-
2393
- return data_coords[0], data_coords[1]
2463
+ # OPTION 1: Use matplotlib's built-in coordinate conversion
2464
+ # This accounts for figure margins, subplot positioning, etc.
2465
+ try:
2466
+ # Get the figure and axes bounds
2467
+ bbox = self.ax.bbox
2468
+
2469
+ # Convert widget coordinates to figure coordinates
2470
+ fig_x = canvas_pos.x()
2471
+ fig_y = self.canvas.height() - canvas_pos.y() # Flip Y coordinate
2472
+
2473
+ # Check if within axes bounds
2474
+ if (bbox.x0 <= fig_x <= bbox.x1 and bbox.y0 <= fig_y <= bbox.y1):
2475
+ # Transform to data coordinates
2476
+ data_coords = self.ax.transData.inverted().transform((fig_x, fig_y))
2477
+ return data_coords[0], data_coords[1]
2478
+ else:
2479
+ return 0, 0
2480
+ except:
2481
+ pass
2394
2482
 
2395
2483
  def on_mouse_press(self, event):
2396
2484
  """Handle mouse press events."""
@@ -2429,61 +2517,50 @@ class ImageViewerWindow(QMainWindow):
2429
2517
 
2430
2518
 
2431
2519
  elif self.brush_mode:
2520
+ """Handle brush mode with virtual painting."""
2432
2521
  if event.inaxes != self.ax:
2433
2522
  return
2434
2523
 
2435
2524
  if event.button == 1 or event.button == 3:
2525
+ if self.machine_window is not None:
2526
+ if self.machine_window.segmentation_worker is not None:
2527
+ self.machine_window.segmentation_worker.pause()
2528
+
2436
2529
  x, y = int(event.xdata), int(event.ydata)
2437
- # Get current zoom to preserve it
2438
-
2530
+
2531
+ # Get current zoom to preserve it
2439
2532
  current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2440
2533
  current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2441
-
2442
-
2443
- if event.button == 1 and self.can:
2444
- self.update_display(preserve_zoom = (current_xlim, current_ylim))
2534
+
2535
+ if event.button == 1 and getattr(self, 'can', False):
2536
+ self.update_display(preserve_zoom=(current_xlim, current_ylim), skip_paint_reinit = True)
2445
2537
  self.handle_can(x, y)
2446
2538
  return
2447
-
2539
+
2540
+ # Determine erase mode and foreground/background
2448
2541
  if event.button == 3:
2449
2542
  self.erase = True
2450
- self.update_display(preserve_zoom = (current_xlim, current_ylim))
2451
2543
  else:
2452
2544
  self.erase = False
2453
-
2454
- self.painting = True
2455
- self.last_paint_pos = (x, y)
2456
-
2545
+
2546
+ # Determine foreground/background for machine window mode
2547
+ foreground = getattr(self, 'foreground', True)
2548
+
2549
+ self.last_virtual_pos = (x, y)
2550
+
2457
2551
  if self.pen_button.isChecked():
2458
2552
  channel = self.active_channel
2459
2553
  else:
2460
2554
  channel = 2
2461
2555
 
2462
- # Paint at initial position
2463
- self.paint_at_position(x, y, self.erase, channel)
2464
-
2465
- self.canvas.draw()
2466
-
2467
- self.restore_channels = []
2468
- if not self.channel_visible[channel]:
2469
- self.channel_visible[channel] = True
2470
-
2471
- # No need to hide other channels or track restore_channels
2472
- self.restore_channels = []
2473
-
2474
- if self.static_background is None:
2475
- if self.machine_window is not None:
2476
- self.update_display(preserve_zoom = (current_xlim, current_ylim))
2477
- elif not self.erase:
2478
- self.temp_chan = channel
2479
- self.channel_data[4] = self.channel_data[channel]
2480
- self.min_max[4] = copy.deepcopy(self.min_max[channel])
2481
- self.channel_brightness[4] = copy.deepcopy(self.channel_brightness[channel])
2482
- self.load_channel(channel, np.zeros_like(self.channel_data[channel]), data = True, preserve_zoom = (current_xlim, current_ylim), begin_paint = True)
2483
- self.channel_visible[4] = True
2484
- self.static_background = self.canvas.copy_from_bbox(self.ax.bbox)
2485
-
2486
- self.update_display_slice_optimized(channel, preserve_zoom=(current_xlim, current_ylim))
2556
+ self.pm.start_virtual_paint_session(channel, current_xlim, current_ylim)
2557
+
2558
+ # Add first virtual paint stroke
2559
+ brush_size = getattr(self, 'brush_size', 5)
2560
+ self.pm.add_virtual_paint_stroke(x, y, brush_size, self.erase, foreground)
2561
+
2562
+ # Update display with virtual paint
2563
+ self.pm.update_virtual_paint_display()
2487
2564
 
2488
2565
  elif not self.zoom_mode and event.button == 3: # Right click (for context menu)
2489
2566
  self.create_context_menu(event)
@@ -2493,92 +2570,6 @@ class ImageViewerWindow(QMainWindow):
2493
2570
  self.selection_start = (event.xdata, event.ydata)
2494
2571
  self.selecting = False # Will be set to True if the mouse moves while button is held
2495
2572
 
2496
- def paint_at_position(self, center_x, center_y, erase = False, channel = 2):
2497
- """Paint pixels within brush radius at given position"""
2498
- if self.channel_data[channel] is None:
2499
- return
2500
-
2501
- if erase:
2502
- val = 0
2503
- elif self.machine_window is None:
2504
- try:
2505
- val = max(255, self.min_max[4][1])
2506
- except:
2507
- val = 255
2508
- elif self.foreground:
2509
- val = 1
2510
- else:
2511
- val = 2
2512
- height, width = self.channel_data[channel][self.current_slice].shape
2513
- radius = self.brush_size // 2
2514
-
2515
- # Calculate brush area
2516
- for y in range(max(0, center_y - radius), min(height, center_y + radius + 1)):
2517
- for x in range(max(0, center_x - radius), min(width, center_x + radius + 1)):
2518
- # Check if point is within circular brush area
2519
- if (x - center_x) * 2 + (y - center_y) * 2 <= radius ** 2:
2520
- if self.threed and self.threedthresh > 1:
2521
- amount = (self.threedthresh - 1) / 2
2522
- low = max(0, self.current_slice - amount)
2523
- high = min(self.channel_data[channel].shape[0] - 1, self.current_slice + amount)
2524
- for i in range(int(low), int(high + 1)):
2525
- self.channel_data[channel][i][y, x] = val
2526
- else:
2527
- self.channel_data[channel][self.current_slice][y, x] = val
2528
-
2529
- def paint_at_position_vectorized(self, center_x, center_y, erase=False, channel=2,
2530
- slice_idx=None, brush_size=None, threed=False,
2531
- threedthresh=1, foreground=True, machine_window=None):
2532
- """Vectorized paint operation for better performance."""
2533
- if self.channel_data[channel] is None:
2534
- return
2535
-
2536
- # Use provided parameters or fall back to instance variables
2537
- slice_idx = slice_idx if slice_idx is not None else self.current_slice
2538
- brush_size = brush_size if brush_size is not None else getattr(self, 'brush_size', 5)
2539
-
2540
- # Determine paint value
2541
- if erase:
2542
- val = 0
2543
- elif machine_window is None:
2544
- try:
2545
- val = max(255, self.min_max[4][1])
2546
- except:
2547
- val = 255
2548
- elif foreground:
2549
- val = 1
2550
- else:
2551
- val = 2
2552
-
2553
- height, width = self.channel_data[channel][slice_idx].shape
2554
- radius = brush_size // 2
2555
-
2556
- # Calculate affected region bounds
2557
- y_min = max(0, center_y - radius)
2558
- y_max = min(height, center_y + radius + 1)
2559
- x_min = max(0, center_x - radius)
2560
- x_max = min(width, center_x + radius + 1)
2561
-
2562
- if y_min >= y_max or x_min >= x_max:
2563
- return # No valid region to paint
2564
-
2565
- # Create coordinate grids for the affected region
2566
- y_coords, x_coords = np.mgrid[y_min:y_max, x_min:x_max]
2567
-
2568
- # Calculate distances squared (avoid sqrt for performance)
2569
- distances_sq = (x_coords - center_x) ** 2 + (y_coords - center_y) ** 2
2570
- mask = distances_sq <= radius ** 2
2571
-
2572
- # Apply paint to affected slices
2573
- if threed and threedthresh > 1:
2574
- amount = (threedthresh - 1) / 2
2575
- low = max(0, int(slice_idx - amount))
2576
- high = min(self.channel_data[channel].shape[0] - 1, int(slice_idx + amount))
2577
-
2578
- for i in range(low, high + 1):
2579
- self.channel_data[channel][i][y_min:y_max, x_min:x_max][mask] = val
2580
- else:
2581
- self.channel_data[channel][slice_idx][y_min:y_max, x_min:x_max][mask] = val
2582
2573
 
2583
2574
  def handle_can(self, x, y):
2584
2575
 
@@ -2648,6 +2639,7 @@ class ImageViewerWindow(QMainWindow):
2648
2639
  return
2649
2640
 
2650
2641
  current_time = time.time()
2642
+ self.rect_time = current_time
2651
2643
 
2652
2644
  if self.selection_start and not self.selecting and not self.pan_mode and not self.brush_mode:
2653
2645
  if (abs(event.xdata - self.selection_start[0]) > 1 or
@@ -2718,129 +2710,24 @@ class ImageViewerWindow(QMainWindow):
2718
2710
  if event.inaxes != self.ax:
2719
2711
  return
2720
2712
 
2721
- # OPTIMIZED: Queue paint operation instead of immediate execution
2722
- self.queue_paint_operation(event)
2713
+ # Throttle updates like selection rectangle
2714
+ current_time = time.time()
2715
+ if current_time - getattr(self, 'last_paint_update_time', 0) < 0.016: # ~60fps
2716
+ return
2717
+ self.last_paint_update_time = current_time
2723
2718
 
2724
- # OPTIMIZED: Schedule display update at controlled frequency
2725
- if not self.pending_paint_update:
2726
- self.pending_paint_update = True
2727
- self.paint_timer.start(16) # ~60fps max update rate
2728
-
2729
- def queue_paint_operation(self, event):
2730
- """Queue a paint operation for background processing."""
2731
- x, y = int(event.xdata), int(event.ydata)
2732
-
2733
- if self.pen_button.isChecked():
2734
- channel = self.active_channel
2735
- else:
2736
- channel = 2
2737
-
2738
- if self.channel_data[channel] is not None:
2739
- # Prepare paint session if needed
2740
- if not self.paint_session_active:
2741
- self.prepare_paint_session(channel)
2742
-
2743
- # Create paint operation
2744
- paint_op = {
2745
- 'type': 'stroke',
2746
- 'x': x,
2747
- 'y': y,
2748
- 'last_pos': getattr(self, 'last_paint_pos', None),
2749
- 'brush_size': self.brush_size,
2750
- 'erase': self.erase,
2751
- 'channel': channel,
2752
- 'slice': self.current_slice,
2753
- 'threed': getattr(self, 'threed', False),
2754
- 'threedthresh': getattr(self, 'threedthresh', 1),
2755
- 'foreground': getattr(self, 'foreground', True),
2756
- 'machine_window': getattr(self, 'machine_window', None)
2757
- }
2719
+ x, y = int(event.xdata), int(event.ydata)
2758
2720
 
2759
- # Queue the operation
2760
- try:
2761
- self.paint_queue.put_nowait(paint_op)
2762
- except queue.Full:
2763
- pass # Skip if queue is full to avoid blocking
2721
+ # Determine foreground/background for machine window mode
2722
+ foreground = getattr(self, 'foreground', True)
2764
2723
 
2765
- self.last_paint_pos = (x, y)
2766
-
2767
- def prepare_paint_session(self, channel):
2768
- """Prepare optimized background for blitting during paint session."""
2769
- if self.paint_session_active:
2770
- return
2724
+ # Add virtual paint stroke with interpolation
2725
+ brush_size = getattr(self, 'brush_size', 5)
2726
+ self.pm.add_virtual_paint_stroke(x, y, brush_size, self.erase, foreground)
2771
2727
 
2772
- # IMPORTANT: Don't capture background here - let the main display update handle it
2773
- # We'll capture the background after the proper channel visibility setup
2774
- self.paint_session_active = True
2775
-
2776
- def end_paint_session(self):
2777
- """Clean up after paint session."""
2778
- self.paint_session_active = False
2779
- self.last_paint_pos = None
2780
-
2781
- def paint_worker_loop(self):
2782
- """Background thread for processing paint operations."""
2783
- while True:
2784
- try:
2785
- paint_op = self.paint_queue.get(timeout=1.0)
2786
- if paint_op is None: # Shutdown signal
2787
- break
2788
-
2789
- with self.paint_lock:
2790
- self.execute_paint_operation(paint_op)
2791
-
2792
- except queue.Empty:
2793
- continue
2794
-
2795
- def shutdown(self):
2796
- """Clean shutdown of worker thread."""
2797
- self.paint_queue.put(None) # Signal worker to stop
2798
- if hasattr(self, 'paint_worker'):
2799
- self.paint_worker.join(timeout=1.0)
2800
-
2801
- def execute_paint_operation(self, paint_op):
2802
- """Execute a single paint operation on the data arrays."""
2803
- if paint_op['type'] == 'stroke':
2804
- channel = paint_op['channel']
2805
- x, y = paint_op['x'], paint_op['y']
2806
- last_pos = paint_op['last_pos']
2807
-
2808
- if last_pos is not None:
2809
- # Paint line from last position to current
2810
- points = self.get_line_points(last_pos[0], last_pos[1], x, y)
2811
- for px, py in points:
2812
- height, width = self.channel_data[channel][paint_op['slice']].shape
2813
- if 0 <= px < width and 0 <= py < height:
2814
- self.paint_at_position_vectorized(
2815
- px, py, paint_op['erase'], paint_op['channel'],
2816
- paint_op['slice'], paint_op['brush_size'],
2817
- paint_op['threed'], paint_op['threedthresh'],
2818
- paint_op['foreground'], paint_op['machine_window']
2819
- )
2820
- else:
2821
- # Single point paint
2822
- height, width = self.channel_data[channel][paint_op['slice']].shape
2823
- if 0 <= x < width and 0 <= y < height:
2824
- self.paint_at_position_vectorized(
2825
- x, y, paint_op['erase'], paint_op['channel'],
2826
- paint_op['slice'], paint_op['brush_size'],
2827
- paint_op['threed'], paint_op['threedthresh'],
2828
- paint_op['foreground'], paint_op['machine_window']
2829
- )
2728
+ # Update display with virtual paint (super fast)
2729
+ self.pm.update_virtual_paint_display()
2830
2730
 
2831
- def flush_paint_updates(self):
2832
- """Update the display with batched paint changes."""
2833
- self.pending_paint_update = False
2834
-
2835
- # Determine which channel to update
2836
- channel = self.active_channel if hasattr(self, 'pen_button') and self.pen_button.isChecked() else 2
2837
-
2838
- # Get current zoom to preserve it
2839
- current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2840
- current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2841
-
2842
- # Update display
2843
- self.update_display_slice_optimized(channel, preserve_zoom=(current_xlim, current_ylim))
2844
2731
 
2845
2732
  def create_pan_background(self):
2846
2733
  """Create a static background image from currently visible channels with proper rendering"""
@@ -3062,6 +2949,12 @@ class ImageViewerWindow(QMainWindow):
3062
2949
 
3063
2950
  def on_mouse_release(self, event):
3064
2951
  """Handle mouse release events"""
2952
+
2953
+ if self.zoom_mode:
2954
+ rect_condition = (time.time() - self.rect_time) > 0.01 # This is just to prevent non-deliberate rectangle zooming
2955
+ else:
2956
+ rect_condition = True
2957
+
3065
2958
  if self.pan_mode:
3066
2959
 
3067
2960
  self.panning = False
@@ -3069,7 +2962,7 @@ class ImageViewerWindow(QMainWindow):
3069
2962
  self.canvas.setCursor(Qt.CursorShape.OpenHandCursor)
3070
2963
 
3071
2964
  elif event.button == 1: # Left button release
3072
- if self.selecting and self.selection_rect is not None:
2965
+ if rect_condition and self.selecting and self.selection_rect is not None:
3073
2966
  # Get the rectangle bounds
3074
2967
  x0 = min(self.selection_start[0], event.xdata)
3075
2968
  y0 = min(self.selection_start[1], event.ydata)
@@ -3077,7 +2970,13 @@ class ImageViewerWindow(QMainWindow):
3077
2970
  height = abs(event.ydata - self.selection_start[1])
3078
2971
  shift_pressed = 'shift' in event.modifiers
3079
2972
 
3080
- if shift_pressed or self.zoom_mode: #Optional targeted zoom
2973
+ if shift_pressed:
2974
+
2975
+ args = int(x0), int(x0 + width), int(y0), int(y0 + height)
2976
+
2977
+ self.show_crop_dialog(args)
2978
+
2979
+ elif self.zoom_mode: #Optional targeted zoom
3081
2980
 
3082
2981
  self.ax.set_xlim([x0, x0 + width])
3083
2982
  self.ax.set_ylim([y0 + height, y0])
@@ -3205,32 +3104,23 @@ class ImageViewerWindow(QMainWindow):
3205
3104
 
3206
3105
  # Handle brush mode cleanup with paint session management
3207
3106
  if self.brush_mode and hasattr(self, 'painting') and self.painting:
3107
+ # Finish current operation
3108
+ self.pm.finish_current_virtual_operation()
3109
+
3110
+ # Reset last position for next stroke
3111
+ self.last_virtual_pos = None
3112
+
3113
+ # End this stroke but keep session active for continuous painting
3208
3114
  self.painting = False
3209
3115
 
3210
- if self.erase:
3211
- # Restore hidden channels
3212
- try:
3213
- for i in self.restore_channels:
3214
- self.channel_visible[i] = True
3215
- self.restore_channels = []
3216
- except:
3217
- pass
3218
-
3219
- self.end_paint_session()
3220
-
3221
- # OPTIMIZED: Stop timer and process any pending paint operations
3222
- if hasattr(self, 'paint_timer'):
3223
- self.paint_timer.stop()
3224
- if hasattr(self, 'pending_paint_update') and self.pending_paint_update:
3225
- self.flush_paint_updates()
3226
-
3227
- self.static_background = None
3228
-
3229
- current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3116
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3117
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3230
3118
 
3231
- current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3119
+ self.update_display(preserve_zoom=(current_xlim, current_ylim), continue_paint = True)
3232
3120
 
3233
- self.update_display(preserve_zoom = (current_xlim, current_ylim))
3121
+ if self.machine_window is not None:
3122
+ if self.machine_window.segmentation_worker is not None:
3123
+ self.machine_window.segmentation_worker.resume()
3234
3124
 
3235
3125
 
3236
3126
 
@@ -3539,7 +3429,7 @@ class ImageViewerWindow(QMainWindow):
3539
3429
  ripley_action.triggered.connect(self.show_ripley_dialog)
3540
3430
  heatmap_action = stats_menu.addAction("Community Cluster Heatmap")
3541
3431
  heatmap_action.triggered.connect(self.show_heatmap_dialog)
3542
- nearneigh_action = stats_menu.addAction("Average Nearest Neighbors")
3432
+ nearneigh_action = stats_menu.addAction("Average Nearest Neighbors (With Clustering Heatmaps)")
3543
3433
  nearneigh_action.triggered.connect(self.show_nearneigh_dialog)
3544
3434
  vol_action = stats_menu.addAction("Calculate Volumes")
3545
3435
  vol_action.triggered.connect(self.volumes)
@@ -3558,6 +3448,8 @@ class ImageViewerWindow(QMainWindow):
3558
3448
  community_code_action.triggered.connect(lambda: self.show_code_dialog(sort = 'Community'))
3559
3449
  id_code_action = overlay_menu.addAction("Code Identities")
3560
3450
  id_code_action.triggered.connect(lambda: self.show_code_dialog(sort = 'Identity'))
3451
+ umap_action = overlay_menu.addAction("Centroid UMAP")
3452
+ umap_action.triggered.connect(self.handle_umap)
3561
3453
 
3562
3454
  rand_menu = analysis_menu.addMenu("Randomize")
3563
3455
  random_action = rand_menu.addAction("Generate Equivalent Random Network")
@@ -3574,6 +3466,10 @@ class ImageViewerWindow(QMainWindow):
3574
3466
  calc_all_action.triggered.connect(self.show_calc_all_dialog)
3575
3467
  calc_prox_action = calculate_menu.addAction("Calculate Proximity Network (connect nodes by distance)")
3576
3468
  calc_prox_action.triggered.connect(self.show_calc_prox_dialog)
3469
+ calc_branch_action = calculate_menu.addAction("Calculate Branchpoint Network (Connect Branchpoints of Edge Image - Good for Nerves/Vessels)")
3470
+ calc_branch_action.triggered.connect(self.handle_calc_branch)
3471
+ calc_branchprox_action = calculate_menu.addAction("Calculate Branch Adjacency Network (Of Edges)")
3472
+ calc_branchprox_action.triggered.connect(self.handle_branchprox_calc)
3577
3473
  centroid_action = calculate_menu.addAction("Calculate Centroids (Active Image)")
3578
3474
  centroid_action.triggered.connect(self.show_centroid_dialog)
3579
3475
 
@@ -3597,13 +3493,17 @@ class ImageViewerWindow(QMainWindow):
3597
3493
  mask_action = image_menu.addAction("Mask Channel")
3598
3494
  mask_action.triggered.connect(self.show_mask_dialog)
3599
3495
  crop_action = image_menu.addAction("Crop Channels")
3600
- crop_action.triggered.connect(self.show_crop_dialog)
3496
+ crop_action.triggered.connect(lambda: self.show_crop_dialog(args = None))
3601
3497
  type_action = image_menu.addAction("Channel dtype")
3602
3498
  type_action.triggered.connect(self.show_type_dialog)
3603
3499
  skeletonize_action = image_menu.addAction("Skeletonize")
3604
3500
  skeletonize_action.triggered.connect(self.show_skeletonize_dialog)
3605
- watershed_action = image_menu.addAction("Watershed")
3501
+ dt_action = image_menu.addAction("Distance Transform (For binary images)")
3502
+ dt_action.triggered.connect(self.show_dt_dialog)
3503
+ watershed_action = image_menu.addAction("Binary Watershed")
3606
3504
  watershed_action.triggered.connect(self.show_watershed_dialog)
3505
+ gray_water_action = image_menu.addAction("Gray Watershed")
3506
+ gray_water_action.triggered.connect(self.show_gray_water_dialog)
3607
3507
  invert_action = image_menu.addAction("Invert")
3608
3508
  invert_action.triggered.connect(self.show_invert_dialog)
3609
3509
  z_proj_action = image_menu.addAction("Z Project")
@@ -3615,7 +3515,7 @@ class ImageViewerWindow(QMainWindow):
3615
3515
  gennodes_action = generate_menu.addAction("Generate Nodes (From 'Edge' Vertices)")
3616
3516
  gennodes_action.triggered.connect(self.show_gennodes_dialog)
3617
3517
  branch_action = generate_menu.addAction("Label Branches")
3618
- branch_action.triggered.connect(self.show_branch_dialog)
3518
+ branch_action.triggered.connect(lambda: self.show_branch_dialog())
3619
3519
  genvor_action = generate_menu.addAction("Generate Voronoi Diagram (From Node Centroids) - goes in Overlay2")
3620
3520
  genvor_action.triggered.connect(self.voronoi)
3621
3521
 
@@ -3654,6 +3554,43 @@ class ImageViewerWindow(QMainWindow):
3654
3554
  help_button = menubar.addAction("Help")
3655
3555
  help_button.triggered.connect(self.help_me)
3656
3556
 
3557
+ cam_button = QPushButton("📷")
3558
+ cam_button.setFixedSize(40, 40)
3559
+ cam_button.setStyleSheet("font-size: 24px;") # Makes emoji larger
3560
+ cam_button.clicked.connect(self.snap)
3561
+ menubar.setCornerWidget(cam_button, Qt.Corner.TopRightCorner)
3562
+
3563
+ def snap(self):
3564
+
3565
+ try:
3566
+
3567
+ for thing in self.channel_data:
3568
+ if thing is not None:
3569
+ data = True
3570
+ if not data:
3571
+ return
3572
+
3573
+ snap = self.create_composite_for_pan()
3574
+
3575
+ filename, _ = QFileDialog.getSaveFileName(
3576
+ self,
3577
+ f"Save Image As",
3578
+ "", # Default directory
3579
+ "TIFF Files (*.tif *.tiff);;All Files (*)" # File type filter
3580
+ )
3581
+
3582
+ if filename: # Only proceed if user didn't cancel
3583
+ # If user didn't type an extension, add .tif
3584
+ if not filename.endswith(('.tif', '.tiff')):
3585
+ filename += '.tif'
3586
+
3587
+ import tifffile
3588
+ tifffile.imwrite(filename, snap)
3589
+
3590
+ except:
3591
+ pass
3592
+
3593
+
3657
3594
  def open_cellpose(self):
3658
3595
 
3659
3596
  try:
@@ -3919,6 +3856,10 @@ class ImageViewerWindow(QMainWindow):
3919
3856
  dialog = MergeNodeIdDialog(self)
3920
3857
  dialog.exec()
3921
3858
 
3859
+ def show_gray_water_dialog(self):
3860
+ """Show the gray watershed parameter dialog."""
3861
+ dialog = GrayWaterDialog(self)
3862
+ dialog.exec()
3922
3863
 
3923
3864
  def show_watershed_dialog(self):
3924
3865
  """Show the watershed parameter dialog."""
@@ -3950,6 +3891,110 @@ class ImageViewerWindow(QMainWindow):
3950
3891
  dialog = ProxDialog(self)
3951
3892
  dialog.exec()
3952
3893
 
3894
+ def table_load_attrs(self):
3895
+
3896
+ # Display network_lists in the network table
3897
+ try:
3898
+ if hasattr(my_network, 'network_lists'):
3899
+ model = PandasModel(my_network.network_lists)
3900
+ self.network_table.setModel(model)
3901
+ # Adjust column widths to content
3902
+ for column in range(model.columnCount(None)):
3903
+ self.network_table.resizeColumnToContents(column)
3904
+ except Exception as e:
3905
+ print(f"Error loading network_lists: {e}")
3906
+
3907
+ #Display the other things if they exist
3908
+ try:
3909
+
3910
+ if hasattr(my_network, 'node_identities') and my_network.node_identities is not None:
3911
+ try:
3912
+ self.format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity', 'Node Identities')
3913
+ except Exception as e:
3914
+ print(f"Error loading node identity table: {e}")
3915
+
3916
+ if hasattr(my_network, 'node_centroids') and my_network.node_centroids is not None:
3917
+ try:
3918
+ self.format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
3919
+ except Exception as e:
3920
+ print(f"Error loading node centroid table: {e}")
3921
+
3922
+
3923
+ if hasattr(my_network, 'edge_centroids') and my_network.edge_centroids is not None:
3924
+ try:
3925
+ self.format_for_upperright_table(my_network.edge_centroids, 'EdgeID', ['Z', 'Y', 'X'], 'Edge Centroids')
3926
+ except Exception as e:
3927
+ print(f"Error loading edge centroid table: {e}")
3928
+
3929
+
3930
+ except Exception as e:
3931
+ print(f"An error has occured: {e}")
3932
+
3933
+ def confirm_calcbranch_dialog(self, message):
3934
+ """Shows a dialog asking user to confirm if they want to proceed below"""
3935
+ msg = QMessageBox()
3936
+ msg.setIcon(QMessageBox.Icon.Question)
3937
+ msg.setText("Alert")
3938
+ msg.setInformativeText(message)
3939
+ msg.setWindowTitle("Proceed?")
3940
+ msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
3941
+ return msg.exec() == QMessageBox.StandardButton.Yes
3942
+
3943
+ def handle_calc_branch(self):
3944
+
3945
+ try:
3946
+
3947
+ if self.channel_data[0] is not None or self.channel_data[3] is not None:
3948
+ if not self.confirm_calcbranch_dialog("Use of this feature will require additional use of the Nodes and Overlay 2 channels. Please save any data and return, or proceed if you do not need those channels' data"):
3949
+ return
3950
+
3951
+ my_network.id_overlay = my_network.edges.copy()
3952
+
3953
+ self.show_gennodes_dialog()
3954
+
3955
+ my_network.edges = (my_network.nodes == 0) * my_network.edges
3956
+
3957
+ my_network.calculate_all(my_network.nodes, my_network.edges, xy_scale = my_network.xy_scale, z_scale = my_network.z_scale, search = None, diledge = None, inners = False, hash_inners = False, remove_trunk = 0, ignore_search_region = True, other_nodes = None, label_nodes = True, directory = None, GPU = False, fast_dil = False, skeletonize = False, GPU_downsample = None)
3958
+
3959
+ self.load_channel(1, my_network.edges, data = True)
3960
+ self.load_channel(0, my_network.nodes, data = True)
3961
+ self.load_channel(3, my_network.id_overlay, data = True)
3962
+
3963
+ self.table_load_attrs()
3964
+
3965
+ except Exception as e:
3966
+
3967
+ try:
3968
+ my_network.edges = my_network.id_overlay
3969
+ my_network.id_overlay = None
3970
+ except:
3971
+ pass
3972
+
3973
+ print(f"Error calculating branchpoint network: {e}")
3974
+
3975
+ def handle_branchprox_calc(self):
3976
+
3977
+ try:
3978
+
3979
+ if self.channel_data[0] is not None:
3980
+ if not self.confirm_calcbranch_dialog("Use of this feature will require additional use of the Nodes and Overlay 2 channels. Please save any data and return, or proceed if you do not need those channels' data"):
3981
+ return
3982
+
3983
+ self.show_branch_dialog(called = True)
3984
+
3985
+ self.load_channel(0, my_network.edges, data = True)
3986
+
3987
+ self.delete_channel(1, False)
3988
+
3989
+ my_network.morph_proximity(search = [3,3], fastdil = True)
3990
+
3991
+ self.table_load_attrs()
3992
+
3993
+ except Exception as e:
3994
+
3995
+ print(f"Error calculating network: {e}")
3996
+
3997
+
3953
3998
  def show_centroid_dialog(self):
3954
3999
  """show the centroid dialog"""
3955
4000
  dialog = CentroidDialog(self)
@@ -3994,9 +4039,9 @@ class ImageViewerWindow(QMainWindow):
3994
4039
  dialog = MaskDialog(self)
3995
4040
  dialog.exec()
3996
4041
 
3997
- def show_crop_dialog(self):
4042
+ def show_crop_dialog(self, args = None):
3998
4043
  """Show the crop dialog"""
3999
- dialog = CropDialog(self)
4044
+ dialog = CropDialog(self, args = args)
4000
4045
  dialog.exec()
4001
4046
 
4002
4047
  def show_type_dialog(self):
@@ -4012,6 +4057,11 @@ class ImageViewerWindow(QMainWindow):
4012
4057
  dialog = SkeletonizeDialog(self)
4013
4058
  dialog.exec()
4014
4059
 
4060
+ def show_dt_dialog(self):
4061
+ """show the dt dialog"""
4062
+ dialog = DistanceDialog(self)
4063
+ dialog.exec()
4064
+
4015
4065
  def show_centroid_node_dialog(self):
4016
4066
  """show the centroid node dialog"""
4017
4067
  dialog = CentroidNodeDialog(self)
@@ -4023,9 +4073,9 @@ class ImageViewerWindow(QMainWindow):
4023
4073
  gennodes = GenNodesDialog(self, down_factor = down_factor, called = called)
4024
4074
  gennodes.exec()
4025
4075
 
4026
- def show_branch_dialog(self):
4076
+ def show_branch_dialog(self, called = False):
4027
4077
  """Show the branch label dialog"""
4028
- dialog = BranchDialog(self)
4078
+ dialog = BranchDialog(self, called = called)
4029
4079
  dialog.exec()
4030
4080
 
4031
4081
  def voronoi(self):
@@ -4178,6 +4228,7 @@ class ImageViewerWindow(QMainWindow):
4178
4228
  if sort == 'Node Identities':
4179
4229
  my_network.load_node_identities(file_path = filename)
4180
4230
 
4231
+ """
4181
4232
  first_value = list(my_network.node_identities.values())[0] # Check that there are not multiple IDs
4182
4233
  if isinstance(first_value, (list, tuple)):
4183
4234
  trump_value, ok = QInputDialog.getText(
@@ -4199,7 +4250,7 @@ class ImageViewerWindow(QMainWindow):
4199
4250
  else:
4200
4251
  trump_value = None
4201
4252
  my_network.node_identities = uncork(my_network.node_identities, trump_value)
4202
-
4253
+ """
4203
4254
 
4204
4255
  if hasattr(my_network, 'node_identities') and my_network.node_identities is not None:
4205
4256
  try:
@@ -4316,6 +4367,7 @@ class ImageViewerWindow(QMainWindow):
4316
4367
  )
4317
4368
 
4318
4369
  self.last_load = directory
4370
+
4319
4371
 
4320
4372
  if directory != "":
4321
4373
 
@@ -4354,7 +4406,7 @@ class ImageViewerWindow(QMainWindow):
4354
4406
  # Display network_lists in the network table
4355
4407
  # Create empty DataFrame for network table if network_lists is None
4356
4408
  if not hasattr(my_network, 'network_lists') or my_network.network_lists is None:
4357
- empty_df = pd.DataFrame(columns=['Node 1A', 'Node 1B', 'Edge 1C'])
4409
+ empty_df = pd.DataFrame(columns=['Node A', 'Node B', 'Edge C'])
4358
4410
  model = PandasModel(empty_df)
4359
4411
  self.network_table.setModel(model)
4360
4412
  else:
@@ -4439,7 +4491,7 @@ class ImageViewerWindow(QMainWindow):
4439
4491
  """Method to close Excelotron"""
4440
4492
  self.excel_manager.close()
4441
4493
 
4442
- def handle_excel_data(self, data_dict, property_name):
4494
+ def handle_excel_data(self, data_dict, property_name, add):
4443
4495
  """Handle data received from Excelotron"""
4444
4496
  print(f"Received data for property: {property_name}")
4445
4497
  print(f"Data keys: {list(data_dict.keys())}")
@@ -4448,12 +4500,19 @@ class ImageViewerWindow(QMainWindow):
4448
4500
 
4449
4501
  try:
4450
4502
 
4503
+ if not add or my_network.node_centroids is None:
4504
+ centroids = {}
4505
+ max_val = 0
4506
+ else:
4507
+ centroids = my_network.node_centroids
4508
+ max_val = max(list(my_network.node_centroids.keys()))
4509
+
4451
4510
  ys = data_dict['Y']
4452
4511
  xs = data_dict['X']
4453
4512
  if 'Numerical IDs' in data_dict:
4454
4513
  nodes = data_dict['Numerical IDs']
4455
4514
  else:
4456
- nodes = np.arange(1, len(ys) + 1)
4515
+ nodes = np.arange(max_val + 1, max_val + len(ys) + 1)
4457
4516
 
4458
4517
 
4459
4518
  if 'Z' in data_dict:
@@ -4461,8 +4520,6 @@ class ImageViewerWindow(QMainWindow):
4461
4520
  else:
4462
4521
  zs = np.zeros(len(ys))
4463
4522
 
4464
- centroids = {}
4465
-
4466
4523
  for i in range(len(nodes)):
4467
4524
 
4468
4525
  centroids[nodes[i]] = [int(zs[i]), int(ys[i]), int(xs[i])]
@@ -4480,15 +4537,26 @@ class ImageViewerWindow(QMainWindow):
4480
4537
 
4481
4538
  try:
4482
4539
 
4540
+ if not add or my_network.node_identities is None:
4541
+ identities = {}
4542
+ max_val = 0
4543
+ else:
4544
+ identities = my_network.node_identities
4545
+ if my_network.node_centroids is not None:
4546
+ max_val = max(list(my_network.node_centroids.keys()))
4547
+ else:
4548
+ max_val = max(list(my_network.node_identities.keys()))
4549
+
4483
4550
  idens = data_dict['Identity Column']
4484
4551
 
4485
4552
  if 'Numerical IDs' in data_dict:
4486
4553
  nodes = data_dict['Numerical IDs']
4487
- else:
4488
- nodes = np.arange(1, len(idens) + 1)
4489
-
4490
- identities = {}
4554
+ if add:
4555
+ for i, node in enumerate(nodes):
4556
+ nodes[i] = node + max_val
4491
4557
 
4558
+ else:
4559
+ nodes = np.arange(max_val + 1, max_val + len(data_dict['Identity Column']) + 1)
4492
4560
 
4493
4561
  for i in range(len(nodes)):
4494
4562
 
@@ -4507,14 +4575,20 @@ class ImageViewerWindow(QMainWindow):
4507
4575
 
4508
4576
  try:
4509
4577
 
4578
+ if not add or my_network.communities is None:
4579
+ communities = {}
4580
+ max_val = 0
4581
+ else:
4582
+ communities = my_network.communities
4583
+ max_val = max(list(my_network.communities.keys()))
4584
+
4585
+
4510
4586
  coms = data_dict['Community Identifier']
4511
4587
 
4512
4588
  if 'Numerical IDs' in data_dict:
4513
4589
  nodes = data_dict['Numerical IDs']
4514
4590
  else:
4515
- nodes = np.arange(1, len(coms) + 1)
4516
-
4517
- communities = {}
4591
+ nodes = np.arange(max_val + 1, max_val + len(data_dict['Community Identifier']) + 1)
4518
4592
 
4519
4593
  for i in range(len(nodes)):
4520
4594
 
@@ -4556,6 +4630,7 @@ class ImageViewerWindow(QMainWindow):
4556
4630
  - 'mean': averages across color channels
4557
4631
  - 'max': takes maximum value across color channels
4558
4632
  - 'min': takes minimum value across color channels
4633
+ - 'weight': takes weighted channel averages
4559
4634
 
4560
4635
  Returns:
4561
4636
  --------
@@ -4570,7 +4645,7 @@ class ImageViewerWindow(QMainWindow):
4570
4645
  if array.ndim != 4:
4571
4646
  raise ValueError(f"Expected 4D array, got {array.ndim}D array")
4572
4647
 
4573
- if method not in ['first', 'mean', 'max', 'min']:
4648
+ if method not in ['first', 'mean', 'max', 'min', 'weight']:
4574
4649
  raise ValueError(f"Unknown method: {method}")
4575
4650
 
4576
4651
  if method == 'first':
@@ -4579,6 +4654,9 @@ class ImageViewerWindow(QMainWindow):
4579
4654
  return np.mean(array, axis=-1)
4580
4655
  elif method == 'max':
4581
4656
  return np.max(array, axis=-1)
4657
+ elif method == 'weight':
4658
+ # Apply the luminosity formula
4659
+ return (0.2989 * array[:,:,:,0] + 0.5870 * array[:,:,:,1] + 0.1140 * array[:,:,:,2])
4582
4660
  else: # min
4583
4661
  return np.min(array, axis=-1)
4584
4662
 
@@ -4602,7 +4680,7 @@ class ImageViewerWindow(QMainWindow):
4602
4680
  msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
4603
4681
  return msg.exec() == QMessageBox.StandardButton.Yes
4604
4682
 
4605
- def load_channel(self, channel_index, channel_data=None, data=False, assign_shape = True, preserve_zoom = None, end_paint = False, begin_paint = False):
4683
+ def load_channel(self, channel_index, channel_data=None, data=False, assign_shape = True, preserve_zoom = None, end_paint = False, begin_paint = False, color = False):
4606
4684
  """Load a channel and enable active channel selection if needed."""
4607
4685
 
4608
4686
  try:
@@ -4627,7 +4705,6 @@ class ImageViewerWindow(QMainWindow):
4627
4705
  import tifffile
4628
4706
  self.channel_data[channel_index] = tifffile.imread(filename)
4629
4707
 
4630
-
4631
4708
  elif file_extension == 'nii':
4632
4709
  import nibabel as nib
4633
4710
  nii_img = nib.load(filename)
@@ -4657,9 +4734,20 @@ class ImageViewerWindow(QMainWindow):
4657
4734
  self.channel_buttons[channel_index].setEnabled(False)
4658
4735
  self.delete_buttons[channel_index].setEnabled(False)
4659
4736
 
4737
+ try:
4738
+ #if len(self.channel_data[channel_index].shape) == 4:
4739
+ if 1 in self.channel_data[channel_index].shape:
4740
+ print("Removing singleton dimension (I am assuming this is a channel dimension?)")
4741
+ self.channel_data[channel_index] = np.squeeze(self.channel_data[channel_index])
4742
+ except:
4743
+ pass
4744
+
4660
4745
  if len(self.channel_data[channel_index].shape) == 2: # handle 2d data
4661
4746
  self.channel_data[channel_index] = np.expand_dims(self.channel_data[channel_index], axis=0)
4662
4747
 
4748
+ if self.channel_data[channel_index].dtype == np.bool_: #Promote boolean arrays if they somehow get loaded
4749
+ self.channel_data[channel_index] = self.channel_data[channel_index].astype(np.uint8)
4750
+
4663
4751
  try:
4664
4752
  if len(self.channel_data[channel_index].shape) == 3: # potentially 2D RGB
4665
4753
  if self.channel_data[channel_index].shape[-1] in (3, 4): # last dim is 3 or 4
@@ -4668,12 +4756,13 @@ class ImageViewerWindow(QMainWindow):
4668
4756
  self.channel_data[channel_index] = np.expand_dims(self.channel_data[channel_index], axis=0)
4669
4757
  except:
4670
4758
  pass
4671
-
4672
- try:
4673
- if len(self.channel_data[channel_index].shape) == 4 and (channel_index == 0 or channel_index == 1):
4674
- self.channel_data[channel_index] = self.reduce_rgb_dimension(self.channel_data[channel_index])
4675
- except:
4676
- pass
4759
+
4760
+ if not color:
4761
+ try:
4762
+ if len(self.channel_data[channel_index].shape) == 4 and (channel_index == 0 or channel_index == 1):
4763
+ self.channel_data[channel_index] = self.reduce_rgb_dimension(self.channel_data[channel_index], 'weight')
4764
+ except:
4765
+ pass
4677
4766
 
4678
4767
  reset_resize = False
4679
4768
 
@@ -4748,8 +4837,13 @@ class ImageViewerWindow(QMainWindow):
4748
4837
  if all(not btn.isEnabled() for btn in self.channel_buttons[:channel_index]):
4749
4838
  self.set_active_channel(channel_index)
4750
4839
 
4751
- if not self.channel_buttons[channel_index].isChecked():
4752
- self.channel_buttons[channel_index].click()
4840
+ if self.chan_load:
4841
+ if not self.channel_buttons[channel_index].isChecked():
4842
+ self.channel_buttons[channel_index].click()
4843
+ else:
4844
+ if self.channel_buttons[channel_index].isChecked():
4845
+ self.channel_buttons[channel_index].click()
4846
+
4753
4847
  self.min_max[channel_index][0] = np.min(self.channel_data[channel_index])
4754
4848
  self.min_max[channel_index][1] = np.max(self.channel_data[channel_index])
4755
4849
  self.volume_dict[channel_index] = None #reset volumes
@@ -4849,7 +4943,7 @@ class ImageViewerWindow(QMainWindow):
4849
4943
  my_network.communities = None
4850
4944
 
4851
4945
  # Create empty DataFrame
4852
- empty_df = pd.DataFrame(columns=['Node 1A', 'Node 1B', 'Edge 1C'])
4946
+ empty_df = pd.DataFrame(columns=['Node A', 'Node B', 'Edge C'])
4853
4947
 
4854
4948
  # Clear network table
4855
4949
  self.network_table.setModel(PandasModel(empty_df))
@@ -5018,25 +5112,29 @@ class ImageViewerWindow(QMainWindow):
5018
5112
 
5019
5113
 
5020
5114
 
5021
- def update_display(self, preserve_zoom=None, dims = None, called = False, reset_resize = False):
5115
+ def update_display(self, preserve_zoom=None, dims = None, called = False, reset_resize = False, continue_paint = False, skip_paint_reinit = False):
5022
5116
  """Update the display with currently visible channels and highlight overlay."""
5023
-
5024
5117
  try:
5025
-
5026
5118
  self.figure.clear()
5027
-
5028
5119
  if self.pan_background_image is not None:
5029
5120
  # Restore previously visible channels
5030
5121
  self.channel_visible = self.pre_pan_channel_state.copy()
5031
5122
  self.is_pan_preview = False
5032
5123
  self.pan_background_image = None
5033
-
5034
5124
  if self.machine_window is not None:
5035
5125
  if self.machine_window.segmentation_worker is not None:
5036
5126
  self.machine_window.segmentation_worker.resume()
5037
-
5038
5127
  if self.static_background is not None:
5039
-
5128
+ # NEW: Convert virtual strokes to real data before cleanup
5129
+ if (hasattr(self, 'virtual_draw_operations') and self.virtual_draw_operations) or \
5130
+ (hasattr(self, 'virtual_erase_operations') and self.virtual_erase_operations) or \
5131
+ (hasattr(self, 'current_operation') and self.current_operation):
5132
+ # Finish current operation first
5133
+ if hasattr(self, 'current_operation') and self.current_operation:
5134
+ self.pm.finish_current_virtual_operation()
5135
+ # Now convert to real data
5136
+ self.pm.convert_virtual_strokes_to_data()
5137
+
5040
5138
  # Restore hidden channels
5041
5139
  try:
5042
5140
  for i in self.restore_channels:
@@ -5044,27 +5142,17 @@ class ImageViewerWindow(QMainWindow):
5044
5142
  self.restore_channels = []
5045
5143
  except:
5046
5144
  pass
5047
-
5048
- self.end_paint_session()
5049
-
5050
- # OPTIMIZED: Stop timer and process any pending paint operations
5051
- if hasattr(self, 'paint_timer'):
5052
- self.paint_timer.stop()
5053
- if hasattr(self, 'pending_paint_update') and self.pending_paint_update:
5054
- self.flush_paint_updates()
5055
-
5056
- self.static_background = None
5057
-
5058
- if self.machine_window is None:
5059
-
5060
- try:
5145
+ if not continue_paint:
5146
+ self.static_background = None
5061
5147
 
5062
- self.channel_data[4][self.current_slice, :, :] = n3d.overlay_arrays_simple(self.channel_data[self.temp_chan][self.current_slice, :, :], self.channel_data[4][self.current_slice, :, :])
5063
- self.load_channel(self.temp_chan, self.channel_data[4], data = True, end_paint = True)
5064
- self.channel_data[4] = None
5065
- self.channel_visible[4] = False
5066
- except:
5067
- pass
5148
+ if self.machine_window is None:
5149
+ try:
5150
+ self.channel_data[4][self.current_slice, :, :] = n3d.overlay_arrays_simple(self.channel_data[self.temp_chan][self.current_slice, :, :], self.channel_data[4][self.current_slice, :, :])
5151
+ self.load_channel(self.temp_chan, self.channel_data[4], data = True, end_paint = True)
5152
+ self.channel_data[4] = None
5153
+ self.channel_visible[4] = False
5154
+ except:
5155
+ pass
5068
5156
 
5069
5157
  # Get active channels and their dimensions
5070
5158
  active_channels = [i for i in range(4) if self.channel_data[i] is not None]
@@ -5295,49 +5383,22 @@ class ImageViewerWindow(QMainWindow):
5295
5383
 
5296
5384
  self.canvas.draw()
5297
5385
 
5386
+ if self.brush_mode and not skip_paint_reinit:
5387
+ # Get current zoom to preserve it
5388
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
5389
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
5390
+
5391
+ if self.pen_button.isChecked():
5392
+ channel = self.active_channel
5393
+ else:
5394
+ channel = 2
5395
+
5396
+ self.pm.initiate_paint_session(channel, current_xlim, current_ylim)
5397
+
5298
5398
  except:
5299
5399
  import traceback
5300
5400
  print(traceback.format_exc())
5301
5401
 
5302
- def update_display_slice_optimized(self, channel, preserve_zoom=None):
5303
- """Ultra minimal update that only changes the paint channel's data - OPTIMIZED VERSION"""
5304
- if not self.channel_visible[channel]:
5305
- return
5306
-
5307
- if preserve_zoom:
5308
- current_xlim, current_ylim = preserve_zoom
5309
- if current_xlim is not None and current_ylim is not None:
5310
- self.ax.set_xlim(current_xlim)
5311
- self.ax.set_ylim(current_ylim)
5312
-
5313
- # Find the existing image for channel (paint channel)
5314
- channel_image = None
5315
- for img in self.ax.images:
5316
- if img.cmap.name == f'custom_{channel}':
5317
- channel_image = img
5318
- break
5319
-
5320
- if channel_image is not None:
5321
- # Update the data of the existing image with thread safety
5322
- with self.paint_lock:
5323
- channel_image.set_array(self.channel_data[channel][self.current_slice])
5324
-
5325
- # Restore the static background (all other channels) at current zoom level
5326
- # This is the key - use static_background from update_display, not paint_background
5327
- if hasattr(self, 'static_background') and self.static_background is not None:
5328
- self.canvas.restore_region(self.static_background)
5329
- # Draw just our paint channel
5330
- self.ax.draw_artist(channel_image)
5331
- # Blit everything
5332
- self.canvas.blit(self.ax.bbox)
5333
- self.canvas.flush_events()
5334
- else:
5335
- # Fallback to full draw if no static background
5336
- self.canvas.draw()
5337
- else:
5338
- # Fallback if channel image not found
5339
- self.canvas.draw()
5340
-
5341
5402
  def get_channel_image(self, channel):
5342
5403
  """Find the matplotlib image object for a specific channel."""
5343
5404
  if not hasattr(self.ax, 'images'):
@@ -5467,6 +5528,13 @@ class ImageViewerWindow(QMainWindow):
5467
5528
  dialog = CodeDialog(self, sort = sort)
5468
5529
  dialog.exec()
5469
5530
 
5531
+ def handle_umap(self):
5532
+
5533
+ if my_network.node_centroids is None:
5534
+ self.show_centroid_dialog()
5535
+
5536
+ my_network.centroid_umap()
5537
+
5470
5538
  def closeEvent(self, event):
5471
5539
  """Override closeEvent to close all windows when main window closes"""
5472
5540
 
@@ -6009,9 +6077,9 @@ class PandasModel(QAbstractTableModel):
6009
6077
  if data is None:
6010
6078
  # Create an empty DataFrame with default columns
6011
6079
  import pandas as pd
6012
- data = pd.DataFrame(columns=['Node 1A', 'Node 1B', 'Edge 1C'])
6080
+ data = pd.DataFrame(columns=['Node A', 'Node B', 'Edge C'])
6013
6081
  elif type(data) == list:
6014
- data = self.lists_to_dataframe(data[0], data[1], data[2], column_names=['Node 1A', 'Node 1B', 'Edge 1C'])
6082
+ data = self.lists_to_dataframe(data[0], data[1], data[2], column_names=['Node A', 'Node B', 'Edge C'])
6015
6083
  self._data = data
6016
6084
  self.bold_cells = set()
6017
6085
  self.highlighted_cells = set()
@@ -6752,7 +6820,7 @@ class MergeNodeIdDialog(QDialog):
6752
6820
 
6753
6821
  self.parent().format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity')
6754
6822
 
6755
- QMessageBox.critical(
6823
+ QMessageBox.information(
6756
6824
  self,
6757
6825
  "Success",
6758
6826
  "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)"
@@ -6987,24 +7055,29 @@ class ColorOverlayDialog(QDialog):
6987
7055
 
6988
7056
  def coloroverlay(self):
6989
7057
 
6990
- down_factor = float(self.down_factor.text()) if self.down_factor.text().strip() else None
7058
+ try:
6991
7059
 
6992
- if self.parent().active_channel == 0:
6993
- mode = 0
6994
- self.sort = 'Node'
6995
- else:
6996
- mode = 1
6997
- self.sort = 'Edge'
7060
+ down_factor = float(self.down_factor.text()) if self.down_factor.text().strip() else None
7061
+
7062
+ if self.parent().active_channel == 0:
7063
+ mode = 0
7064
+ self.sort = 'Node'
7065
+ else:
7066
+ mode = 1
7067
+ self.sort = 'Edge'
6998
7068
 
6999
7069
 
7000
- result, legend = my_network.node_to_color(down_factor = down_factor, mode = mode)
7070
+ result, legend = my_network.node_to_color(down_factor = down_factor, mode = mode)
7001
7071
 
7002
- self.parent().format_for_upperright_table(legend, f'{self.sort} Id', f'Encoding Val: {self.sort}', 'Legend')
7072
+ self.parent().format_for_upperright_table(legend, f'{self.sort} Id', f'Encoding Val: {self.sort}', 'Legend')
7003
7073
 
7004
7074
 
7005
- self.parent().load_channel(3, channel_data = result, data = True)
7075
+ self.parent().load_channel(3, channel_data = result, data = True)
7006
7076
 
7007
- self.accept()
7077
+ self.accept()
7078
+
7079
+ except:
7080
+ pass
7008
7081
 
7009
7082
 
7010
7083
  class ShuffleDialog(QDialog):
@@ -7211,6 +7284,8 @@ class PartitionDialog(QDialog):
7211
7284
 
7212
7285
  def partition(self):
7213
7286
 
7287
+ self.parent().prev_coms = None
7288
+
7214
7289
  accepted_mode = self.mode_selector.currentIndex()
7215
7290
  weighted = self.weighted.isChecked()
7216
7291
  dostats = self.stats.isChecked()
@@ -7345,7 +7420,7 @@ class ComNeighborDialog(QDialog):
7345
7420
  # weighted checkbox (default True)
7346
7421
  self.proportional = QPushButton("Robust")
7347
7422
  self.proportional.setCheckable(True)
7348
- self.proportional.setChecked(False)
7423
+ self.proportional.setChecked(True)
7349
7424
  layout.addRow("Return Node Type Distribution Robust Heatmaps (ie, will give two more heatmaps that are not beholden to the total number of nodes of each type, representing which structures are overrepresented in a network):", self.proportional)
7350
7425
 
7351
7426
  self.mode = QComboBox()
@@ -7393,7 +7468,7 @@ class ComNeighborDialog(QDialog):
7393
7468
  self.parent().format_for_upperright_table(matrix, 'NeighborhoodID', id_set, title = f'Neighborhood Heatmap {i + 1}')
7394
7469
 
7395
7470
 
7396
- self.parent().format_for_upperright_table(len_dict, 'NeighborhoodID', 'Proportion of Total Nodes', title = 'Neighborhood Counts')
7471
+ self.parent().format_for_upperright_table(len_dict, 'NeighborhoodID', ['Number of Communities', 'Proportion of Total Nodes'], title = 'Neighborhood Counts')
7397
7472
  self.parent().format_for_upperright_table(my_network.communities, 'NodeID', 'NeighborhoodID', title = 'Neighborhood Partition')
7398
7473
 
7399
7474
  print("Neighborhoods have been assigned to communities based on similarity")
@@ -7435,6 +7510,8 @@ class ComCellDialog(QDialog):
7435
7510
 
7436
7511
  try:
7437
7512
 
7513
+ self.parent().prev_coms = None
7514
+
7438
7515
  size = float(self.size.text()) if self.size.text().strip() else None
7439
7516
  xy_scale = float(self.xy_scale.text()) if self.xy_scale.text().strip() else 1
7440
7517
  z_scale = float(self.z_scale.text()) if self.z_scale.text().strip() else 1
@@ -7596,6 +7673,16 @@ class NearNeighDialog(QDialog):
7596
7673
  heatmap_layout.addRow("Overlay:", self.numpy)
7597
7674
 
7598
7675
  main_layout.addWidget(heatmap_group)
7676
+
7677
+ quant_group = QGroupBox("Quantifiable Overlay")
7678
+ quant_layout = QFormLayout(quant_group)
7679
+
7680
+ self.quant = QPushButton("Return quantifiable overlay? (Labels nodes by distance, good with intensity-thresholding to isolate targets. Requires labeled nodes image.)")
7681
+ self.quant.setCheckable(True)
7682
+ self.quant.setChecked(False)
7683
+ quant_layout.addRow("Overlay:", self.quant)
7684
+
7685
+ main_layout.addWidget(quant_group)
7599
7686
 
7600
7687
  # Get Distribution group box
7601
7688
  distribution_group = QGroupBox("Get Distribution")
@@ -7643,6 +7730,7 @@ class NearNeighDialog(QDialog):
7643
7730
  threed = self.threed.isChecked()
7644
7731
  numpy = self.numpy.isChecked()
7645
7732
  num = int(self.num.text()) if self.num.text().strip() else 1
7733
+ quant = self.quant.isChecked()
7646
7734
 
7647
7735
  if root is not None and targ is not None:
7648
7736
  title = f"Nearest {num} Neighbor(s) Distance of {targ} from {root}"
@@ -7659,11 +7747,14 @@ class NearNeighDialog(QDialog):
7659
7747
  return
7660
7748
 
7661
7749
  if not numpy:
7662
- avg, output = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num, heatmap = heatmap, threed = threed)
7750
+ avg, output, quant_overlay = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num, heatmap = heatmap, threed = threed, quant = quant)
7663
7751
  else:
7664
- 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)
7752
+ avg, output, overlay, quant_overlay = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num, heatmap = heatmap, threed = threed, numpy = True, quant = quant)
7665
7753
  self.parent().load_channel(3, overlay, data = True)
7666
7754
 
7755
+ if quant_overlay is not None:
7756
+ self.parent().load_channel(2, quant_overlay, data = True)
7757
+
7667
7758
  self.parent().format_for_upperright_table([avg], metric = f'Avg {title}', title = f'Avg {title}')
7668
7759
  self.parent().format_for_upperright_table(output, header2, header, title = title)
7669
7760
 
@@ -7691,7 +7782,7 @@ class NearNeighDialog(QDialog):
7691
7782
 
7692
7783
  for targ in available:
7693
7784
 
7694
- avg, _ = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num)
7785
+ avg, _, _ = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num)
7695
7786
 
7696
7787
  output_dict[f"{root} vs {targ}"] = avg
7697
7788
 
@@ -7781,52 +7872,86 @@ class NeighborIdentityDialog(QDialog):
7781
7872
 
7782
7873
 
7783
7874
  class RipleyDialog(QDialog):
7784
-
7785
7875
  def __init__(self, parent=None):
7786
-
7787
7876
  super().__init__(parent)
7788
7877
  self.setWindowTitle(f"Find Ripley's H Function From Centroids")
7789
7878
  self.setModal(True)
7790
-
7791
- layout = QFormLayout(self)
7792
-
7879
+
7880
+ # Main layout
7881
+ main_layout = QVBoxLayout(self)
7882
+
7883
+ # Node Parameters Group (only if node_identities exist)
7793
7884
  if my_network.node_identities is not None:
7885
+ node_group = QGroupBox("Node Parameters")
7886
+ node_layout = QFormLayout(node_group)
7887
+
7794
7888
  self.root = QComboBox()
7795
7889
  self.root.addItems(list(set(my_network.node_identities.values())))
7796
7890
  self.root.setCurrentIndex(0)
7797
- layout.addRow("Root Identity to Search for Neighbors", self.root)
7798
- else:
7799
- self.root = None
7800
-
7801
- if my_network.node_identities is not None:
7891
+ node_layout.addRow("Root Identity to Search for Neighbors:", self.root)
7892
+
7802
7893
  self.targ = QComboBox()
7803
7894
  self.targ.addItems(list(set(my_network.node_identities.values())))
7804
7895
  self.targ.setCurrentIndex(0)
7805
- layout.addRow("Targ Identity to be Searched For", self.targ)
7896
+ node_layout.addRow("Target Identity to be Searched For:", self.targ)
7897
+
7898
+ main_layout.addWidget(node_group)
7806
7899
  else:
7900
+ self.root = None
7807
7901
  self.targ = None
7808
-
7902
+
7903
+ # Search Parameters Group
7904
+ search_group = QGroupBox("Search Parameters")
7905
+ search_layout = QFormLayout(search_group)
7906
+
7809
7907
  self.distance = QLineEdit("5")
7810
- layout.addRow("Bucket Distance for Searching For Clusters (automatically scaled by xy and z scales):", self.distance)
7811
-
7812
-
7908
+ search_layout.addRow("1. Bucket Distance for Searching For Clusters\n(automatically scaled by xy and z scales):", self.distance)
7909
+
7813
7910
  self.proportion = QLineEdit("0.5")
7814
- layout.addRow("Proportion of image to search? (0-1, high vals increase border artifacts): ", self.proportion)
7815
-
7911
+ search_layout.addRow("2. Proportion of image to search?\n(0-1, high vals increase border artifacts):", self.proportion)
7912
+
7913
+ main_layout.addWidget(search_group)
7914
+
7915
+ # Border Safety Group
7916
+ border_group = QGroupBox("Border Safety")
7917
+ border_layout = QFormLayout(border_group)
7918
+
7919
+ self.ignore = QPushButton("Ignore Border Roots")
7920
+ self.ignore.setCheckable(True)
7921
+ self.ignore.setChecked(True)
7922
+ border_layout.addRow("3. Exclude Root Nodes Near Borders?:", self.ignore)
7923
+
7924
+ self.factor = QLineEdit("0.5")
7925
+ border_layout.addRow("4. (If param 3): Proportion of most internal nodes to use? (0 < n < 1) (Higher = more internal)?:", self.factor)
7926
+
7927
+ self.mode = QComboBox()
7928
+ self.mode.addItems(["Boundaries of Entire Image", "Boundaries of Edge Image Mask",
7929
+ "Boundaries of Overlay1 Mask", "Boundaries of Overlay2 Mask"])
7930
+ self.mode.setCurrentIndex(0)
7931
+ border_layout.addRow("5. (If param 3): Define Boundaries How?:", self.mode)
7932
+
7933
+ self.safe = QPushButton("Ignore Border Radii")
7934
+ self.safe.setCheckable(True)
7935
+ self.safe.setChecked(True)
7936
+ border_layout.addRow("6. (If param 3): Keep search radii within border (overrides Param 2, also assigns volume to that of mask)?:", self.safe)
7937
+
7938
+ main_layout.addWidget(border_group)
7939
+
7940
+ # Experimental Border Safety Group
7941
+ experimental_group = QGroupBox("Aggressive Border Safety (Creates duplicate centroids reflected across the image border - if you really need to search there for whatever reason - Not meant to be used if confining search to a masked object)")
7942
+ experimental_layout = QFormLayout(experimental_group)
7943
+
7816
7944
  self.edgecorrect = QPushButton("Border Correction")
7817
7945
  self.edgecorrect.setCheckable(True)
7818
7946
  self.edgecorrect.setChecked(False)
7819
- layout.addRow("Use Border Correction (Extrapolate for points beyond the border):", self.edgecorrect)
7820
-
7821
- self.ignore = QPushButton("Ignore Border Roots")
7822
- self.ignore.setCheckable(True)
7823
- self.ignore.setChecked(False)
7824
- layout.addRow("Exclude Root Nodes Near Borders?:", self.ignore)
7825
-
7947
+ experimental_layout.addRow("7. Use Border Correction\n(Extrapolate for points beyond the border):", self.edgecorrect)
7948
+
7949
+ main_layout.addWidget(experimental_group)
7950
+
7826
7951
  # Add Run button
7827
7952
  run_button = QPushButton("Get Ripley's H")
7828
7953
  run_button.clicked.connect(self.ripley)
7829
- layout.addWidget(run_button)
7954
+ main_layout.addWidget(run_button)
7830
7955
 
7831
7956
  def ripley(self):
7832
7957
 
@@ -7856,6 +7981,16 @@ class RipleyDialog(QDialog):
7856
7981
  except:
7857
7982
  proportion = 0.5
7858
7983
 
7984
+ try:
7985
+ factor = abs(float(self.factor.text()))
7986
+
7987
+ except:
7988
+ factor = 0.25
7989
+
7990
+ if factor > 1 or factor <= 0:
7991
+ print("Utilizing factor = 0.25")
7992
+ factor = 0.25
7993
+
7859
7994
  if proportion > 1 or proportion <= 0:
7860
7995
  print("Utilizing proportion = 0.5")
7861
7996
  proportion = 0.5
@@ -7865,6 +8000,13 @@ class RipleyDialog(QDialog):
7865
8000
 
7866
8001
  ignore = self.ignore.isChecked()
7867
8002
 
8003
+ safe = self.safe.isChecked()
8004
+
8005
+ mode = self.mode.currentIndex()
8006
+
8007
+ if mode == 0:
8008
+ factor = factor/2 #The logic treats this as distance to border later, only if mode is 0, but its supposed to represent proportion internal.
8009
+
7868
8010
  if my_network.nodes is not None:
7869
8011
 
7870
8012
  if my_network.nodes.shape[0] == 1:
@@ -7874,7 +8016,7 @@ class RipleyDialog(QDialog):
7874
8016
  else:
7875
8017
  bounds = None
7876
8018
 
7877
- r_vals, k_vals, h_vals = my_network.get_ripley(root, targ, distance, edgecorrect, bounds, ignore, proportion)
8019
+ r_vals, k_vals, h_vals = my_network.get_ripley(root, targ, distance, edgecorrect, bounds, ignore, proportion, mode, safe, factor)
7878
8020
 
7879
8021
  k_dict = dict(zip(r_vals, k_vals))
7880
8022
  h_dict = dict(zip(r_vals, h_vals))
@@ -7887,6 +8029,9 @@ class RipleyDialog(QDialog):
7887
8029
  self.accept()
7888
8030
 
7889
8031
  except Exception as e:
8032
+ import traceback
8033
+ print(traceback.format_exc())
8034
+
7890
8035
  QMessageBox.critical(
7891
8036
  self,
7892
8037
  "Error:",
@@ -8629,10 +8774,10 @@ class ResizeDialog(QDialog):
8629
8774
  else:
8630
8775
  new_shape = tuple(int(dim * factor) for dim, factor in zip(array_shape, resize))
8631
8776
 
8632
- if any(dim < 1 for dim in new_shape):
8633
- QMessageBox.critical(self, "Error", f"Resize would result in invalid dimensions: {new_shape}")
8634
- self.reset_fields()
8635
- return
8777
+ #if any(dim < 1 for dim in new_shape):
8778
+ #QMessageBox.critical(self, "Error", f"Resize would result in invalid dimensions: {new_shape}")
8779
+ #self.reset_fields()
8780
+ #return
8636
8781
 
8637
8782
  cubic = self.cubic.isChecked()
8638
8783
  order = 3 if cubic else 0
@@ -8706,7 +8851,12 @@ class ResizeDialog(QDialog):
8706
8851
  centroids = copy.deepcopy(my_network.node_centroids)
8707
8852
  if isinstance(resize, (int, float)):
8708
8853
  for item in my_network.node_centroids:
8709
- centroids[item] = np.round((my_network.node_centroids[item]) * resize)
8854
+ try:
8855
+ centroids[item] = np.round((my_network.node_centroids[item]) * resize)
8856
+ except:
8857
+ temp = np.array(my_network.node_centroids[item])
8858
+ centroids[item] = np.round((temp) * resize)
8859
+
8710
8860
  else:
8711
8861
  for item in my_network.node_centroids:
8712
8862
  centroids[item][0] = int(np.round((my_network.node_centroids[item][0]) * resize[0]))
@@ -9149,6 +9299,12 @@ class ThresholdDialog(QDialog):
9149
9299
 
9150
9300
  def start_ml(self, GPU = False):
9151
9301
 
9302
+ try:
9303
+ print("Please select image to load into nodes channel for segmentation or press X if you already have the one you want. Note that this load may permit a color image in the nodes channel for segmentation purposes only, which is otherwise not allowed.")
9304
+ self.parent().load_channel(0, color = True)
9305
+ except:
9306
+ pass
9307
+
9152
9308
 
9153
9309
  if self.parent().channel_data[2] is not None or self.parent().channel_data[3] is not None or self.parent().highlight_overlay is not None:
9154
9310
  if self.confirm_machine_dialog():
@@ -9188,13 +9344,14 @@ class ThresholdDialog(QDialog):
9188
9344
 
9189
9345
  class ExcelotronManager(QObject):
9190
9346
  # Signal to emit when data is received from Excelotron
9191
- data_received = pyqtSignal(dict, str) # dictionary, property_name
9347
+ data_received = pyqtSignal(dict, str, bool) # dictionary, property_name
9192
9348
 
9193
9349
  def __init__(self, parent=None):
9194
9350
  super().__init__(parent)
9195
9351
  self.excelotron_window = None
9196
9352
  self.last_data = None
9197
9353
  self.last_property = None
9354
+ self.last_add = None
9198
9355
 
9199
9356
  def launch(self):
9200
9357
  """Launch the Excelotron window"""
@@ -9243,12 +9400,13 @@ class ExcelotronManager(QObject):
9243
9400
  is_open = self.excelotron_window is not None
9244
9401
  return is_open
9245
9402
 
9246
- def _on_data_exported(self, data_dict, property_name):
9403
+ def _on_data_exported(self, data_dict, property_name, add):
9247
9404
  """Internal slot to handle data from Excelotron"""
9248
9405
  self.last_data = data_dict
9249
9406
  self.last_property = property_name
9407
+ self.last_add = add
9250
9408
  # Re-emit the signal for parent to handle
9251
- self.data_received.emit(data_dict, property_name)
9409
+ self.data_received.emit(data_dict, property_name, add)
9252
9410
 
9253
9411
  def _on_window_destroyed(self):
9254
9412
  """Handle when the Excelotron window is destroyed/closed"""
@@ -9256,213 +9414,215 @@ class ExcelotronManager(QObject):
9256
9414
 
9257
9415
  def get_last_data(self):
9258
9416
  """Get the last exported data"""
9259
- return self.last_data, self.last_property
9417
+ return self.last_data, self.last_property, self.last_add
9260
9418
 
9261
9419
  class MachineWindow(QMainWindow):
9262
9420
 
9263
9421
  def __init__(self, parent=None, GPU = False):
9264
9422
  super().__init__(parent)
9265
9423
 
9266
- if self.parent().active_channel == 0:
9267
- if self.parent().channel_data[0] is not None:
9268
- try:
9269
- active_data = self.parent().channel_data[0]
9270
- act_channel = 0
9271
- except:
9424
+ try:
9425
+
9426
+ if self.parent().active_channel == 0:
9427
+ if self.parent().channel_data[0] is not None:
9428
+ try:
9429
+ active_data = self.parent().channel_data[0]
9430
+ act_channel = 0
9431
+ except:
9432
+ active_data = self.parent().channel_data[1]
9433
+ act_channel = 1
9434
+ else:
9272
9435
  active_data = self.parent().channel_data[1]
9273
9436
  act_channel = 1
9274
- else:
9275
- active_data = self.parent().channel_data[1]
9276
- act_channel = 1
9277
-
9278
- try:
9279
- array1 = np.zeros_like(active_data).astype(np.uint8)
9280
- except:
9281
- print("No data in nodes channel")
9282
- return
9283
9437
 
9284
- self.setWindowTitle("Threshold")
9285
-
9286
- # Create central widget and layout
9287
- central_widget = QWidget()
9288
- self.setCentralWidget(central_widget)
9289
- layout = QVBoxLayout(central_widget)
9438
+ try:
9439
+ if len(active_data.shape) == 3:
9440
+ array1 = np.zeros_like(active_data).astype(np.uint8)
9441
+ elif len(active_data.shape) == 4:
9442
+ array1 = np.zeros_like(active_data)[:,:,:,0].astype(np.uint8)
9443
+ except:
9444
+ print("No data in nodes channel")
9445
+ return
9290
9446
 
9447
+ self.setWindowTitle("Threshold")
9448
+
9449
+ # Create central widget and layout
9450
+ central_widget = QWidget()
9451
+ self.setCentralWidget(central_widget)
9452
+ layout = QVBoxLayout(central_widget)
9291
9453
 
9292
- # Create form layout for inputs
9293
- form_layout = QFormLayout()
9294
9454
 
9295
- layout.addLayout(form_layout)
9455
+ # Create form layout for inputs
9456
+ form_layout = QFormLayout()
9296
9457
 
9297
- if self.parent().pen_button.isChecked(): #Disable the pen mode if the user is in it because the segmenter pen forks it
9298
- self.parent().pen_button.click()
9299
- self.parent().threed = False
9300
- self.parent().can = False
9301
- self.parent().last_change = None
9458
+ layout.addLayout(form_layout)
9302
9459
 
9303
- self.parent().pen_button.setEnabled(False)
9460
+ if self.parent().pen_button.isChecked(): #Disable the pen mode if the user is in it because the segmenter pen forks it
9461
+ self.parent().pen_button.click()
9462
+ self.parent().threed = False
9463
+ self.parent().can = False
9464
+ self.parent().last_change = None
9304
9465
 
9305
- array3 = np.zeros_like(active_data).astype(np.uint8)
9306
- self.parent().highlight_overlay = array3 #Clear this out for the segmenter to use
9466
+ self.parent().pen_button.setEnabled(False)
9307
9467
 
9308
- self.parent().load_channel(2, array1, True)
9309
- # Enable the channel button
9310
- # Not exactly sure why we need all this but the channel buttons weren't loading like they normally do when load_channel() is called:
9311
- if not self.parent().channel_buttons[2].isEnabled():
9312
- self.parent().channel_buttons[2].setEnabled(True)
9313
- self.parent().channel_buttons[2].click()
9314
- self.parent().delete_buttons[2].setEnabled(True)
9468
+ array3 = np.zeros_like(array1).astype(np.uint8)
9469
+ self.parent().highlight_overlay = array3 #Clear this out for the segmenter to use
9315
9470
 
9316
- self.parent().base_colors[act_channel] = self.parent().color_dictionary['WHITE']
9317
- self.parent().base_colors[2] = self.parent().color_dictionary['LIGHT_GREEN']
9471
+ self.parent().load_channel(2, array1, True)
9472
+ # Enable the channel button
9473
+ # Not exactly sure why we need all this but the channel buttons weren't loading like they normally do when load_channel() is called:
9474
+ if not self.parent().channel_buttons[2].isEnabled():
9475
+ self.parent().channel_buttons[2].setEnabled(True)
9476
+ self.parent().channel_buttons[2].click()
9477
+ self.parent().delete_buttons[2].setEnabled(True)
9318
9478
 
9319
- self.parent().update_display()
9320
-
9321
- # Set a reasonable default size for the window
9322
- self.setMinimumWidth(600) # Increased to accommodate grouped buttons
9323
- self.setMinimumHeight(500)
9479
+ if len(active_data.shape) == 3:
9480
+ self.parent().base_colors[act_channel] = self.parent().color_dictionary['WHITE']
9481
+ self.parent().base_colors[2] = self.parent().color_dictionary['LIGHT_GREEN']
9324
9482
 
9325
- # Create main layout container
9326
- main_widget = QWidget()
9327
- main_layout = QVBoxLayout(main_widget)
9483
+ self.parent().update_display()
9484
+
9485
+ # Set a reasonable default size for the window
9486
+ self.setMinimumWidth(600) # Increased to accommodate grouped buttons
9487
+ self.setMinimumHeight(500)
9488
+
9489
+ # Create main layout container
9490
+ main_widget = QWidget()
9491
+ main_layout = QVBoxLayout(main_widget)
9492
+
9493
+ # Group 1: Drawing tools (Brush + Foreground/Background)
9494
+ drawing_group = QGroupBox("Drawing Tools")
9495
+ drawing_layout = QHBoxLayout()
9496
+
9497
+ # Brush button
9498
+ self.brush_button = QPushButton("🖌️")
9499
+ self.brush_button.setCheckable(True)
9500
+ self.brush_button.setFixedSize(40, 40)
9501
+ self.brush_button.clicked.connect(self.toggle_brush_mode)
9502
+ self.brush_button.click()
9503
+
9504
+ # Foreground/Background buttons in their own horizontal layout
9505
+ fb_layout = QHBoxLayout()
9506
+ self.fore_button = QPushButton("Foreground")
9507
+ self.fore_button.setCheckable(True)
9508
+ self.fore_button.setChecked(True)
9509
+ self.fore_button.clicked.connect(self.toggle_foreground)
9328
9510
 
9329
- # Group 1: Drawing tools (Brush + Foreground/Background)
9330
- drawing_group = QGroupBox("Drawing Tools")
9331
- drawing_layout = QHBoxLayout()
9511
+ self.back_button = QPushButton("Background")
9512
+ self.back_button.setCheckable(True)
9513
+ self.back_button.setChecked(False)
9514
+ self.back_button.clicked.connect(self.toggle_background)
9332
9515
 
9333
- # Brush button
9334
- self.brush_button = QPushButton("🖌️")
9335
- self.brush_button.setCheckable(True)
9336
- self.brush_button.setFixedSize(40, 40)
9337
- self.brush_button.clicked.connect(self.toggle_brush_mode)
9338
- self.brush_button.click()
9516
+ fb_layout.addWidget(self.fore_button)
9517
+ fb_layout.addWidget(self.back_button)
9339
9518
 
9340
- # Foreground/Background buttons in their own horizontal layout
9341
- fb_layout = QHBoxLayout()
9342
- self.fore_button = QPushButton("Foreground")
9343
- self.fore_button.setCheckable(True)
9344
- self.fore_button.setChecked(True)
9345
- self.fore_button.clicked.connect(self.toggle_foreground)
9519
+ drawing_layout.addWidget(self.brush_button)
9520
+ drawing_layout.addLayout(fb_layout)
9521
+ drawing_group.setLayout(drawing_layout)
9346
9522
 
9347
- self.back_button = QPushButton("Background")
9348
- self.back_button.setCheckable(True)
9349
- self.back_button.setChecked(False)
9350
- self.back_button.clicked.connect(self.toggle_background)
9523
+ # Group 2: Processing Options (GPU)
9524
+ processing_group = QGroupBox("Processing Options")
9525
+ processing_layout = QHBoxLayout()
9351
9526
 
9352
- fb_layout.addWidget(self.fore_button)
9353
- fb_layout.addWidget(self.back_button)
9527
+ self.use_gpu = GPU
9528
+ self.two = QPushButton("Train By 2D Slice Patterns")
9529
+ self.two.setCheckable(True)
9530
+ self.two.setChecked(False)
9531
+ self.two.clicked.connect(self.toggle_two)
9532
+ self.use_two = False
9533
+ self.three = QPushButton("Train by 3D Patterns")
9534
+ self.three.setCheckable(True)
9535
+ self.three.setChecked(True)
9536
+ self.three.clicked.connect(self.toggle_three)
9537
+ self.GPU = QPushButton("GPU")
9538
+ self.GPU.setCheckable(True)
9539
+ self.GPU.setChecked(False)
9540
+ self.GPU.clicked.connect(self.toggle_GPU)
9541
+ processing_layout.addWidget(self.GPU)
9542
+ processing_layout.addWidget(self.two)
9543
+ processing_layout.addWidget(self.three)
9544
+ processing_group.setLayout(processing_layout)
9545
+
9546
+ # Group 3: Training Options
9547
+ training_group = QGroupBox("Training")
9548
+ training_layout = QHBoxLayout()
9549
+ train_quick = QPushButton("Train Quick Model (When Good SNR)")
9550
+ train_quick.clicked.connect(lambda: self.train_model(speed=True))
9551
+ train_detailed = QPushButton("Train Detailed Model (For Morphology)")
9552
+ train_detailed.clicked.connect(lambda: self.train_model(speed=False))
9553
+ save = QPushButton("Save Model")
9554
+ save.clicked.connect(self.save_model)
9555
+ load = QPushButton("Load Model")
9556
+ load.clicked.connect(self.load_model)
9557
+ training_layout.addWidget(train_quick)
9558
+ training_layout.addWidget(train_detailed)
9559
+ training_layout.addWidget(save)
9560
+ training_layout.addWidget(load)
9561
+ training_group.setLayout(training_layout)
9562
+
9563
+ # Group 4: Segmentation Options
9564
+ segmentation_group = QGroupBox("Segmentation")
9565
+ segmentation_layout = QHBoxLayout()
9566
+ seg_button = QPushButton("Preview Segment")
9567
+ self.seg_button = seg_button
9568
+ seg_button.clicked.connect(self.start_segmentation)
9569
+ self.pause_button = QPushButton("▶/⏸️")
9570
+ self.pause_button.setFixedSize(40, 40)
9571
+ self.pause_button.clicked.connect(self.toggle_segment)
9572
+ self.lock_button = QPushButton("🔒 Memory lock - (Prioritize RAM)")
9573
+ self.lock_button.setCheckable(True)
9574
+ self.lock_button.setChecked(True)
9575
+ self.lock_button.clicked.connect(self.toggle_lock)
9576
+ self.mem_lock = True
9577
+ full_button = QPushButton("Segment All")
9578
+ full_button.clicked.connect(self.segment)
9579
+ segmentation_layout.addWidget(seg_button)
9580
+ segmentation_layout.addWidget(self.pause_button) # <--- for some reason the segmenter preview is still running even when killed, may be regenerating itself somewhere. May or may not actually try to resolve this because this feature isnt that necessary.
9581
+ #segmentation_layout.addWidget(self.lock_button) # Also turned this off
9582
+ segmentation_layout.addWidget(full_button)
9583
+ segmentation_group.setLayout(segmentation_layout)
9584
+
9585
+ # Add all groups to main layout
9586
+ main_layout.addWidget(drawing_group)
9587
+ if not GPU:
9588
+ main_layout.addWidget(processing_group)
9589
+ main_layout.addWidget(training_group)
9590
+ main_layout.addWidget(segmentation_group)
9591
+
9592
+ # Set the main widget as the central widget
9593
+ self.setCentralWidget(main_widget)
9594
+
9595
+ self.trained = False
9596
+ self.previewing = False
9354
9597
 
9355
- drawing_layout.addWidget(self.brush_button)
9356
- drawing_layout.addLayout(fb_layout)
9357
- drawing_group.setLayout(drawing_layout)
9598
+ if not GPU:
9599
+ self.segmenter = segmenter.InteractiveSegmenter(active_data, use_gpu=False)
9600
+ else:
9601
+ self.segmenter = seg_GPU.InteractiveSegmenter(active_data)
9358
9602
 
9359
- # Group 2: Processing Options (GPU)
9360
- processing_group = QGroupBox("Processing Options")
9361
- processing_layout = QHBoxLayout()
9362
-
9363
- self.use_gpu = GPU
9364
- self.two = QPushButton("Train By 2D Slice Patterns")
9365
- self.two.setCheckable(True)
9366
- self.two.setChecked(False)
9367
- self.two.clicked.connect(self.toggle_two)
9368
- self.use_two = False
9369
- self.three = QPushButton("Train by 3D Patterns")
9370
- self.three.setCheckable(True)
9371
- self.three.setChecked(True)
9372
- self.three.clicked.connect(self.toggle_three)
9373
- self.GPU = QPushButton("GPU")
9374
- self.GPU.setCheckable(True)
9375
- self.GPU.setChecked(False)
9376
- self.GPU.clicked.connect(self.toggle_GPU)
9377
- processing_layout.addWidget(self.GPU)
9378
- processing_layout.addWidget(self.two)
9379
- processing_layout.addWidget(self.three)
9380
- processing_group.setLayout(processing_layout)
9603
+ self.segmentation_worker = None
9381
9604
 
9382
- # Group 3: Training Options
9383
- training_group = QGroupBox("Training")
9384
- training_layout = QHBoxLayout()
9385
- train_quick = QPushButton("Train Quick Model")
9386
- train_quick.clicked.connect(lambda: self.train_model(speed=True))
9387
- train_detailed = QPushButton("Train More Detailed Model")
9388
- train_detailed.clicked.connect(lambda: self.train_model(speed=False))
9389
- save = QPushButton("Save Model")
9390
- save.clicked.connect(self.save_model)
9391
- load = QPushButton("Load Model")
9392
- load.clicked.connect(self.load_model)
9393
- training_layout.addWidget(train_quick)
9394
- training_layout.addWidget(train_detailed)
9395
- training_layout.addWidget(save)
9396
- training_layout.addWidget(load)
9397
- training_group.setLayout(training_layout)
9398
-
9399
- # Group 4: Segmentation Options
9400
- segmentation_group = QGroupBox("Segmentation")
9401
- segmentation_layout = QHBoxLayout()
9402
- seg_button = QPushButton("Preview Segment")
9403
- self.seg_button = seg_button
9404
- seg_button.clicked.connect(self.start_segmentation)
9405
- self.pause_button = QPushButton("▶/⏸️")
9406
- self.pause_button.clicked.connect(self.pause)
9407
- self.lock_button = QPushButton("🔒 Memory lock - (Prioritize RAM)")
9408
- self.lock_button.setCheckable(True)
9409
- self.lock_button.setChecked(True)
9410
- self.lock_button.clicked.connect(self.toggle_lock)
9411
- self.mem_lock = True
9412
- full_button = QPushButton("Segment All")
9413
- full_button.clicked.connect(self.segment)
9414
- segmentation_layout.addWidget(seg_button)
9415
- #segmentation_layout.addWidget(self.pause_button) # <--- for some reason the segmenter preview is still running even when killed, may be regenerating itself somewhere. May or may not actually try to resolve this because this feature isnt that necessary.
9416
- #segmentation_layout.addWidget(self.lock_button) # Also turned this off
9417
- segmentation_layout.addWidget(full_button)
9418
- segmentation_group.setLayout(segmentation_layout)
9419
-
9420
- # Add all groups to main layout
9421
- main_layout.addWidget(drawing_group)
9422
- if not GPU:
9423
- main_layout.addWidget(processing_group)
9424
- main_layout.addWidget(training_group)
9425
- main_layout.addWidget(segmentation_group)
9426
-
9427
- # Set the main widget as the central widget
9428
- self.setCentralWidget(main_widget)
9605
+ self.fore_button.click()
9606
+ self.fore_button.click()
9429
9607
 
9430
- self.trained = False
9431
- self.previewing = False
9608
+ except:
9609
+ return
9432
9610
 
9433
- if not GPU:
9434
- self.segmenter = segmenter.InteractiveSegmenter(active_data, use_gpu=False)
9435
- else:
9436
- self.segmenter = seg_GPU.InteractiveSegmenter(active_data)
9611
+ def toggle_segment(self):
9437
9612
 
9438
- self.segmentation_worker = None
9613
+ if self.segmentation_worker is not None:
9614
+ if not self.segmentation_worker._paused:
9615
+ self.segmentation_worker.pause()
9616
+ print("Segmentation Worker Paused")
9617
+ elif self.segmentation_worker._paused:
9618
+ self.segmentation_worker.resume()
9619
+ print("Segmentation Worker Resuming")
9439
9620
 
9440
- self.fore_button.click()
9441
- self.fore_button.click()
9442
9621
 
9443
9622
  def toggle_lock(self):
9444
9623
 
9445
9624
  self.mem_lock = self.lock_button.isChecked()
9446
9625
 
9447
- def pause(self):
9448
-
9449
- if self.segmentation_worker is not None:
9450
- try:
9451
- print("Pausing segmenter")
9452
- self.previewing = False
9453
- self.segmentation_finished
9454
- del self.segmentation_worker
9455
- self.segmentation_worker = None
9456
- except:
9457
- pass
9458
-
9459
- else:
9460
- try:
9461
- print("Restarting segmenter")
9462
- self.previewing = True
9463
- self.start_segmentation
9464
- except:
9465
- pass
9466
9626
 
9467
9627
  def save_model(self):
9468
9628
 
@@ -9484,6 +9644,9 @@ class MachineWindow(QMainWindow):
9484
9644
 
9485
9645
  except Exception as e:
9486
9646
  print(f"Error saving model: {e}")
9647
+ import traceback
9648
+ traceback.print_exc()
9649
+
9487
9650
 
9488
9651
  def load_model(self):
9489
9652
 
@@ -9579,7 +9742,23 @@ class MachineWindow(QMainWindow):
9579
9742
  def toggle_brush_mode(self):
9580
9743
  """Toggle brush mode on/off"""
9581
9744
  self.parent().brush_mode = self.brush_button.isChecked()
9745
+
9582
9746
  if self.parent().brush_mode:
9747
+
9748
+ self.parent().pm = painting.PaintManager(parent = self.parent())
9749
+
9750
+ # Start virtual paint session
9751
+ # Get current zoom to preserve it
9752
+ current_xlim = self.parent().ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
9753
+ current_ylim = self.parent().ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
9754
+
9755
+ if self.parent().pen_button.isChecked():
9756
+ channel = self.parent().active_channel
9757
+ else:
9758
+ channel = 2
9759
+
9760
+ self.parent().pm.initiate_paint_session(channel, current_xlim, current_ylim)
9761
+
9583
9762
  self.parent().pan_button.setChecked(False)
9584
9763
  self.parent().zoom_button.setChecked(False)
9585
9764
  if self.parent().pan_mode:
@@ -9606,12 +9785,13 @@ class MachineWindow(QMainWindow):
9606
9785
  self.kill_segmentation()
9607
9786
  # Wait a bit for cleanup
9608
9787
  time.sleep(0.1)
9609
- if not self.use_two:
9610
- self.previewing = False
9788
+
9789
+ self.previewing = True
9611
9790
  try:
9612
9791
  try:
9613
9792
  self.segmenter.train_batch(self.parent().channel_data[2], speed = speed, use_gpu = self.use_gpu, use_two = self.use_two, mem_lock = self.mem_lock)
9614
9793
  self.trained = True
9794
+ self.start_segmentation()
9615
9795
  except Exception as e:
9616
9796
  print("Error training. Perhaps you forgot both foreground and background markers? I need both!")
9617
9797
  import traceback
@@ -9627,6 +9807,8 @@ class MachineWindow(QMainWindow):
9627
9807
 
9628
9808
  def start_segmentation(self):
9629
9809
 
9810
+ self.parent().static_background = None
9811
+
9630
9812
  self.kill_segmentation()
9631
9813
  time.sleep(0.1)
9632
9814
 
@@ -9635,12 +9817,10 @@ class MachineWindow(QMainWindow):
9635
9817
  else:
9636
9818
  print("Beginning new segmentation...")
9637
9819
 
9638
-
9639
- if self.parent().active_channel == 0:
9640
- if self.parent().channel_data[0] is not None:
9641
- active_data = self.parent().channel_data[0]
9642
- else:
9643
- active_data = self.parent().channel_data[1]
9820
+ if self.parent().channel_data[2] is not None:
9821
+ active_data = self.parent().channel_data[2]
9822
+ else:
9823
+ active_data = self.parent().channel_data[0]
9644
9824
 
9645
9825
  array3 = np.zeros_like(active_data).astype(np.uint8)
9646
9826
  self.parent().highlight_overlay = array3 #Clear this out for the segmenter to use
@@ -9649,8 +9829,7 @@ class MachineWindow(QMainWindow):
9649
9829
  return
9650
9830
  else:
9651
9831
  self.segmentation_worker = SegmentationWorker(self.parent().highlight_overlay, self.segmenter, self.use_gpu, self.use_two, self.previewing, self, self.mem_lock)
9652
- self.segmentation_worker.chunk_processed.connect(self.update_display) # Just update display
9653
- self.segmentation_worker.finished.connect(self.segmentation_finished)
9832
+ self.segmentation_worker.chunk_processed.connect(lambda: self.update_display(skip_paint_reinit = True)) # Just update display
9654
9833
  current_xlim = self.parent().ax.get_xlim()
9655
9834
  current_ylim = self.parent().ax.get_ylim()
9656
9835
  try:
@@ -9703,7 +9882,7 @@ class MachineWindow(QMainWindow):
9703
9882
 
9704
9883
  return changed
9705
9884
 
9706
- def update_display(self):
9885
+ def update_display(self, skip_paint_reinit = False):
9707
9886
  if not hasattr(self, '_last_update'):
9708
9887
  self._last_update = 0
9709
9888
 
@@ -9713,8 +9892,6 @@ class MachineWindow(QMainWindow):
9713
9892
 
9714
9893
  self._last_z = current_z
9715
9894
 
9716
- if self.previewing:
9717
- changed = self.check_for_z_change()
9718
9895
 
9719
9896
  current_time = time.time()
9720
9897
  if current_time - self._last_update >= 1: # Match worker's interval
@@ -9731,71 +9908,40 @@ class MachineWindow(QMainWindow):
9731
9908
 
9732
9909
  if not self.parent().painting:
9733
9910
  # Only update if view limits are valid
9734
- self.parent().update_display(preserve_zoom=(current_xlim, current_ylim))
9911
+ self.parent().update_display(preserve_zoom=(current_xlim, current_ylim), skip_paint_reinit = skip_paint_reinit)
9912
+
9913
+ if self.parent().brush_mode:
9914
+ current_xlim = self.parent().ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
9915
+ current_ylim = self.parent().ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
9916
+
9917
+ if self.parent().pen_button.isChecked():
9918
+ channel = self.parent().active_channel
9919
+ else:
9920
+ channel = 2
9735
9921
 
9922
+ self.parent().pm.initiate_paint_session(channel, current_xlim, current_ylim)
9736
9923
 
9737
9924
  self._last_update = current_time
9738
9925
  except Exception as e:
9739
9926
  print(f"Display update error: {e}")
9740
9927
 
9741
9928
  def poke_segmenter(self):
9742
- if self.use_two and self.previewing:
9743
- try:
9744
- # Clear any processing flags in the segmenter
9745
- if hasattr(self.segmenter, '_currently_processing'):
9746
- self.segmenter._currently_processing = None
9747
-
9748
- # Force regenerating the worker
9749
- if self.segmentation_worker is not None:
9750
- self.kill_segmentation()
9751
-
9752
- time.sleep(0.2)
9753
- self.start_segmentation()
9929
+ try:
9930
+ # Clear any processing flags in the segmenter
9931
+ if hasattr(self.segmenter, '_currently_processing'):
9932
+ self.segmenter._currently_processing = None
9754
9933
 
9755
- except Exception as e:
9756
- print(f"Error in poke_segmenter: {e}")
9757
- import traceback
9758
- traceback.print_exc()
9759
-
9760
- def segmentation_finished(self):
9761
-
9762
- current_xlim = self.parent().ax.get_xlim()
9763
- current_ylim = self.parent().ax.get_ylim()
9764
- self.parent().update_display(preserve_zoom=(current_xlim, current_ylim))
9765
-
9766
- # Store the current z position before killing the worker
9767
- current_z = self.parent().current_slice
9768
-
9769
- # Clean up the worker
9770
- self.kill_segmentation()
9771
- self.segmentation_worker = None
9772
- time.sleep(0.1)
9773
-
9774
- # Auto-restart for 2D preview mode only if certain conditions are met
9775
- if self.previewing and self.use_two:
9776
- # Track when this slice was last processed
9777
- if not hasattr(self, '_processed_slices'):
9778
- self._processed_slices = {}
9934
+ # Force regenerating the worker
9935
+ if self.segmentation_worker is not None:
9936
+ self.kill_segmentation()
9779
9937
 
9780
- current_time = time.time()
9938
+ time.sleep(0.2)
9939
+ self.start_segmentation()
9781
9940
 
9782
- # Check if we've recently tried to process this slice (to prevent loops)
9783
- recently_processed = False
9784
- if current_z in self._processed_slices:
9785
- time_since_last_attempt = current_time - self._processed_slices[current_z]
9786
- recently_processed = time_since_last_attempt < 5.0 # 5 second cooldown
9787
-
9788
- if not recently_processed:
9789
- self._processed_slices[current_z] = current_time
9790
-
9791
- # Reset any processing flags in the segmenter
9792
- if hasattr(self.segmenter, '_currently_processing'):
9793
- self.segmenter._currently_processing = None
9794
-
9795
- if 0 in self.parent().highlight_overlay[current_z, :, :]:
9796
- # Create a new worker after a brief delay
9797
- QTimer.singleShot(500, self.start_segmentation)
9798
-
9941
+ except Exception as e:
9942
+ print(f"Error in poke_segmenter: {e}")
9943
+ import traceback
9944
+ traceback.print_exc()
9799
9945
 
9800
9946
 
9801
9947
  def kill_segmentation(self):
@@ -9829,11 +9975,10 @@ class MachineWindow(QMainWindow):
9829
9975
 
9830
9976
  self.previewing = False
9831
9977
 
9832
- if self.parent().active_channel == 0:
9833
- if self.parent().channel_data[0] is not None:
9834
- active_data = self.parent().channel_data[0]
9835
- else:
9836
- active_data = self.parent().channel_data[1]
9978
+ if self.parent().channel_data[2] is not None:
9979
+ active_data = self.parent().channel_data[2]
9980
+ else:
9981
+ active_data = self.parent().channel_data[0]
9837
9982
 
9838
9983
  array3 = np.zeros_like(active_data).astype(np.uint8)
9839
9984
  self.parent().highlight_overlay = array3 #Clear this out for the segmenter to use
@@ -9844,6 +9989,8 @@ class MachineWindow(QMainWindow):
9844
9989
  self.parent().highlight_overlay = self.segmenter.segment_volume(array = self.parent().highlight_overlay)
9845
9990
  except Exception as e:
9846
9991
  print(f"Error segmenting (Perhaps retrain the model...): {e}")
9992
+ import traceback
9993
+ traceback.print_exc()
9847
9994
  return
9848
9995
 
9849
9996
  # Clean up when done
@@ -9880,6 +10027,12 @@ class MachineWindow(QMainWindow):
9880
10027
  # Kill the segmentation thread and wait for it to finish
9881
10028
  self.kill_segmentation()
9882
10029
  time.sleep(0.2) # Give additional time for cleanup
10030
+
10031
+ try:
10032
+ self.parent().channel_data[0] = self.parent().reduce_rgb_dimension(self.parent().channel_data[0], 'weight')
10033
+ self.update_display()
10034
+ except:
10035
+ pass
9883
10036
 
9884
10037
  self.parent().machine_window = None
9885
10038
  else:
@@ -9940,59 +10093,26 @@ class SegmentationWorker(QThread):
9940
10093
 
9941
10094
  # Remember the starting z position
9942
10095
  self.starting_z = self.segmenter.current_z
9943
-
9944
- if self.previewing and self.use_two:
9945
- # Process current z-slice in chunks
9946
- current_z = self.segmenter.current_z
9947
-
9948
- # Process the slice with chunked generator
9949
- for foreground, background in self.segmenter.segment_slice_chunked(current_z):
9950
- # Check for pause/stop before processing each chunk
9951
- self._check_pause()
9952
- if self._stop:
9953
- break
9954
-
9955
- if foreground == None and background == None:
9956
- self.get_poked()
9957
10096
 
9958
- if self._stop:
9959
- break
9960
-
9961
- # Update the overlay
9962
- for z,y,x in foreground:
9963
- self.overlay[z,y,x] = 1
9964
- for z,y,x in background:
9965
- self.overlay[z,y,x] = 2
9966
-
9967
- # Signal update after each chunk
9968
- self.chunks_since_update += 1
9969
- current_time = time.time()
9970
- if (self.chunks_since_update >= self.chunks_per_update and
9971
- current_time - self.last_update >= self.update_interval):
9972
- self.chunk_processed.emit()
9973
- self.chunks_since_update = 0
9974
- self.last_update = current_time
10097
+ # Original 3D approach
10098
+ for foreground_coords, background_coords in self.segmenter.segment_volume_realtime(gpu=self.use_gpu):
10099
+ # Check for pause/stop before processing each chunk
10100
+ self._check_pause()
10101
+ if self._stop:
10102
+ break
9975
10103
 
9976
- else:
9977
- # Original 3D approach
9978
- for foreground_coords, background_coords in self.segmenter.segment_volume_realtime(gpu=self.use_gpu):
9979
- # Check for pause/stop before processing each chunk
9980
- self._check_pause()
9981
- if self._stop:
9982
- break
9983
-
9984
- for z,y,x in foreground_coords:
9985
- self.overlay[z,y,x] = 1
9986
- for z,y,x in background_coords:
9987
- self.overlay[z,y,x] = 2
9988
-
9989
- self.chunks_since_update += 1
9990
- current_time = time.time()
9991
- if (self.chunks_since_update >= self.chunks_per_update and
9992
- current_time - self.last_update >= self.update_interval):
9993
- self.chunk_processed.emit()
9994
- self.chunks_since_update = 0
9995
- self.last_update = current_time
10104
+ for z,y,x in foreground_coords:
10105
+ self.overlay[z,y,x] = 1
10106
+ for z,y,x in background_coords:
10107
+ self.overlay[z,y,x] = 2
10108
+
10109
+ self.chunks_since_update += 1
10110
+ current_time = time.time()
10111
+ if (self.chunks_since_update >= self.chunks_per_update and
10112
+ current_time - self.last_update >= self.update_interval):
10113
+ self.chunk_processed.emit()
10114
+ self.chunks_since_update = 0
10115
+ self.last_update = current_time
9996
10116
 
9997
10117
  self.finished.emit()
9998
10118
 
@@ -10780,7 +10900,7 @@ class MaskDialog(QDialog):
10780
10900
 
10781
10901
  class CropDialog(QDialog):
10782
10902
 
10783
- def __init__(self, parent=None):
10903
+ def __init__(self, parent=None, args = None):
10784
10904
 
10785
10905
  try:
10786
10906
 
@@ -10788,18 +10908,26 @@ class CropDialog(QDialog):
10788
10908
  self.setWindowTitle("Crop Image (Will transpose any centroids)?")
10789
10909
  self.setModal(True)
10790
10910
 
10911
+ if args is None:
10912
+ xmin = 0
10913
+ xmax = self.parent().shape[2]
10914
+ ymin = 0
10915
+ ymax = self.parent().shape[1]
10916
+ else:
10917
+ xmin, xmax, ymin, ymax = args
10918
+
10791
10919
  layout = QFormLayout(self)
10792
10920
 
10793
- self.xmin = QLineEdit("0")
10921
+ self.xmin = QLineEdit(f"{xmin}")
10794
10922
  layout.addRow("X Min", self.xmin)
10795
10923
 
10796
- self.xmax = QLineEdit(f"{self.parent().shape[2]}")
10924
+ self.xmax = QLineEdit(f"{xmax}")
10797
10925
  layout.addRow("X Max", self.xmax)
10798
10926
 
10799
- self.ymin = QLineEdit("0")
10927
+ self.ymin = QLineEdit(f"{ymin}")
10800
10928
  layout.addRow("Y Min", self.ymin)
10801
10929
 
10802
- self.ymax = QLineEdit(f"{self.parent().shape[1]}")
10930
+ self.ymax = QLineEdit(f"{ymax}")
10803
10931
  layout.addRow("Y Max", self.ymax)
10804
10932
 
10805
10933
  self.zmin = QLineEdit("0")
@@ -10853,10 +10981,16 @@ class CropDialog(QDialog):
10853
10981
  transformed = centroids - np.array([zmin, ymin, xmin])
10854
10982
  transformed = transformed.astype(int)
10855
10983
 
10856
- # Boolean mask for valid coordinates
10857
- valid_mask = ((transformed >= 0) &
10858
- (transformed <= np.array([zmax, ymax, xmax]))).all(axis=1)
10984
+ # Create upper bounds array with same shape
10985
+ upper_bounds = np.array([zmax - zmin, ymax - ymin, xmax - xmin])
10859
10986
 
10987
+ # Boolean mask for valid coordinates - check each dimension separately
10988
+ z_valid = (transformed[:, 0] >= 0) & (transformed[:, 0] <= upper_bounds[0])
10989
+ y_valid = (transformed[:, 1] >= 0) & (transformed[:, 1] <= upper_bounds[1])
10990
+ x_valid = (transformed[:, 2] >= 0) & (transformed[:, 2] <= upper_bounds[2])
10991
+
10992
+ valid_mask = z_valid & y_valid & x_valid
10993
+
10860
10994
  # Rebuild dictionary with only valid entries
10861
10995
  my_network.node_centroids = {
10862
10996
  nodes[int(i)]: [int(transformed[i, 0]), int(transformed[i, 1]), int(transformed[i, 2])]
@@ -10865,6 +10999,15 @@ class CropDialog(QDialog):
10865
10999
 
10866
11000
  self.parent().format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
10867
11001
 
11002
+ if my_network.node_identities is not None:
11003
+ new_idens = {}
11004
+ for node, iden in my_network.node_identities.items():
11005
+ if node in my_network.node_centroids:
11006
+ new_idens[node] = iden
11007
+ my_network.node_identities = new_idens
11008
+
11009
+ self.parent().format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity', 'Node Identities')
11010
+
10868
11011
  except Exception as e:
10869
11012
 
10870
11013
  print(f"Error transposing node centroids: {e}")
@@ -11058,6 +11201,74 @@ class SkeletonizeDialog(QDialog):
11058
11201
  f"Error running skeletonize: {str(e)}"
11059
11202
  )
11060
11203
 
11204
+ class DistanceDialog(QDialog):
11205
+ def __init__(self, parent=None):
11206
+ super().__init__(parent)
11207
+ self.setWindowTitle("Compute Distance Transform (Applies xy and z scaling, set them to 1 if you want voxel correspondence)?")
11208
+ self.setModal(True)
11209
+
11210
+ layout = QFormLayout(self)
11211
+
11212
+ # Add Run button
11213
+ run_button = QPushButton("Run")
11214
+ run_button.clicked.connect(self.run)
11215
+ layout.addRow(run_button)
11216
+
11217
+ def run(self):
11218
+
11219
+ try:
11220
+
11221
+ data = self.parent().channel_data[self.parent().active_channel]
11222
+
11223
+ data = sdl.compute_distance_transform_distance(data, sampling = [my_network.z_scale, my_network.xy_scale, my_network.xy_scale])
11224
+
11225
+ self.parent().load_channel(self.parent().active_channel, data, data = True)
11226
+
11227
+ except Exception as e:
11228
+
11229
+ print(f"Error: {e}")
11230
+
11231
+ class GrayWaterDialog(QDialog):
11232
+ def __init__(self, parent=None):
11233
+ super().__init__(parent)
11234
+ self.setWindowTitle(f"Gray Watershed - Please segment out your background first (ie with intensity thresholding) or this will not work correctly. \nAt the moment, this is designed for similarly sized objects. Having mixed large/small objects may not work correctly.")
11235
+ self.setModal(True)
11236
+
11237
+ layout = QFormLayout(self)
11238
+
11239
+ self.min_peak_distance = QLineEdit("1")
11240
+ layout.addRow("Minimum Peak Distance (To any other peak - Recommended) (This is true voxel distance here)", self.min_peak_distance)
11241
+
11242
+ # Minimum Intensity
11243
+ self.min_intensity = QLineEdit("")
11244
+ layout.addRow("Minimum Peak Intensity (Optional):", self.min_intensity)
11245
+
11246
+ # Add Run button
11247
+ run_button = QPushButton("Run Watershed")
11248
+ run_button.clicked.connect(self.run_watershed)
11249
+ layout.addRow(run_button)
11250
+
11251
+ def run_watershed(self):
11252
+
11253
+ try:
11254
+
11255
+ min_intensity = float(self.min_intensity.text()) if self.min_intensity.text().strip() else None
11256
+
11257
+ min_peak_distance = int(self.min_peak_distance.text()) if self.min_peak_distance.text().strip() else 1
11258
+
11259
+ data = self.parent().channel_data[self.parent().active_channel]
11260
+
11261
+ data = n3d.gray_watershed(data, min_peak_distance, min_intensity)
11262
+
11263
+ self.parent().load_channel(self.parent().active_channel, data, data = True)
11264
+
11265
+ self.accept()
11266
+
11267
+ except Exception as e:
11268
+ print(f"Error: {e}")
11269
+
11270
+
11271
+
11061
11272
 
11062
11273
  class WatershedDialog(QDialog):
11063
11274
  def __init__(self, parent=None):
@@ -11084,10 +11295,14 @@ class WatershedDialog(QDialog):
11084
11295
  except:
11085
11296
  self.default = 0.05
11086
11297
 
11298
+ # Smallest radius (empty by default)
11299
+ self.smallest_rad = QLineEdit()
11300
+ self.smallest_rad.setPlaceholderText("Leave empty for None")
11301
+ layout.addRow(f"Smallest Radius (Objects any smaller may get thresholded out - this value always overrides below 'proportion' param). \n Somewhat more intuitive param then below, use a conservative value a bit smaller than your smallest object's radius:", self.smallest_rad)
11087
11302
 
11088
11303
  # Proportion (default 0.1)
11089
11304
  self.proportion = QLineEdit(f"{self.default}")
11090
- layout.addRow("Proportion:", self.proportion)
11305
+ layout.addRow(f"Proportion (0-1) of distance transform value set [ie unique elements] to exclude (ie 0.2 = 20% of the set of all values of the distance transform get excluded).\n Essentially, vals closer to 0 are less likely to split objects but also won't kick out small objects from the output, vals slightly further from 0 will split more aggressively, but vals closer to 1 become unstable, leading to objects being evicted or labelling errors. \nRecommend something between 0.05 and 0.4, but it depends on the data (Or just enter a smallest radius above to avoid using this). \nWill tell you in command window what equivalent 'smallest radius' this is):", self.proportion)
11091
11306
 
11092
11307
  # GPU checkbox (default True)
11093
11308
  self.gpu = QPushButton("GPU")
@@ -11095,10 +11310,6 @@ class WatershedDialog(QDialog):
11095
11310
  self.gpu.setChecked(False)
11096
11311
  layout.addRow("Use GPU:", self.gpu)
11097
11312
 
11098
- # Smallest radius (empty by default)
11099
- self.smallest_rad = QLineEdit()
11100
- self.smallest_rad.setPlaceholderText("Leave empty for None")
11101
- layout.addRow("Smallest Radius:", self.smallest_rad)
11102
11313
 
11103
11314
  # Predownsample (empty by default)
11104
11315
  self.predownsample = QLineEdit()
@@ -11106,11 +11317,11 @@ class WatershedDialog(QDialog):
11106
11317
  layout.addRow("Kernel Obtainment GPU Downsample:", self.predownsample)
11107
11318
 
11108
11319
  # Predownsample2 (empty by default)
11109
- self.predownsample2 = QLineEdit()
11110
- self.predownsample2.setPlaceholderText("Leave empty for None")
11111
- layout.addRow("Smart Label GPU Downsample:", self.predownsample2)
11320
+ #self.predownsample2 = QLineEdit()
11321
+ #self.predownsample2.setPlaceholderText("Leave empty for None")
11322
+ #layout.addRow("Smart Label GPU Downsample:", self.predownsample2)
11112
11323
 
11113
- layout.addRow("Note:", QLabel(f"If the optimal proportion watershed output is still labeling spatially seperated objects with the same label, try right placing the result in nodes or edges\nthen right click the image and choose 'select all', followed by right clicking and 'selection' -> 'split non-touching labels'."))
11324
+ #layout.addRow("Note:", QLabel(f"If the optimal proportion watershed output is still labeling spatially seperated objects with the same label, try right placing the result in nodes or edges\nthen right click the image and choose 'select all', followed by right clicking and 'selection' -> 'split non-touching labels'."))
11114
11325
 
11115
11326
 
11116
11327
  # Add Run button
@@ -11147,7 +11358,7 @@ class WatershedDialog(QDialog):
11147
11358
  # Get predownsample2 (None if empty)
11148
11359
  try:
11149
11360
  predownsample2 = float(self.predownsample2.text()) if self.predownsample2.text() else None
11150
- except ValueError:
11361
+ except:
11151
11362
  predownsample2 = None
11152
11363
 
11153
11364
  # Get the active channel data from parent
@@ -11325,7 +11536,22 @@ class CentroidNodeDialog(QDialog):
11325
11536
 
11326
11537
  if mode == 0:
11327
11538
 
11328
- my_network.nodes = my_network.centroid_array()
11539
+ try:
11540
+ shape = my_network.nodes.shape
11541
+
11542
+ except:
11543
+ try:
11544
+ shape = my_network.edges.shape
11545
+ except:
11546
+ try:
11547
+ shape = my_network.network_overlay.shape
11548
+ except:
11549
+ try:
11550
+ shape = my_network.id_overlay.shape
11551
+ except:
11552
+ shape = None
11553
+
11554
+ my_network.nodes = my_network.centroid_array(shape = shape)
11329
11555
 
11330
11556
  else:
11331
11557
 
@@ -11439,7 +11665,7 @@ class GenNodesDialog(QDialog):
11439
11665
 
11440
11666
  # Component dilation
11441
11667
  self.comp_dil = QLineEdit("0")
11442
- opt_layout.addWidget(QLabel("Voxel distance to merge nearby nodes (Compensates for multi-branch regions):"), 1, 0)
11668
+ opt_layout.addWidget(QLabel("Amount to expand nodes (Merges nearby nodes, say if they are overassigned, good for broader branch breaking):"), 1, 0)
11443
11669
  opt_layout.addWidget(self.comp_dil, 1, 1)
11444
11670
 
11445
11671
  opt_group.setLayout(opt_layout)
@@ -11573,11 +11799,11 @@ class GenNodesDialog(QDialog):
11573
11799
 
11574
11800
  class BranchDialog(QDialog):
11575
11801
 
11576
- def __init__(self, parent=None):
11802
+ def __init__(self, parent=None, called = False):
11577
11803
  super().__init__(parent)
11578
11804
  self.setWindowTitle("Label Branches (of edges)")
11579
11805
  self.setModal(True)
11580
-
11806
+
11581
11807
  # Main layout
11582
11808
  main_layout = QVBoxLayout(self)
11583
11809
 
@@ -11610,7 +11836,10 @@ class BranchDialog(QDialog):
11610
11836
 
11611
11837
  self.fix3 = QPushButton("Split Nontouching Branches?")
11612
11838
  self.fix3.setCheckable(True)
11613
- self.fix3.setChecked(True)
11839
+ if called:
11840
+ self.fix3.setChecked(True)
11841
+ else:
11842
+ self.fix3.setChecked(False)
11614
11843
  correction_layout.addWidget(QLabel("Split Nontouching Branches? (Useful if branch pruning - may want to threshold out small, split branches after): "), 4, 0)
11615
11844
  correction_layout.addWidget(self.fix3, 4, 1)
11616
11845
 
@@ -12137,6 +12366,8 @@ class CentroidDialog(QDialog):
12137
12366
 
12138
12367
  try:
12139
12368
 
12369
+ print("Calculating centroids...")
12370
+
12140
12371
  chan = self.mode_selector.currentIndex()
12141
12372
 
12142
12373
  # Get directory (None if empty)
@@ -12499,6 +12730,9 @@ class CalcAllDialog(QDialog):
12499
12730
 
12500
12731
 
12501
12732
  except Exception as e:
12733
+ import traceback
12734
+ print(traceback.format_exc())
12735
+
12502
12736
  QMessageBox.critical(
12503
12737
  self,
12504
12738
  "Error",
@@ -12506,6 +12740,7 @@ class CalcAllDialog(QDialog):
12506
12740
  )
12507
12741
 
12508
12742
 
12743
+
12509
12744
  class ProxDialog(QDialog):
12510
12745
  def __init__(self, parent=None):
12511
12746
  super().__init__(parent)
@@ -12744,11 +12979,6 @@ class ProxDialog(QDialog):
12744
12979
 
12745
12980
 
12746
12981
 
12747
-
12748
-
12749
-
12750
-
12751
-
12752
12982
  # Initiating this program from the script line:
12753
12983
 
12754
12984
  def run_gui():