nettracer3d 0.4.2__py3-none-any.whl → 0.4.4__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.
@@ -16,13 +16,15 @@ from nettracer3d import nettracer as n3d
16
16
  from nettracer3d import smart_dilate as sdl
17
17
  from nettracer3d import proximity as pxt
18
18
  from matplotlib.colors import LinearSegmentedColormap
19
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
19
20
  import pandas as pd
20
- from PyQt6.QtGui import (QFont, QCursor, QColor)
21
+ from PyQt6.QtGui import (QFont, QCursor, QColor, QPixmap, QPainter, QPen)
21
22
  import tifffile
22
23
  import copy
23
24
  import multiprocessing as mp
24
25
  from concurrent.futures import ThreadPoolExecutor
25
26
  from functools import partial
27
+ from nettracer3d import segmenter
26
28
 
27
29
 
28
30
  class ImageViewerWindow(QMainWindow):
@@ -105,11 +107,21 @@ class ImageViewerWindow(QMainWindow):
105
107
  self.zoom_mode = False
106
108
  self.original_xlim = None
107
109
  self.original_ylim = None
110
+ self.zoom_changed = False
108
111
 
109
112
  # Pan mode state
110
113
  self.pan_mode = False
111
114
  self.panning = False
112
115
  self.pan_start = None
116
+
117
+ #For ML segmenting mode
118
+ self.brush_mode = False
119
+ self.painting = False
120
+ self.foreground = True
121
+ self.machine_window = None
122
+ self.brush_size = 1 # Start with 1 pixel
123
+ self.min_brush_size = 1
124
+ self.max_brush_size = 10
113
125
 
114
126
  # Store brightness/contrast values for each channel
115
127
  self.channel_brightness = [{
@@ -176,6 +188,9 @@ class ImageViewerWindow(QMainWindow):
176
188
  buttons_layout.addWidget(self.pan_button)
177
189
 
178
190
  control_layout.addWidget(buttons_widget)
191
+
192
+ self.preview = False #Whether in preview mode or not
193
+ self.targs = None #Targets for preview mode
179
194
 
180
195
  # Create channel buttons
181
196
  self.channel_buttons = []
@@ -236,6 +251,8 @@ class ImageViewerWindow(QMainWindow):
236
251
  self.ax = self.figure.add_subplot(111)
237
252
  left_layout.addWidget(self.canvas)
238
253
 
254
+ self.canvas.mpl_connect('scroll_event', self.on_mpl_scroll)
255
+
239
256
 
240
257
  left_layout.addWidget(control_panel)
241
258
 
@@ -344,6 +361,7 @@ class ImageViewerWindow(QMainWindow):
344
361
  self.canvas.mpl_connect('button_press_event', self.on_mouse_press)
345
362
  self.canvas.mpl_connect('button_release_event', self.on_mouse_release)
346
363
  self.canvas.mpl_connect('motion_notify_event', self.on_mouse_move)
364
+
347
365
  #self.canvas.mpl_connect('button_press_event', self.on_mouse_click)
348
366
 
349
367
  # Initialize measurement points tracking
@@ -396,7 +414,7 @@ class ImageViewerWindow(QMainWindow):
396
414
  self.slice_slider.setValue(new_value)
397
415
 
398
416
 
399
- def create_highlight_overlay(self, node_indices=None, edge_indices=None, overlay1_indices = None, overlay2_indices = None):
417
+ def create_highlight_overlay(self, node_indices=None, edge_indices=None, overlay1_indices = None, overlay2_indices = None, bounds = False):
400
418
  """
401
419
  Create a binary overlay highlighting specific nodes and/or edges using parallel processing.
402
420
 
@@ -410,6 +428,12 @@ class ImageViewerWindow(QMainWindow):
410
428
  mask = np.isin(chunk_data, indices_to_check)
411
429
  return mask * 255
412
430
 
431
+ def process_chunk_bounds(chunk_data, indices_to_check):
432
+ """Process a single chunk of the array to create highlight mask"""
433
+
434
+ mask = (chunk_data >= indices_to_check[0]) & (chunk_data <= indices_to_check[1])
435
+ return mask * 255
436
+
413
437
  if node_indices is not None:
414
438
  if 0 in node_indices:
415
439
  node_indices.remove(0)
@@ -453,7 +477,7 @@ class ImageViewerWindow(QMainWindow):
453
477
  num_cores = mp.cpu_count()
454
478
 
455
479
  # Calculate chunk size along y-axis
456
- chunk_size = full_shape[0] // num_cores
480
+ chunk_size = full_shape[1] // num_cores
457
481
  if chunk_size < 1:
458
482
  chunk_size = 1
459
483
 
@@ -463,25 +487,32 @@ class ImageViewerWindow(QMainWindow):
463
487
 
464
488
  # Create chunks
465
489
  chunks = []
466
- for i in range(0, array_shape[0], chunk_size):
467
- end = min(i + chunk_size, array_shape[0])
468
- chunks.append(channel_data[i:end])
490
+ for i in range(0, array_shape[1], chunk_size):
491
+ end = min(i + chunk_size, array_shape[1])
492
+ chunks.append(channel_data[:, i:end, :])
469
493
 
470
494
  # Process chunks in parallel using ThreadPoolExecutor
471
- process_func = partial(process_chunk, indices_to_check=indices)
495
+ if not bounds:
496
+ process_func = partial(process_chunk, indices_to_check=indices)
497
+ else:
498
+ if len(indices) == 1:
499
+ indices.insert(0, 0)
500
+ process_func = partial(process_chunk_bounds, indices_to_check=indices)
501
+
472
502
 
473
503
  with ThreadPoolExecutor(max_workers=num_cores) as executor:
474
504
  chunk_results = list(executor.map(process_func, chunks))
475
505
 
476
506
  # Reassemble the chunks
477
- return np.vstack(chunk_results)
507
+ return np.concatenate(chunk_results, axis=1)
478
508
 
479
509
  # Process nodes and edges in parallel using multiprocessing
480
- with ThreadPoolExecutor(max_workers=2) as executor:
510
+ with ThreadPoolExecutor(max_workers=num_cores) as executor:
481
511
  future_nodes = executor.submit(process_channel, self.channel_data[0], node_indices, full_shape)
482
512
  future_edges = executor.submit(process_channel, self.channel_data[1], edge_indices, full_shape)
483
513
  future_overlay1 = executor.submit(process_channel, self.channel_data[2], overlay1_indices, full_shape)
484
514
  future_overlay2 = executor.submit(process_channel, self.channel_data[3], overlay2_indices, full_shape)
515
+
485
516
 
486
517
  # Get results
487
518
  node_overlay = future_nodes.result()
@@ -502,6 +533,84 @@ class ImageViewerWindow(QMainWindow):
502
533
  # Update display
503
534
  self.update_display(preserve_zoom=(current_xlim, current_ylim))
504
535
 
536
+ def create_highlight_overlay_slice(self, indices, bounds = False):
537
+
538
+
539
+ def process_chunk_bounds(chunk_data, indices_to_check):
540
+ """Process a single chunk of the array to create highlight mask"""
541
+ mask = (chunk_data >= indices_to_check[0]) & (chunk_data <= indices_to_check[1])
542
+ return mask * 255
543
+
544
+ def process_chunk(chunk_data, indices_to_check):
545
+ """Process a single chunk of the array to create highlight mask"""
546
+
547
+ mask = np.isin(chunk_data, indices_to_check)
548
+ return mask * 255
549
+
550
+ array = self.channel_data[self.active_channel]
551
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
552
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
553
+
554
+ current_slice = array[self.current_slice, :, :]
555
+ full_shape = array.shape
556
+ slice_shape = current_slice.shape
557
+
558
+ if self.highlight_overlay is None:
559
+
560
+ self.highlight_overlay = np.zeros(full_shape, dtype=np.uint8)
561
+
562
+ # Get number of CPU cores
563
+ num_cores = mp.cpu_count()
564
+
565
+ # Calculate chunk size along y-axis
566
+ chunk_size = slice_shape[0] // num_cores
567
+ if chunk_size < 1:
568
+ chunk_size = 1
569
+
570
+ def process_channel(channel_data, indices, array_shape):
571
+ if channel_data is None or not indices:
572
+ return None
573
+
574
+ # Create chunks
575
+ chunks = []
576
+ for i in range(0, array_shape[0], chunk_size):
577
+ end = min(i + chunk_size, array_shape[0])
578
+ chunks.append(channel_data[i:end])
579
+
580
+ # Process chunks in parallel using ThreadPoolExecutor
581
+ if not bounds:
582
+ process_func = partial(process_chunk, indices_to_check=indices)
583
+ else:
584
+ if len(indices) == 1:
585
+ indices.insert(0, 0)
586
+ process_func = partial(process_chunk_bounds, indices_to_check=indices)
587
+
588
+
589
+ with ThreadPoolExecutor(max_workers=num_cores) as executor:
590
+ chunk_results = list(executor.map(process_func, chunks))
591
+
592
+ # Reassemble the chunks
593
+ return np.vstack(chunk_results)
594
+
595
+ # Process nodes and edges in parallel using multiprocessing
596
+ with ThreadPoolExecutor(max_workers=num_cores) as executor:
597
+ future_highlight = executor.submit(process_channel, current_slice, indices, slice_shape)
598
+
599
+ # Get results
600
+ overlay = future_highlight.result()
601
+
602
+ try:
603
+
604
+ self.highlight_overlay[self.current_slice, :, :] = overlay
605
+ except:
606
+ pass
607
+
608
+ # Update display
609
+ self.update_display(preserve_zoom=(current_xlim, current_ylim), called = True)
610
+
611
+
612
+
613
+
505
614
 
506
615
 
507
616
 
@@ -1328,9 +1437,16 @@ class ImageViewerWindow(QMainWindow):
1328
1437
  if self.zoom_mode:
1329
1438
  self.pan_button.setChecked(False)
1330
1439
  self.pan_mode = False
1440
+ self.brush_mode = False
1441
+ if self.machine_window is not None:
1442
+ self.machine_window.silence_button()
1331
1443
  self.canvas.setCursor(Qt.CursorShape.CrossCursor)
1332
1444
  else:
1333
- self.canvas.setCursor(Qt.CursorShape.ArrowCursor)
1445
+ if self.machine_window is None:
1446
+ self.canvas.setCursor(Qt.CursorShape.ArrowCursor)
1447
+ else:
1448
+ self.machine_window.toggle_brush_button()
1449
+
1334
1450
 
1335
1451
  def toggle_pan_mode(self):
1336
1452
  """Toggle pan mode on/off."""
@@ -1338,14 +1454,132 @@ class ImageViewerWindow(QMainWindow):
1338
1454
  if self.pan_mode:
1339
1455
  self.zoom_button.setChecked(False)
1340
1456
  self.zoom_mode = False
1457
+ self.brush_mode = False
1458
+ if self.machine_window is not None:
1459
+ self.machine_window.silence_button()
1341
1460
  self.canvas.setCursor(Qt.CursorShape.OpenHandCursor)
1342
1461
  else:
1343
- self.canvas.setCursor(Qt.CursorShape.ArrowCursor)
1462
+ if self.machine_window is None:
1463
+ self.canvas.setCursor(Qt.CursorShape.ArrowCursor)
1464
+ else:
1465
+ self.machine_window.toggle_brush_button()
1466
+
1467
+
1468
+
1469
+ def on_mpl_scroll(self, event):
1470
+ """Handle matplotlib canvas scroll events"""
1471
+ #Wheel events
1472
+ if self.brush_mode and event.inaxes == self.ax:
1473
+ # Check if Ctrl is pressed
1474
+ if event.guiEvent.modifiers() & Qt.ShiftModifier:
1475
+ pass
1476
+
1477
+ elif event.guiEvent.modifiers() & Qt.ControlModifier:
1478
+ # Brush size adjustment code...
1479
+ step = 1 if event.button == 'up' else -1
1480
+ new_size = self.brush_size + step
1481
+
1482
+ if new_size < self.min_brush_size:
1483
+ new_size = self.min_brush_size
1484
+ elif new_size > self.max_brush_size:
1485
+ new_size = self.max_brush_size
1486
+
1487
+ self.brush_size = new_size
1488
+ self.update_brush_cursor()
1489
+ event.guiEvent.accept()
1490
+ return
1491
+
1492
+ # General scrolling code outside the brush mode condition
1493
+ step = 1 if event.button == 'up' else -1
1494
+
1495
+ if event.guiEvent.modifiers() & Qt.ShiftModifier:
1496
+ if event.guiEvent.modifiers() & Qt.ControlModifier:
1497
+ step = step * 3
1498
+ if (self.current_slice + step) < 0 or (self.current_slice + step) > self.slice_slider.maximum():
1499
+ return
1500
+
1501
+ self.current_slice = self.current_slice + step
1502
+ self.slice_slider.setValue(self.current_slice + step)
1503
+
1504
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
1505
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
1506
+
1507
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
1508
+
1509
+ def keyPressEvent(self, event):
1510
+ if event.key() == Qt.Key_Z:
1511
+ self.zoom_button.click()
1512
+ if self.machine_window is not None:
1513
+ if event.key() == Qt.Key_A:
1514
+ self.machine_window.switch_foreground()
1515
+
1516
+
1517
+ def update_brush_cursor(self):
1518
+ """Update the cursor to show brush size"""
1519
+ if not self.brush_mode:
1520
+ return
1521
+
1522
+ # Create a pixmap for the cursor
1523
+ size = self.brush_size * 2 + 2 # Add padding for border
1524
+ pixmap = QPixmap(size, size)
1525
+ pixmap.fill(Qt.transparent)
1526
+
1527
+ # Create painter for the pixmap
1528
+ painter = QPainter(pixmap)
1529
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
1530
+
1531
+ # Draw circle
1532
+ pen = QPen(Qt.white)
1533
+ pen.setWidth(1)
1534
+ painter.setPen(pen)
1535
+ painter.setBrush(Qt.transparent)
1536
+ painter.drawEllipse(1, 1, size-2, size-2)
1537
+
1538
+ # Create cursor from pixmap
1539
+ cursor = QCursor(pixmap)
1540
+ self.canvas.setCursor(cursor)
1541
+
1542
+ painter.end()
1543
+
1544
+ def get_line_points(self, x0, y0, x1, y1):
1545
+ """Get all points in a line between (x0,y0) and (x1,y1) using Bresenham's algorithm"""
1546
+ points = []
1547
+ dx = abs(x1 - x0)
1548
+ dy = abs(y1 - y0)
1549
+ x, y = x0, y0
1550
+ sx = 1 if x0 < x1 else -1
1551
+ sy = 1 if y0 < y1 else -1
1552
+
1553
+ if dx > dy:
1554
+ err = dx / 2.0
1555
+ while x != x1:
1556
+ points.append((x, y))
1557
+ err -= dy
1558
+ if err < 0:
1559
+ y += sy
1560
+ err += dx
1561
+ x += sx
1562
+ else:
1563
+ err = dy / 2.0
1564
+ while y != y1:
1565
+ points.append((x, y))
1566
+ err -= dx
1567
+ if err < 0:
1568
+ x += sx
1569
+ err += dy
1570
+ y += sy
1571
+
1572
+ points.append((x, y))
1573
+ return points
1344
1574
 
1345
1575
  def on_mouse_press(self, event):
1346
1576
  """Handle mouse press events."""
1347
1577
  if event.inaxes != self.ax:
1348
1578
  return
1579
+
1580
+ if event.button == 2:
1581
+ self.pan_button.click()
1582
+ return
1349
1583
 
1350
1584
  if self.zoom_mode:
1351
1585
  # Handle zoom mode press
@@ -1364,6 +1598,11 @@ class ImageViewerWindow(QMainWindow):
1364
1598
 
1365
1599
  self.ax.set_xlim([xdata - x_range, xdata + x_range])
1366
1600
  self.ax.set_ylim([ydata - y_range, ydata + y_range])
1601
+
1602
+ self.zoom_changed = True # Flag that zoom has changed
1603
+
1604
+ if not hasattr(self, 'zoom_changed'):
1605
+ self.zoom_changed = False
1367
1606
 
1368
1607
  elif event.button == 3: # Right click - zoom out
1369
1608
  x_range = (current_xlim[1] - current_xlim[0])
@@ -1381,6 +1620,11 @@ class ImageViewerWindow(QMainWindow):
1381
1620
  else:
1382
1621
  self.ax.set_xlim(new_xlim)
1383
1622
  self.ax.set_ylim(new_ylim)
1623
+
1624
+ self.zoom_changed = False # Flag that zoom has changed
1625
+
1626
+ if not hasattr(self, 'zoom_changed'):
1627
+ self.zoom_changed = False
1384
1628
 
1385
1629
  self.canvas.draw()
1386
1630
 
@@ -1388,6 +1632,50 @@ class ImageViewerWindow(QMainWindow):
1388
1632
  self.panning = True
1389
1633
  self.pan_start = (event.xdata, event.ydata)
1390
1634
  self.canvas.setCursor(Qt.CursorShape.ClosedHandCursor)
1635
+
1636
+ elif self.brush_mode:
1637
+ if event.inaxes != self.ax:
1638
+ return
1639
+
1640
+
1641
+ if event.button == 1 or event.button == 3:
1642
+
1643
+ if event.button == 3:
1644
+ self.erase = True
1645
+ else:
1646
+ self.erase = False
1647
+
1648
+ self.painting = True
1649
+ x, y = int(event.xdata), int(event.ydata)
1650
+ self.last_paint_pos = (x, y)
1651
+
1652
+ if self.foreground:
1653
+ channel = 2
1654
+ else:
1655
+ channel = 3
1656
+
1657
+
1658
+ # Paint at initial position
1659
+ self.paint_at_position(x, y, self.erase, channel)
1660
+
1661
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
1662
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
1663
+
1664
+
1665
+ self.canvas.draw()
1666
+ #self.update_display(preserve_zoom=(current_xlim, current_ylim))
1667
+ self.restore_channels = []
1668
+
1669
+
1670
+ for i in range(4):
1671
+ if i == channel:
1672
+ self.channel_visible[i] = True
1673
+ elif self.channel_data[i] is not None and self.channel_visible[i] == True:
1674
+ self.channel_visible[i] = False
1675
+ self.restore_channels.append(i)
1676
+ self.update_display(preserve_zoom = (current_xlim, current_ylim), begin_paint = True)
1677
+ self.update_display_slice(channel, preserve_zoom=(current_xlim, current_ylim))
1678
+
1391
1679
 
1392
1680
  elif event.button == 3: # Right click (for context menu)
1393
1681
  self.create_context_menu(event)
@@ -1397,12 +1685,32 @@ class ImageViewerWindow(QMainWindow):
1397
1685
  self.selection_start = (event.xdata, event.ydata)
1398
1686
  self.selecting = False # Will be set to True if the mouse moves while button is held
1399
1687
 
1688
+ def paint_at_position(self, center_x, center_y, erase = False, channel = 2):
1689
+ """Paint pixels within brush radius at given position"""
1690
+ if self.channel_data[channel] is None:
1691
+ return
1692
+
1693
+ if erase:
1694
+ val = 0
1695
+ else:
1696
+ val = 255
1697
+
1698
+ height, width = self.channel_data[channel][self.current_slice].shape
1699
+ radius = self.brush_size // 2
1700
+
1701
+ # Calculate brush area
1702
+ for y in range(max(0, center_y - radius), min(height, center_y + radius + 1)):
1703
+ for x in range(max(0, center_x - radius), min(width, center_x + radius + 1)):
1704
+ # Check if point is within circular brush area
1705
+ if (x - center_x) ** 2 + (y - center_y) ** 2 <= radius ** 2:
1706
+ self.channel_data[channel][self.current_slice][y, x] = val
1707
+
1400
1708
  def on_mouse_move(self, event):
1401
1709
  """Handle mouse movement events."""
1402
1710
  if event.inaxes != self.ax:
1403
1711
  return
1404
1712
 
1405
- if self.selection_start and not self.selecting and not self.pan_mode and not self.zoom_mode:
1713
+ if self.selection_start and not self.selecting and not self.pan_mode and not self.zoom_mode and not self.brush_mode:
1406
1714
  # If mouse has moved more than a tiny amount while button is held, start selection
1407
1715
  if (abs(event.xdata - self.selection_start[0]) > 1 or
1408
1716
  abs(event.ydata - self.selection_start[1]) > 1):
@@ -1424,6 +1732,7 @@ class ImageViewerWindow(QMainWindow):
1424
1732
  self.canvas.draw()
1425
1733
 
1426
1734
  elif self.panning and self.pan_start is not None:
1735
+
1427
1736
  # Calculate the movement
1428
1737
  dx = event.xdata - self.pan_start[0]
1429
1738
  dy = event.ydata - self.pan_start[1]
@@ -1459,6 +1768,39 @@ class ImageViewerWindow(QMainWindow):
1459
1768
  # Update pan start position
1460
1769
  self.pan_start = (event.xdata, event.ydata)
1461
1770
 
1771
+ elif self.painting and self.brush_mode:
1772
+ if event.inaxes != self.ax:
1773
+ return
1774
+
1775
+ x, y = int(event.xdata), int(event.ydata)
1776
+
1777
+ if self.foreground:
1778
+ channel = 2
1779
+ else:
1780
+ channel = 3
1781
+
1782
+
1783
+ if self.channel_data[2] is not None:
1784
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
1785
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
1786
+ height, width = self.channel_data[2][self.current_slice].shape
1787
+
1788
+ if hasattr(self, 'last_paint_pos'):
1789
+ last_x, last_y = self.last_paint_pos
1790
+ points = self.get_line_points(last_x, last_y, x, y)
1791
+
1792
+ # Paint at each point along the line
1793
+ for px, py in points:
1794
+ if 0 <= px < width and 0 <= py < height:
1795
+ self.paint_at_position(px, py, self.erase, channel)
1796
+
1797
+ self.last_paint_pos = (x, y)
1798
+
1799
+ self.canvas.draw()
1800
+ #self.update_display(preserve_zoom=(current_xlim, current_ylim))
1801
+ self.update_display_slice(channel, preserve_zoom=(current_xlim, current_ylim))
1802
+
1803
+
1462
1804
  def on_mouse_release(self, event):
1463
1805
  """Handle mouse release events."""
1464
1806
  if self.pan_mode:
@@ -1520,15 +1862,27 @@ class ImageViewerWindow(QMainWindow):
1520
1862
  elif not self.selecting and self.selection_start: # If we had a click but never started selection
1521
1863
  # Handle as a normal click
1522
1864
  self.on_mouse_click(event)
1865
+
1523
1866
 
1524
1867
  # Clean up
1525
1868
  self.selection_start = None
1526
1869
  self.selecting = False
1870
+
1871
+
1527
1872
  if self.selection_rect is not None:
1528
1873
  self.selection_rect.remove()
1529
1874
  self.selection_rect = None
1530
1875
  self.canvas.draw()
1531
1876
 
1877
+ if self.brush_mode:
1878
+ self.painting = False
1879
+ for i in self.restore_channels:
1880
+ self.channel_visible[i] = True
1881
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
1882
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
1883
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
1884
+
1885
+
1532
1886
  def highlight_value_in_tables(self, clicked_value):
1533
1887
  """Helper method to find and highlight a value in both tables."""
1534
1888
 
@@ -1622,6 +1976,11 @@ class ImageViewerWindow(QMainWindow):
1622
1976
 
1623
1977
  self.ax.set_xlim([xdata - x_range, xdata + x_range])
1624
1978
  self.ax.set_ylim([ydata - y_range, ydata + y_range])
1979
+
1980
+ self.zoom_changed = True # Flag that zoom has changed
1981
+
1982
+ if not hasattr(self, 'zoom_changed'):
1983
+ self.zoom_changed = False
1625
1984
 
1626
1985
  elif event.button == 3: # Right click - zoom out
1627
1986
  x_range = (current_xlim[1] - current_xlim[0])
@@ -1639,6 +1998,11 @@ class ImageViewerWindow(QMainWindow):
1639
1998
  else:
1640
1999
  self.ax.set_xlim(new_xlim)
1641
2000
  self.ax.set_ylim(new_ylim)
2001
+
2002
+
2003
+ self.zoom_changed = False # Flag that zoom has changed
2004
+
2005
+
1642
2006
 
1643
2007
  self.canvas.draw()
1644
2008
 
@@ -1652,7 +2016,7 @@ class ImageViewerWindow(QMainWindow):
1652
2016
  x_idx = int(round(event.xdata))
1653
2017
  y_idx = int(round(event.ydata))
1654
2018
  # Check if Ctrl key is pressed (using matplotlib's key_press system)
1655
- ctrl_pressed = 'ctrl' in event.modifiers # Note: changed from 'control' to 'ctrl'
2019
+ ctrl_pressed = 'ctrl' in event.modifiers
1656
2020
  if self.channel_data[self.active_channel][self.current_slice, y_idx, x_idx] != 0:
1657
2021
  clicked_value = self.channel_data[self.active_channel][self.current_slice, y_idx, x_idx]
1658
2022
  else:
@@ -2055,8 +2419,9 @@ class ImageViewerWindow(QMainWindow):
2055
2419
 
2056
2420
  def show_thresh_dialog(self):
2057
2421
  """Show threshold dialog"""
2058
- thresh_window = ThresholdWindow(self)
2059
- thresh_window.show() # Non-modal window
2422
+ dialog = ThresholdDialog(self)
2423
+ dialog.exec()
2424
+
2060
2425
 
2061
2426
  def show_mask_dialog(self):
2062
2427
  """Show the mask dialog"""
@@ -2265,13 +2630,10 @@ class ImageViewerWindow(QMainWindow):
2265
2630
 
2266
2631
 
2267
2632
  except Exception as e:
2268
- import traceback
2269
- print(traceback.format_exc())
2270
2633
  print(f"An error has occured: {e}")
2271
2634
 
2272
2635
  except Exception as e:
2273
- import traceback
2274
- print(traceback.format_exc())
2636
+
2275
2637
  QMessageBox.critical(
2276
2638
  self,
2277
2639
  "Error Loading",
@@ -2513,12 +2875,22 @@ class ImageViewerWindow(QMainWindow):
2513
2875
  """Shows a dialog asking user to confirm if image is 2D RGB"""
2514
2876
  msg = QMessageBox()
2515
2877
  msg.setIcon(QMessageBox.Icon.Question)
2516
- msg.setText("Image Format Detection")
2878
+ msg.setText("Image Format Alert")
2517
2879
  msg.setInformativeText("Is this a 2D color (RGB/CMYK) image?")
2518
2880
  msg.setWindowTitle("Confirm Image Format")
2519
2881
  msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
2520
2882
  return msg.exec() == QMessageBox.StandardButton.Yes
2521
2883
 
2884
+ def confirm_resize_dialog(self):
2885
+ """Shows a dialog asking user to resize image"""
2886
+ msg = QMessageBox()
2887
+ msg.setIcon(QMessageBox.Icon.Question)
2888
+ msg.setText("Image Format Alert")
2889
+ msg.setInformativeText(f"This image is a different shape than the ones loaded into the viewer window. Trying to run processes with images of different sizes has a high probability of crashing the program.\nPress yes to resize the new image to the other images. Press no to load it anyway.")
2890
+ msg.setWindowTitle("Resize")
2891
+ msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
2892
+ return msg.exec() == QMessageBox.StandardButton.Yes
2893
+
2522
2894
  def load_channel(self, channel_index, channel_data=None, data=False, assign_shape = True):
2523
2895
  """Load a channel and enable active channel selection if needed."""
2524
2896
 
@@ -2532,6 +2904,7 @@ class ImageViewerWindow(QMainWindow):
2532
2904
  "TIFF Files (*.tif *.tiff)"
2533
2905
  )
2534
2906
  self.channel_data[channel_index] = tifffile.imread(filename)
2907
+
2535
2908
  if len(self.channel_data[channel_index].shape) == 2: # handle 2d data
2536
2909
  self.channel_data[channel_index] = np.expand_dims(self.channel_data[channel_index], axis=0)
2537
2910
 
@@ -2547,6 +2920,23 @@ class ImageViewerWindow(QMainWindow):
2547
2920
  if len(self.channel_data[channel_index].shape) == 4 and (channel_index == 0 or channel_index == 1):
2548
2921
  self.channel_data[channel_index] = self.reduce_rgb_dimension(self.channel_data[channel_index])
2549
2922
 
2923
+ reset_resize = False
2924
+
2925
+ for i in range(4): #Try to ensure users don't load in different sized arrays
2926
+ if self.channel_data[i] is None or i == channel_index or data:
2927
+ if self.highlight_overlay is not None: #Make sure highlight overlay is always the same shape as new images
2928
+ if self.channel_data[i].shape[:3] != self.highlight_overlay.shape:
2929
+ self.resizing = True
2930
+ reset_resize = True
2931
+ self.highlight_overlay = None
2932
+ continue
2933
+ else:
2934
+ old_shape = self.channel_data[i].shape[:3] #Ask user to resize images that are shaped differently
2935
+ if old_shape != self.channel_data[channel_index].shape[:3]:
2936
+ if self.confirm_resize_dialog():
2937
+ self.channel_data[channel_index] = n3d.upsample_with_padding(self.channel_data[channel_index], original_shape = old_shape)
2938
+ break
2939
+
2550
2940
 
2551
2941
  if channel_index == 0:
2552
2942
  my_network.nodes = self.channel_data[channel_index]
@@ -2601,11 +2991,14 @@ class ImageViewerWindow(QMainWindow):
2601
2991
  self.volume_dict[channel_index] = None #reset volumes
2602
2992
 
2603
2993
  if assign_shape: #keep original shape tracked to undo resampling.
2604
- self.original_shape = self.channel_data[channel_index].shape
2994
+ if self.original_shape is None:
2995
+ self.original_shape = self.channel_data[channel_index].shape
2996
+ elif self.original_shape[0] < self.channel_data[channel_index].shape[0] or self.original_shape[1] < self.channel_data[channel_index].shape[1] or self.original_shape[2] < self.channel_data[channel_index].shape[2]:
2997
+ self.original_shape = self.channel_data[channel_index].shape
2605
2998
  if len(self.original_shape) == 4:
2606
2999
  self.original_shape = (self.original_shape[0], self.original_shape[1], self.original_shape[2])
2607
3000
 
2608
- self.update_display()
3001
+ self.update_display(reset_resize = reset_resize)
2609
3002
 
2610
3003
 
2611
3004
 
@@ -2829,13 +3222,21 @@ class ImageViewerWindow(QMainWindow):
2829
3222
  current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2830
3223
  # Convert slider values (0-100) to data values (0-1)
2831
3224
  min_val, max_val = values
2832
- self.channel_brightness[channel_index]['min'] = min_val / 255
3225
+ self.channel_brightness[channel_index]['min'] = min_val / 255 #Accomodate 32 bit data?
2833
3226
  self.channel_brightness[channel_index]['max'] = max_val / 255
2834
3227
  self.update_display(preserve_zoom = (current_xlim, current_ylim))
3228
+
3229
+
3230
+
2835
3231
 
2836
- def update_display(self, preserve_zoom=None, dims = None):
3232
+ def update_display(self, preserve_zoom=None, dims = None, called = False, reset_resize = False, begin_paint = False):
2837
3233
  """Update the display with currently visible channels and highlight overlay."""
2838
3234
 
3235
+ if begin_paint:
3236
+ # Store/update the static background with current zoom level
3237
+ self.static_background = self.canvas.copy_from_bbox(self.ax.bbox)
3238
+
3239
+
2839
3240
  self.figure.clear()
2840
3241
 
2841
3242
  # Get active channels and their dimensions
@@ -2927,6 +3328,9 @@ class ImageViewerWindow(QMainWindow):
2927
3328
  vmax=1,
2928
3329
  extent=(-0.5, min_width-0.5, min_height-0.5, -0.5))
2929
3330
 
3331
+ if self.preview and not called:
3332
+ self.create_highlight_overlay_slice(self.targs, bounds = self.bounds)
3333
+
2930
3334
  # Add highlight overlay if it exists
2931
3335
  if self.highlight_overlay is not None:
2932
3336
  highlight_slice = self.highlight_overlay[self.current_slice]
@@ -2939,6 +3343,7 @@ class ImageViewerWindow(QMainWindow):
2939
3343
  alpha=0.5)
2940
3344
 
2941
3345
 
3346
+
2942
3347
 
2943
3348
  # Style the axes
2944
3349
  self.ax.set_xlabel('X')
@@ -2987,9 +3392,42 @@ class ImageViewerWindow(QMainWindow):
2987
3392
  if current_xlim is not None and current_ylim is not None:
2988
3393
  self.ax.set_xlim(current_xlim)
2989
3394
  self.ax.set_ylim(current_ylim)
3395
+ if reset_resize:
3396
+ self.resizing = False
2990
3397
 
2991
3398
  self.canvas.draw()
2992
3399
 
3400
+ def update_display_slice(self, channel, preserve_zoom=None):
3401
+ """Ultra minimal update that only changes the paint channel's data"""
3402
+ if not self.channel_visible[channel]:
3403
+ return
3404
+
3405
+ if preserve_zoom:
3406
+ current_xlim, current_ylim = preserve_zoom
3407
+ if current_xlim is not None and current_ylim is not None:
3408
+ self.ax.set_xlim(current_xlim)
3409
+ self.ax.set_ylim(current_ylim)
3410
+
3411
+
3412
+ # Find the existing image for channel (paint channel)
3413
+ channel_image = None
3414
+ for img in self.ax.images:
3415
+ if img.cmap.name == f'custom_{channel}':
3416
+ channel_image = img
3417
+ break
3418
+
3419
+ if channel_image is not None:
3420
+ # Update the data of the existing image
3421
+ channel_image.set_array(self.channel_data[channel][self.current_slice])
3422
+
3423
+ # Restore the static background (all other channels) at current zoom level
3424
+ self.canvas.restore_region(self.static_background)
3425
+ # Draw just our paint channel
3426
+ self.ax.draw_artist(channel_image)
3427
+ # Blit everything
3428
+ self.canvas.blit(self.ax.bbox)
3429
+ self.canvas.flush_events()
3430
+
2993
3431
  def show_netshow_dialog(self):
2994
3432
  dialog = NetShowDialog(self)
2995
3433
  dialog.exec()
@@ -4162,17 +4600,18 @@ class WhiteDialog(QDialog):
4162
4600
  def white_overlay(self):
4163
4601
 
4164
4602
  try:
4165
-
4166
- try:
4603
+ if isinstance(my_network.nodes, np.ndarray) :
4167
4604
  overlay = np.ones_like(my_network.nodes).astype(np.uint8) * 255
4168
- except:
4605
+ elif isinstance(my_network.edges, np.ndarray):
4169
4606
  overlay = np.ones_like(my_network.edges).astype(np.uint8) * 255
4170
- finally:
4171
- my_network.id_overlay = overlay
4607
+ elif isinstance(my_network.network_overlay, np.ndarray):
4608
+ overlay = np.ones_like(my_network.network_overlay).astype(np.uint8) * 255
4172
4609
 
4173
- self.parent().load_channel(3, channel_data = my_network.id_overlay, data = True)
4610
+ my_network.id_overlay = overlay
4174
4611
 
4175
- self.accept()
4612
+ self.parent().load_channel(3, channel_data = my_network.id_overlay, data = True)
4613
+
4614
+ self.accept()
4176
4615
 
4177
4616
  except Exception as e:
4178
4617
  print(f"Error making white background: {e}")
@@ -4274,7 +4713,7 @@ class NetShowDialog(QDialog):
4274
4713
  self.geo_layout = QPushButton("geo_layout")
4275
4714
  self.geo_layout.setCheckable(True)
4276
4715
  self.geo_layout.setChecked(False)
4277
- layout.addRow("Use Geometric Layout:", self.geo_layout)
4716
+ layout.addRow("Use Geographic Layout:", self.geo_layout)
4278
4717
 
4279
4718
  # Add mode selection dropdown
4280
4719
  self.mode_selector = QComboBox()
@@ -5246,101 +5685,504 @@ class LabelDialog(QDialog):
5246
5685
  f"Error running label: {str(e)}"
5247
5686
  )
5248
5687
 
5249
- class ThresholdWindow(QMainWindow):
5688
+ class ThresholdDialog(QDialog):
5250
5689
  def __init__(self, parent=None):
5251
5690
  super().__init__(parent)
5252
- self.setWindowTitle("Threshold Params (Active Image)")
5253
-
5254
- # Create central widget and layout
5255
- central_widget = QWidget()
5256
- self.setCentralWidget(central_widget)
5257
- layout = QFormLayout(central_widget)
5258
-
5259
- self.min = QLineEdit("")
5260
- layout.addRow("Minimum Value to retain:", self.min)
5691
+ self.setWindowTitle("Choose Threshold Mode")
5692
+ self.setModal(True)
5261
5693
 
5262
- # Create widgets
5263
- self.max = QLineEdit("")
5264
- layout.addRow("Maximum Value to retain:", self.max)
5694
+ layout = QFormLayout(self)
5265
5695
 
5266
5696
  # Add mode selection dropdown
5267
5697
  self.mode_selector = QComboBox()
5268
- self.mode_selector.addItems(["Using Volumes", "Using Label/Brightness"])
5698
+ self.mode_selector.addItems(["Using Label/Brightness", "Using Volumes"])
5269
5699
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
5270
5700
  layout.addRow("Execution Mode:", self.mode_selector)
5271
5701
 
5272
5702
  # Add Run button
5273
- prev_button = QPushButton("Preview")
5274
- prev_button.clicked.connect(self.run_preview)
5275
- layout.addRow(prev_button)
5703
+ run_button = QPushButton("Select")
5704
+ run_button.clicked.connect(self.thresh_mode)
5705
+ layout.addRow(run_button)
5706
+
5707
+ # Add ML button
5708
+ ML = QPushButton("Machine Learning")
5709
+ ML.clicked.connect(self.start_ml)
5710
+ layout.addRow(ML)
5711
+
5712
+
5713
+ def thresh_mode(self):
5714
+
5715
+ try:
5716
+
5717
+ accepted_mode = self.mode_selector.currentIndex()
5718
+
5719
+ if accepted_mode == 1:
5720
+ if len(np.unique(self.parent().channel_data[self.parent().active_channel])) < 3:
5721
+ self.parent().show_label_dialog()
5722
+
5723
+ if self.parent().volume_dict[self.parent().active_channel] is None:
5724
+ self.parent().volumes()
5725
+
5726
+ thresh_window = ThresholdWindow(self.parent(), accepted_mode)
5727
+ thresh_window.show() # Non-modal window
5728
+ self.highlight_overlay = None
5729
+ self.accept()
5730
+ except:
5731
+ pass
5732
+
5733
+ def start_ml(self):
5734
+
5735
+
5736
+ 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:
5737
+ if self.confirm_machine_dialog():
5738
+ pass
5739
+ else:
5740
+ return
5741
+ elif self.parent().channel_data[0] is None and self.parent().channel_data[1] is None:
5742
+ QMessageBox.critical(
5743
+ self,
5744
+ "Alert",
5745
+ "Requires the channel for segmentation to be loaded into either the nodes or edges channels"
5746
+ )
5747
+ return
5748
+
5749
+
5750
+ self.parent().machine_window = MachineWindow(self.parent())
5751
+ self.parent().machine_window.show() # Non-modal window
5752
+ self.accept()
5753
+
5754
+ def confirm_machine_dialog(self):
5755
+ """Shows a dialog asking user to confirm if they want to start the segmenter"""
5756
+ msg = QMessageBox()
5757
+ msg.setIcon(QMessageBox.Icon.Question)
5758
+ msg.setText("Alert")
5759
+ msg.setInformativeText("Use of this feature will require use of both overlay channels and the highlight overlay. Please save any data and return, or proceed if you do not need those overlays")
5760
+ msg.setWindowTitle("Proceed?")
5761
+ msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
5762
+ return msg.exec() == QMessageBox.StandardButton.Yes
5763
+
5764
+
5765
+ class MachineWindow(QMainWindow):
5766
+
5767
+ def __init__(self, parent=None):
5768
+ super().__init__(parent)
5769
+
5770
+ self.setWindowTitle("Threshold")
5276
5771
 
5277
- # Add Run button
5772
+ # Create central widget and layout
5773
+ central_widget = QWidget()
5774
+ self.setCentralWidget(central_widget)
5775
+ layout = QVBoxLayout(central_widget)
5776
+
5777
+
5778
+ # Create form layout for inputs
5779
+ form_layout = QFormLayout()
5780
+
5781
+ layout.addLayout(form_layout)
5782
+
5783
+
5784
+
5785
+
5786
+ if self.parent().active_channel == 0:
5787
+ if self.parent().channel_data[0] is not None:
5788
+ active_data = self.parent().channel_data[0]
5789
+ else:
5790
+ active_data = self.parent().channel_data[1]
5791
+
5792
+
5793
+ array1 = np.zeros_like(active_data)
5794
+ array2 = np.zeros_like(active_data)
5795
+ array3 = np.zeros_like(active_data)
5796
+ self.parent().highlight_overlay = array3 #Clear this out for the segmenter to use
5797
+
5798
+ self.parent().load_channel(2, array1, True) #Temp for debugging
5799
+ self.parent().load_channel(3, array2, True) #Temp for debugging
5800
+
5801
+ self.parent().base_colors[2] = self.parent().color_dictionary['LIGHT_GREEN']
5802
+ self.parent().base_colors[3] = self.parent().color_dictionary['LIGHT_RED']
5803
+
5804
+
5805
+ # Set a reasonable default size
5806
+ self.setMinimumWidth(400)
5807
+ self.setMinimumHeight(400)
5808
+
5809
+ # Create zoom button and pan button
5810
+ buttons_widget = QWidget()
5811
+ buttons_layout = QHBoxLayout(buttons_widget)
5812
+
5813
+ # Create zoom button
5814
+ self.brush_button = QPushButton("🖌️")
5815
+ self.brush_button.setCheckable(True)
5816
+ self.brush_button.setFixedSize(40, 40)
5817
+ self.brush_button.clicked.connect(self.toggle_brush_mode)
5818
+ form_layout.addWidget(self.brush_button)
5819
+ self.brush_button.click()
5820
+
5821
+ self.fore_button = QPushButton("Foreground")
5822
+ self.fore_button.setCheckable(True)
5823
+ self.fore_button.setChecked(True)
5824
+ self.fore_button.clicked.connect(self.toggle_foreground)
5825
+ form_layout.addWidget(self.fore_button)
5826
+
5827
+ self.back_button = QPushButton("Background")
5828
+ self.back_button.setCheckable(True)
5829
+ self.back_button.setChecked(False)
5830
+ self.back_button.clicked.connect(self.toggle_background)
5831
+ form_layout.addWidget(self.back_button)
5832
+
5833
+ train_button = QPushButton("Train Model")
5834
+ train_button.clicked.connect(self.train_model)
5835
+ form_layout.addRow(train_button)
5836
+
5837
+ seg_button = QPushButton("Segment")
5838
+ seg_button.clicked.connect(self.segment)
5839
+ form_layout.addRow(seg_button)
5840
+
5841
+ self.trained = False
5842
+
5843
+
5844
+ self.segmenter = segmenter.InteractiveSegmenter(active_data, use_gpu=True)
5845
+
5846
+
5847
+
5848
+
5849
+ def toggle_foreground(self):
5850
+
5851
+ self.parent().foreground = self.fore_button.isChecked()
5852
+
5853
+ if self.parent().foreground:
5854
+ self.back_button.setChecked(False)
5855
+ else:
5856
+ self.back_button.setChecked(True)
5857
+
5858
+ def switch_foreground(self):
5859
+
5860
+ self.fore_button.click()
5861
+
5862
+ def toggle_background(self):
5863
+
5864
+ self.parent().foreground = not self.back_button.isChecked()
5865
+
5866
+ if not self.parent().foreground:
5867
+ self.fore_button.setChecked(False)
5868
+ else:
5869
+ self.fore_button.setChecked(True)
5870
+
5871
+
5872
+
5873
+ def toggle_brush_mode(self):
5874
+ """Toggle brush mode on/off"""
5875
+ self.parent().brush_mode = self.brush_button.isChecked()
5876
+ if self.parent().brush_mode:
5877
+ self.parent().pan_button.setChecked(False)
5878
+ self.parent().zoom_button.setChecked(False)
5879
+ self.parent().pan_mode = False
5880
+ self.parent().zoom_mode = False
5881
+ self.parent().update_brush_cursor()
5882
+ else:
5883
+ self.parent().zoom_button.click()
5884
+
5885
+ def silence_button(self):
5886
+ self.brush_button.setChecked(False)
5887
+
5888
+ def toggle_brush_button(self):
5889
+
5890
+ self.brush_button.click()
5891
+
5892
+ def train_model(self):
5893
+
5894
+ self.segmenter.train_batch(self.parent().channel_data[2], self.parent().channel_data[3])
5895
+ self.trained = True
5896
+
5897
+ def segment(self):
5898
+
5899
+ if not self.trained:
5900
+ return
5901
+ else:
5902
+ foreground_coords, background_coords = self.segmenter.segment_volume()
5903
+
5904
+ # Clean up when done
5905
+ self.segmenter.cleanup()
5906
+
5907
+ for z,y,x in foreground_coords:
5908
+ self.parent().highlight_overlay[z,y,x] = True
5909
+
5910
+ self.parent().update_display()
5911
+
5912
+ def closeEvent(self, event):
5913
+ if self.brush_button.isChecked():
5914
+ self.silence_button()
5915
+ self.toggle_brush_mode()
5916
+ self.parent().brush_mode = False
5917
+ self.parent().machine_window = None
5918
+
5919
+
5920
+
5921
+
5922
+
5923
+ class ThresholdWindow(QMainWindow):
5924
+ def __init__(self, parent=None, accepted_mode=0):
5925
+ super().__init__(parent)
5926
+ self.setWindowTitle("Threshold")
5927
+
5928
+ # Create central widget and layout
5929
+ central_widget = QWidget()
5930
+ self.setCentralWidget(central_widget)
5931
+ layout = QVBoxLayout(central_widget)
5932
+
5933
+ # Get histogram data
5934
+ if accepted_mode == 1:
5935
+ self.histo_list = list(self.parent().volume_dict[self.parent().active_channel].values())
5936
+ self.bounds = False
5937
+ self.parent().bounds = False
5938
+ elif accepted_mode == 0:
5939
+ targ_shape = self.parent().channel_data[self.parent().active_channel].shape
5940
+ if (targ_shape[0] + targ_shape[1] + targ_shape[2]) > 2500: #Take a simpler histogram on big arrays
5941
+ temp_max = np.max(self.parent().channel_data[self.parent().active_channel])
5942
+ temp_min = np.min(self.parent().channel_data[self.parent().active_channel])
5943
+ temp_array = n3d.downsample(self.parent().channel_data[self.parent().active_channel], 5)
5944
+ self.histo_list = temp_array.flatten().tolist()
5945
+ self.histo_list.append(temp_min)
5946
+ self.histo_list.append(temp_max)
5947
+ else: #Otherwise just use full array data
5948
+ self.histo_list = self.parent().channel_data[self.parent().active_channel].flatten().tolist()
5949
+ self.bounds = True
5950
+ self.parent().bounds = True
5951
+
5952
+ # Create matplotlib figure
5953
+ fig = Figure(figsize=(5, 4))
5954
+ self.canvas = FigureCanvas(fig)
5955
+ layout.addWidget(self.canvas)
5956
+
5957
+ # Pre-compute histogram with numpy
5958
+ counts, bin_edges = np.histogram(self.histo_list, bins=50)
5959
+ bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
5960
+
5961
+ # Plot pre-computed histogram
5962
+ self.ax = fig.add_subplot(111)
5963
+ self.ax.bar(bin_centers, counts, width=bin_edges[1] - bin_edges[0], alpha=0.5)
5964
+
5965
+ # Add vertical lines for thresholds
5966
+ self.min_line = self.ax.axvline(min(self.histo_list), color='r')
5967
+ self.max_line = self.ax.axvline(max(self.histo_list), color='b')
5968
+
5969
+ # Connect events for dragging
5970
+ self.canvas.mpl_connect('button_press_event', self.on_press)
5971
+ self.canvas.mpl_connect('motion_notify_event', self.on_motion)
5972
+ self.canvas.mpl_connect('button_release_event', self.on_release)
5973
+
5974
+ self.dragging = None
5975
+
5976
+ # Store histogram bounds
5977
+ if self.bounds:
5978
+ self.data_min = 0
5979
+ else:
5980
+ self.data_min = min(self.histo_list)
5981
+ self.data_max = max(self.histo_list)
5982
+
5983
+ # Create form layout for inputs
5984
+ form_layout = QFormLayout()
5985
+
5986
+ self.min = QLineEdit(f"{self.data_min}")
5987
+ self.min.editingFinished.connect(self.min_value_changed)
5988
+ form_layout.addRow("Minimum Value to retain:", self.min)
5989
+ self.prev_min = self.data_min
5990
+
5991
+ self.max = QLineEdit(f"{self.data_max}")
5992
+ self.max.editingFinished.connect(self.max_value_changed)
5993
+ form_layout.addRow("Maximum Value to retain:", self.max)
5994
+ self.prev_max = self.data_max
5995
+
5996
+ self.targs = [self.prev_min, self.prev_max]
5997
+
5998
+ # preview checkbox (default False)
5999
+ self.preview = QPushButton("Preview")
6000
+ self.preview.setCheckable(True)
6001
+ self.preview.setChecked(False)
6002
+ self.preview.clicked.connect(self.preview_mode)
6003
+ form_layout.addRow("Show Preview:", self.preview)
6004
+
5278
6005
  run_button = QPushButton("Apply Threshold")
5279
6006
  run_button.clicked.connect(self.thresh)
5280
- layout.addRow(run_button)
6007
+ form_layout.addRow(run_button)
5281
6008
 
5282
- # Set a reasonable default size
5283
- self.setMinimumWidth(300)
6009
+ layout.addLayout(form_layout)
5284
6010
 
5285
- def run_preview(self):
6011
+ # Set a reasonable default size
6012
+ self.setMinimumWidth(400)
6013
+ self.setMinimumHeight(400)
5286
6014
 
5287
- def get_valid_float(text, default_value):
5288
- try:
5289
- return float(text) if text.strip() else default_value
5290
- except ValueError:
5291
- print(f"Invalid input: {text}")
5292
- return default_value
6015
+ def closeEvent(self, event):
6016
+ self.parent().preview = False
6017
+ self.parent().targs = None
6018
+ self.parent().bounds = False
5293
6019
 
6020
+ def get_values_in_range(self, lst, min_val, max_val):
6021
+ values = [x for x in lst if min_val <= x <= max_val]
6022
+ output = []
6023
+ for item in self.parent().volume_dict[self.parent().active_channel]:
6024
+ if self.parent().volume_dict[self.parent().active_channel][item] in values:
6025
+ output.append(item)
6026
+ return output
6027
+
6028
+
6029
+ def min_value_changed(self):
5294
6030
  try:
5295
- channel = self.parent().active_channel
5296
- accepted_mode = self.mode_selector.currentIndex()
6031
+ text = self.min.text()
6032
+ if not text: # If empty, ignore
6033
+ return
5297
6034
 
5298
- if accepted_mode == 0:
5299
- if len(np.unique(self.parent().channel_data[self.parent().active_channel])) < 3:
5300
- self.parent().show_label_dialog()
6035
+ try:
6036
+ value = float(text)
6037
+
6038
+ # Bound check against data limits
6039
+ value = max(self.data_min, value)
6040
+
6041
+ # Check against max line
6042
+ max_val = float(self.max.text()) if self.max.text() else self.data_max
6043
+ if value > max_val:
6044
+ # If min would exceed max, set max to its highest possible value
6045
+ self.max.setText(str(round(self.data_max, 2)))
6046
+ self.max_line.set_xdata([self.data_max, self.data_max])
6047
+ # And set min to the previous max value
6048
+ value = max_val
6049
+ self.min.setText(str(round(value, 2)))
6050
+
6051
+ if value == self.prev_min:
6052
+ return
6053
+ else:
6054
+ self.prev_min = value
6055
+ if self.bounds:
6056
+ self.targs = [self.prev_min, self.prev_max]
6057
+ else:
6058
+ self.targs = self.get_values_in_range(self.histo_list, self.prev_min, self.prev_max)
6059
+ self.parent().targs = self.targs
6060
+ if self.preview.isChecked():
6061
+ self.parent().highlight_overlay = None
6062
+ self.parent().create_highlight_overlay_slice(self.targs, bounds = self.bounds)
6063
+
6064
+ # Update the line
6065
+ self.min_line.set_xdata([value, value])
6066
+ self.canvas.draw()
6067
+
5301
6068
 
5302
- if self.parent().volume_dict[channel] is None:
5303
- self.parent().volumes()
5304
-
5305
- volumes = self.parent().volume_dict[channel]
5306
- default_max = max(volumes.values())
5307
- default_min = min(volumes.values())
5308
6069
 
5309
- max_val = get_valid_float(self.max.text(), default_max)
5310
- min_val = get_valid_float(self.min.text(), default_min)
6070
+ except ValueError:
6071
+ # If invalid number, reset to current line position
6072
+ self.min.setText(str(round(self.min_line.get_xself.data_mindata()[0], 2)))
6073
+ except:
6074
+ pass
6075
+
6076
+ def max_value_changed(self):
6077
+ try:
6078
+ text = self.max.text()
6079
+ if not text: # If empty, ignore
6080
+ return
5311
6081
 
5312
- valid_indices = [item for item in volumes
5313
- if min_val <= volumes[item] <= max_val]
5314
-
5315
- elif accepted_mode == 1:
5316
- channel_data = self.parent().channel_data[self.parent().active_channel]
5317
- default_max = np.max(channel_data)
5318
- default_min = np.min(channel_data)
6082
+ try:
6083
+ value = float(text)
5319
6084
 
5320
- max_val = int(get_valid_float(self.max.text(), default_max))
5321
- min_val = int(get_valid_float(self.min.text(), default_min))
6085
+ # Bound check against data limits
6086
+ value = min(self.data_max, value)
5322
6087
 
5323
- if min_val > max_val:
5324
- min_val, max_val = max_val, min_val
5325
-
5326
- valid_indices = list(range(min_val, max_val + 1))
6088
+ # Check against min line
6089
+ min_val = float(self.min.text()) if self.min.text() else self.data_min
6090
+ if value < min_val:
6091
+ # If max would go below min, set min to its lowest possible value
6092
+ self.min.setText(str(round(self.data_min, 2)))
6093
+ self.min_line.set_xdata([self.data_min, self.data_min])
6094
+ # And set max to the previous min value
6095
+ value = min_val
6096
+ self.max.setText(str(round(value, 2)))
6097
+
6098
+ if value == self.prev_max:
6099
+ return
6100
+ else:
6101
+ self.prev_max = value
6102
+ if self.bounds:
6103
+ self.targs = [self.prev_min, self.prev_max]
6104
+ else:
6105
+ self.targs = self.get_values_in_range(self.histo_list, self.prev_min, self.prev_max)
6106
+ self.parent().targs = self.targs
6107
+ if self.preview.isChecked():
6108
+ self.parent().highlight_overlay = None
6109
+ self.parent().create_highlight_overlay_slice(self.targs, bounds = self.bounds)
6110
+
6111
+ # Update the line
6112
+ self.max_line.set_xdata([value, value])
6113
+ self.canvas.draw()
6114
+
6115
+
6116
+
6117
+
6118
+
6119
+
6120
+ except ValueError:
6121
+ # If invalid number, reset to current line position
6122
+ self.max.setText(str(round(self.max_line.get_xdata()[0], 2)))
6123
+ except:
6124
+ pass
6125
+
6126
+ def on_press(self, event):
6127
+ try:
6128
+ if event.inaxes != self.ax:
6129
+ return
5327
6130
 
5328
- if channel == 0:
5329
- self.parent().create_highlight_overlay(node_indices = valid_indices)
5330
- elif channel == 1:
5331
- self.parent().create_highlight_overlay(edge_indices = valid_indices)
5332
- elif channel == 2:
5333
- self.parent().create_highlight_overlay(overlay1_indices = valid_indices)
5334
- elif channel == 3:
5335
- self.parent().create_highlight_overlay(overlay2_indices = valid_indices)
6131
+ # Left click controls left line
6132
+ if event.button == 1: # Left click
6133
+ self.dragging = 'min'
6134
+ # Right click controls right line
6135
+ elif event.button == 3: # Right click
6136
+ self.dragging = 'max'
6137
+ except:
6138
+ pass
6139
+
6140
+ def on_motion(self, event):
6141
+ try:
6142
+ if not self.dragging or event.inaxes != self.ax:
6143
+ return
6144
+
6145
+ if self.dragging == 'min':
6146
+ if event.xdata < self.max_line.get_xdata()[0]:
6147
+ self.min_line.set_xdata([event.xdata, event.xdata])
6148
+ self.min.setText(str(round(event.xdata, 2)))
6149
+ else:
6150
+ if event.xdata > self.min_line.get_xdata()[0]:
6151
+ self.max_line.set_xdata([event.xdata, event.xdata])
6152
+ self.max.setText(str(round(event.xdata, 2)))
6153
+
6154
+ self.canvas.draw()
6155
+ except:
6156
+ pass
6157
+
6158
+ def on_release(self, event):
6159
+ self.min_value_changed()
6160
+ self.max_value_changed()
6161
+ self.dragging = None
5336
6162
 
5337
- except Exception as e:
5338
- print(f"Error showing preview: {e}")
6163
+ def preview_mode(self):
6164
+ try:
6165
+ preview = self.preview.isChecked()
6166
+ self.parent().preview = preview
6167
+ self.parent().targs = self.targs
6168
+
6169
+ if preview and self.targs is not None:
6170
+ self.parent().create_highlight_overlay_slice(self.parent().targs, bounds = self.bounds)
6171
+ except:
6172
+ pass
5339
6173
 
5340
6174
  def thresh(self):
5341
6175
  try:
5342
6176
 
5343
- self.run_preview()
6177
+ if self.parent().active_channel == 0:
6178
+ self.parent().create_highlight_overlay(node_indices = self.targs, bounds = self.bounds)
6179
+ elif self.parent().active_channel == 1:
6180
+ self.parent().create_highlight_overlay(edge_indices = self.targs, bounds = self.bounds)
6181
+ elif self.parent().active_channel == 2:
6182
+ self.parent().create_highlight_overlay(overlay1_indices = self.targs, bounds = self.bounds)
6183
+ elif self.parent().active_channel == 3:
6184
+ self.parent().create_highlight_overlay(overlay2_indices = self.targs, bounds = self.bounds)
6185
+
5344
6186
  channel_data = self.parent().channel_data[self.parent().active_channel]
5345
6187
  mask = self.parent().highlight_overlay > 0
5346
6188
  channel_data = channel_data * mask
@@ -5349,6 +6191,7 @@ class ThresholdWindow(QMainWindow):
5349
6191
  self.close()
5350
6192
 
5351
6193
  except Exception as e:
6194
+
5352
6195
  QMessageBox.critical(
5353
6196
  self,
5354
6197
  "Error",
@@ -5775,9 +6618,17 @@ class WatershedDialog(QDialog):
5775
6618
  self.directory = QLineEdit()
5776
6619
  self.directory.setPlaceholderText("Leave empty for None")
5777
6620
  layout.addRow("Output Directory:", self.directory)
6621
+
6622
+ active_shape = self.parent().channel_data[self.parent().active_channel].shape[0]
6623
+
6624
+ if active_shape == 1:
6625
+ self.default = 0.2
6626
+ else:
6627
+ self.default = 0.05
6628
+
5778
6629
 
5779
6630
  # Proportion (default 0.1)
5780
- self.proportion = QLineEdit("0.05")
6631
+ self.proportion = QLineEdit(f"{self.default}")
5781
6632
  layout.addRow("Proportion:", self.proportion)
5782
6633
 
5783
6634
  # GPU checkbox (default True)
@@ -5813,9 +6664,9 @@ class WatershedDialog(QDialog):
5813
6664
 
5814
6665
  # Get proportion (0.1 if empty or invalid)
5815
6666
  try:
5816
- proportion = float(self.proportion.text()) if self.proportion.text() else 0.05
6667
+ proportion = float(self.proportion.text()) if self.proportion.text() else self.default
5817
6668
  except ValueError:
5818
- proportion = 0.05
6669
+ proportion = self.default
5819
6670
 
5820
6671
  # Get GPU state
5821
6672
  gpu = self.gpu.isChecked()
@@ -5842,7 +6693,8 @@ class WatershedDialog(QDialog):
5842
6693
  active_data = self.parent().channel_data[self.parent().active_channel]
5843
6694
  if active_data is None:
5844
6695
  raise ValueError("No active image selected")
5845
-
6696
+
6697
+
5846
6698
  # Call watershed method with parameters
5847
6699
  result = n3d.watershed(
5848
6700
  active_data,
@@ -5865,6 +6717,8 @@ class WatershedDialog(QDialog):
5865
6717
  self.accept()
5866
6718
 
5867
6719
  except Exception as e:
6720
+ import traceback
6721
+ print(traceback.format_exc())
5868
6722
  QMessageBox.critical(
5869
6723
  self,
5870
6724
  "Error",
@@ -6119,11 +6973,21 @@ class BranchDialog(QDialog):
6119
6973
  self.nodes.setChecked(True)
6120
6974
  layout.addRow("Generate nodes from edges? (Skip if already completed - presumes your edge skeleton from generate nodes is in Edges and that your original Edges are in Overlay 2):", self.nodes)
6121
6975
 
6122
- # GPU checkbox (default True)
6976
+ # GPU checkbox (default False)
6123
6977
  self.GPU = QPushButton("GPU")
6124
6978
  self.GPU.setCheckable(True)
6125
6979
  self.GPU.setChecked(False)
6126
- layout.addRow("Use GPU (Note this may need to temporarily downsample your large images which may simplify outputs - Only memory errors but not permission errors for accessing GRAM are handled by default - CPU will never try to downsample):", self.GPU)
6980
+ layout.addRow("Use GPU (Note this may need to temporarily downsample your large images which may simplify outputs - Only memory errors but not permission errors for accessing VRAM are handled by default - CPU will never try to downsample):", self.GPU)
6981
+
6982
+ # Branch Fix checkbox (default False)
6983
+ self.fix = QPushButton("Auto-Correct Branches")
6984
+ self.fix.setCheckable(True)
6985
+ self.fix.setChecked(False)
6986
+ layout.addRow("Attempt to auto-correct branch labels:", self.fix)
6987
+
6988
+ self.fix_val = QLineEdit()
6989
+ self.fix_val.setPlaceholderText("Empty = default value...")
6990
+ layout.addRow("If checked above - Avg Degree of Nearby Branch Communities to Merge (Attempt to fix branch labeling - try 4 to 6 to start or leave empty):", self.fix_val)
6127
6991
 
6128
6992
  self.down_factor = QLineEdit("0")
6129
6993
  layout.addRow("Internal downsample (will have to recompute nodes)?:", self.down_factor)
@@ -6151,6 +7015,9 @@ class BranchDialog(QDialog):
6151
7015
  nodes = self.nodes.isChecked()
6152
7016
  GPU = self.GPU.isChecked()
6153
7017
  cubic = self.cubic.isChecked()
7018
+ fix = self.fix.isChecked()
7019
+ fix_val = float(self.fix_val.text()) if self.fix_val.text() else None
7020
+
6154
7021
 
6155
7022
 
6156
7023
  original_shape = my_network.edges.shape
@@ -6158,7 +7025,7 @@ class BranchDialog(QDialog):
6158
7025
 
6159
7026
  if down_factor > 0:
6160
7027
  self.parent().show_gennodes_dialog(down_factor = [down_factor, cubic], called = True)
6161
- elif nodes:
7028
+ elif nodes or my_network.nodes is None:
6162
7029
  self.parent().show_gennodes_dialog(called = True)
6163
7030
  down_factor = None
6164
7031
 
@@ -6166,6 +7033,21 @@ class BranchDialog(QDialog):
6166
7033
 
6167
7034
  output = n3d.label_branches(my_network.edges, nodes = my_network.nodes, bonus_array = original_array, GPU = GPU, down_factor = down_factor, arrayshape = original_shape)
6168
7035
 
7036
+ if fix:
7037
+
7038
+ temp_network = n3d.Network_3D(nodes = output)
7039
+
7040
+ temp_network.morph_proximity(search = 1) #Detect network of nearby branches
7041
+
7042
+ temp_network.community_partition(weighted = False, style = 1, dostats = False) #Find communities with louvain, unweighted params
7043
+
7044
+ targs = n3d.fix_branches(temp_network.nodes, temp_network.network, temp_network.communities, fix_val)
7045
+
7046
+ temp_network.com_to_node(targs)
7047
+
7048
+ output = temp_network.nodes
7049
+
7050
+
6169
7051
  if down_factor is not None:
6170
7052
 
6171
7053
  self.parent().reset(nodes = True, id_overlay = True, edges = True)
@@ -6181,6 +7063,8 @@ class BranchDialog(QDialog):
6181
7063
 
6182
7064
  except Exception as e:
6183
7065
  print(f"Error labeling branches: {e}")
7066
+ import traceback
7067
+ print(traceback.format_exc())
6184
7068
 
6185
7069
 
6186
7070
 
@@ -6336,7 +7220,7 @@ class AlterDialog(QDialog):
6336
7220
  class ModifyDialog(QDialog):
6337
7221
  def __init__(self, parent=None):
6338
7222
  super().__init__(parent)
6339
- self.setWindowTitle("Create Nodes from Edge Vertices")
7223
+ self.setWindowTitle("Modify Network Qualities")
6340
7224
  self.setModal(True)
6341
7225
  layout = QFormLayout(self)
6342
7226
 
@@ -6376,6 +7260,12 @@ class ModifyDialog(QDialog):
6376
7260
  self.isolate.setChecked(False)
6377
7261
  layout.addRow("Isolate connections between two specific node types (if assigned)?:", self.isolate)
6378
7262
 
7263
+ # Community collapse checkbox (default False)
7264
+ self.comcollapse = QPushButton("Communities -> nodes")
7265
+ self.comcollapse.setCheckable(True)
7266
+ self.comcollapse.setChecked(False)
7267
+ layout.addRow("Convert communities to nodes?:", self.comcollapse)
7268
+
6379
7269
  #change button
6380
7270
  change_button = QPushButton("Add/Remove Network Pairs")
6381
7271
  change_button.clicked.connect(self.show_alter_dialog)
@@ -6409,6 +7299,8 @@ class ModifyDialog(QDialog):
6409
7299
  edgeweight = self.edgeweight.isChecked()
6410
7300
  prune = self.prune.isChecked()
6411
7301
  isolate = self.isolate.isChecked()
7302
+ comcollapse = self.comcollapse.isChecked()
7303
+
6412
7304
 
6413
7305
  if isolate and my_network.node_identities is not None:
6414
7306
  self.show_isolate_dialog()
@@ -6434,6 +7326,15 @@ class ModifyDialog(QDialog):
6434
7326
  self.parent().format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
6435
7327
  except:
6436
7328
  pass
7329
+ if comcollapse:
7330
+ if my_network.communities is None:
7331
+ self.parent().show_partition_dialog()
7332
+ if my_network.communities is None:
7333
+ return
7334
+ my_network.com_to_node()
7335
+ self.parent().load_channel(0, my_network.nodes, True)
7336
+ my_network.communities = None
7337
+
6437
7338
  try:
6438
7339
  if hasattr(my_network, 'network_lists'):
6439
7340
  model = PandasModel(my_network.network_lists)
@@ -6552,8 +7453,7 @@ class CentroidDialog(QDialog):
6552
7453
  "Error",
6553
7454
  f"Error finding centroids: {str(e)}"
6554
7455
  )
6555
- import traceback
6556
- print(traceback.format_exc())
7456
+
6557
7457
 
6558
7458
 
6559
7459