nettracer3d 0.9.0__py3-none-any.whl → 0.9.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

@@ -5,7 +5,7 @@ from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QG
5
5
  QFormLayout, QLineEdit, QPushButton, QFileDialog,
6
6
  QLabel, QComboBox, QMessageBox, QTableView, QInputDialog,
7
7
  QMenu, QTabWidget, QGroupBox)
8
- from PyQt6.QtCore import (QPoint, Qt, QAbstractTableModel, QTimer, QThread, pyqtSignal, QObject, QCoreApplication)
8
+ from PyQt6.QtCore import (QPoint, Qt, QAbstractTableModel, QTimer, QThread, pyqtSignal, QObject, QCoreApplication, QEvent)
9
9
  import numpy as np
10
10
  import time
11
11
  from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
@@ -18,7 +18,7 @@ from nettracer3d import smart_dilate as sdl
18
18
  from matplotlib.colors import LinearSegmentedColormap
19
19
  from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
20
20
  import pandas as pd
21
- from PyQt6.QtGui import (QFont, QCursor, QColor, QPixmap, QFontMetrics, QPainter, QPen)
21
+ from PyQt6.QtGui import (QFont, QCursor, QColor, QPixmap, QFontMetrics, QPainter, QPen, QShortcut, QKeySequence)
22
22
  import tifffile
23
23
  import copy
24
24
  import multiprocessing as mp
@@ -137,7 +137,6 @@ class ImageViewerWindow(QMainWindow):
137
137
  self.img_height = None
138
138
  self.pre_pan_channel_state = None # Store which channels were visible before pan
139
139
  self.is_pan_preview = False # Track if we're in pan preview mode
140
- self.pre_pan_channel_state = None # Store which channels were visible before pan
141
140
  self.pan_background_image = None # Store the rendered composite image
142
141
  self.pan_zoom_state = None # Store zoom state when pan began
143
142
  self.is_pan_preview = False # Track if we're in pan preview mode
@@ -309,18 +308,19 @@ class ImageViewerWindow(QMainWindow):
309
308
 
310
309
  # Create left panel for image and controls
311
310
  left_panel = QWidget()
312
- left_layout = QVBoxLayout(left_panel)
311
+ self.left_layout = QVBoxLayout(left_panel)
312
+
313
313
 
314
314
  # Create matplotlib canvas for image display
315
315
  self.figure = Figure(figsize=(8, 8))
316
316
  self.canvas = FigureCanvas(self.figure)
317
317
  self.ax = self.figure.add_subplot(111)
318
- left_layout.addWidget(self.canvas)
318
+ self.left_layout.addWidget(self.canvas)
319
319
 
320
320
  self.canvas.mpl_connect('scroll_event', self.on_mpl_scroll)
321
321
 
322
322
 
323
- left_layout.addWidget(control_panel)
323
+ self.left_layout.addWidget(control_panel)
324
324
 
325
325
  # Add timer for debouncing slice updates
326
326
  self._slice_update_timer = QTimer()
@@ -358,7 +358,7 @@ class ImageViewerWindow(QMainWindow):
358
358
  self.continuous_scroll_timer.timeout.connect(self.continuous_scroll)
359
359
  self.scroll_direction = 0 # 0: none, -1: left, 1: right
360
360
 
361
- left_layout.addWidget(slider_container)
361
+ self.left_layout.addWidget(slider_container)
362
362
 
363
363
 
364
364
  main_layout.addWidget(left_panel)
@@ -466,6 +466,299 @@ class ImageViewerWindow(QMainWindow):
466
466
 
467
467
  self.resume = False
468
468
 
469
+ self.hold_update = False
470
+ self._first_pan_done = False
471
+
472
+ def popup_canvas(self):
473
+ """Pop the canvas out into its own window"""
474
+ if hasattr(self, 'popup_window') and self.popup_window.isVisible():
475
+ # If popup already exists, just bring it to front
476
+ self.popup_window.raise_()
477
+ self.popup_window.activateWindow()
478
+ if hasattr(self, 'control_popup_window'):
479
+ self.control_popup_window.raise_()
480
+ self.control_popup_window.activateWindow()
481
+ # Also bring machine window to front if it exists
482
+ if self.machine_window is not None:
483
+ self.machine_window.raise_()
484
+ self.machine_window.activateWindow()
485
+ return
486
+
487
+ self.is_popped = True
488
+
489
+ # Store original figure size and canvas size policy before popping out
490
+ self.original_figure_size = self.figure.get_size_inches()
491
+ self.original_canvas_size_policy = self.canvas.sizePolicy()
492
+
493
+ # Create popup window for canvas
494
+ self.popup_window = QMainWindow()
495
+ self.popup_window.setWindowTitle("NetTracer3D - Canvas View")
496
+ self.popup_window.setGeometry(200, 200, 1000, 800) # Bigger size
497
+
498
+ # Install event filters for both window management and keyboard shortcuts
499
+ self.popup_window.installEventFilter(self)
500
+
501
+ # Create popup window for control panel and slider
502
+ self.control_popup_window = QMainWindow()
503
+ self.control_popup_window.setWindowTitle("NetTracer3D - Controls")
504
+ self.control_popup_window.setGeometry(1220, 200, 400, 200) # Bigger height for slider
505
+
506
+ # Install event filter on control window too
507
+ self.control_popup_window.installEventFilter(self)
508
+
509
+ # Make control window non-closeable while popped out
510
+ self.control_popup_window.setWindowFlags(
511
+ self.control_popup_window.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint
512
+ )
513
+
514
+ # Set control panel as child of canvas popup for natural stacking
515
+ self.control_popup_window.setParent(self.popup_window, Qt.WindowType.Window)
516
+
517
+ # Remove canvas from left panel
518
+ self.canvas.setParent(None)
519
+
520
+ # Remove control panel from left panel
521
+ # First find the control_panel widget
522
+ control_panel = None
523
+ for i in range(self.left_layout.count()):
524
+ widget = self.left_layout.itemAt(i).widget()
525
+ if widget and hasattr(widget, 'findChild') and widget.findChild(QComboBox):
526
+ control_panel = widget
527
+ break
528
+
529
+ # Remove slider container from left panel
530
+ slider_container = None
531
+ for i in range(self.left_layout.count()):
532
+ widget = self.left_layout.itemAt(i).widget()
533
+ if widget and hasattr(widget, 'findChild') and widget.findChild(QSlider):
534
+ slider_container = widget
535
+ break
536
+
537
+ if control_panel:
538
+ control_panel.setParent(None)
539
+ self.popped_control_panel = control_panel
540
+
541
+ if slider_container:
542
+ slider_container.setParent(None)
543
+ self.popped_slider_container = slider_container
544
+
545
+ # Move the actual canvas to popup window
546
+ self.popup_window.setCentralWidget(self.canvas)
547
+
548
+ # Create a container widget for the control popup to hold both control panel and slider
549
+ control_container = QWidget()
550
+ control_container_layout = QVBoxLayout(control_container)
551
+
552
+ # Add control panel to container
553
+ if control_panel:
554
+ control_container_layout.addWidget(control_panel)
555
+
556
+ # Add slider container to container
557
+ if slider_container:
558
+ control_container_layout.addWidget(slider_container)
559
+
560
+ # Set the container as the central widget
561
+ self.control_popup_window.setCentralWidget(control_container)
562
+
563
+ # Create placeholder for left panel
564
+ placeholder = QLabel("Canvas and controls are popped out\nClick to return both")
565
+ placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
566
+ placeholder.setStyleSheet("""
567
+ QLabel {
568
+ background-color: #f0f0f0;
569
+ border: 2px dashed #ccc;
570
+ font-size: 14px;
571
+ color: #666;
572
+ }
573
+ """)
574
+ placeholder.mousePressEvent = lambda event: self.return_canvas()
575
+
576
+ # Add placeholder to left layout
577
+ self.left_layout.insertWidget(0, placeholder) # Insert at canvas position
578
+ self.canvas_placeholder = placeholder
579
+
580
+ # Create keyboard shortcuts for popup windows
581
+ self.create_popup_shortcuts()
582
+
583
+ # Show both popup windows
584
+ self.popup_window.show()
585
+ self.control_popup_window.show()
586
+
587
+ # Ensure proper initial window order
588
+ self.ensure_window_order()
589
+
590
+ # Connect close event to return canvas (only canvas window can be closed)
591
+ self.popup_window.closeEvent = self.on_popup_close
592
+
593
+ def eventFilter(self, obj, event):
594
+ """Filter events to manage window stacking and keyboard shortcuts"""
595
+ # Handle keyboard events for popup windows
596
+ if (obj == self.popup_window or obj == self.control_popup_window) and event.type() == QEvent.Type.KeyPress:
597
+ # Forward key events to main window's keyPressEvent method
598
+ self.keyPressEvent(event)
599
+ return True # Event handled
600
+
601
+ # Handle scroll events for popup canvas
602
+ if obj == self.popup_window and event.type() == QEvent.Type.Wheel:
603
+ # Forward wheel events to the scroll handler
604
+ canvas_widget = self.popup_window.centralWidget()
605
+ if canvas_widget == self.canvas:
606
+ # Create a mock matplotlib event from the Qt wheel event
607
+ self.handle_popup_scroll(event)
608
+ return True
609
+
610
+ # Existing window stacking code
611
+ if obj == self.popup_window:
612
+ if event.type() == QEvent.Type.WindowActivate:
613
+ # Canvas popup was activated, raise our preferred windows
614
+ QTimer.singleShot(0, self.ensure_window_order)
615
+ elif event.type() == QEvent.Type.FocusIn:
616
+ # Canvas got focus, raise controls
617
+ QTimer.singleShot(0, self.ensure_window_order)
618
+
619
+ return super().eventFilter(obj, event)
620
+
621
+ def create_popup_shortcuts(self):
622
+ """Create keyboard shortcuts for popup windows"""
623
+ if not hasattr(self, 'popup_shortcuts'):
624
+ self.popup_shortcuts = []
625
+
626
+ # Define shortcuts - using your existing keyPressEvent logic
627
+ shortcuts_config = [
628
+ ('Z', lambda: self.zoom_button.click()),
629
+ ('Ctrl+Z', self.handle_undo),
630
+ ('X', lambda: self.high_button.click()),
631
+ ('Shift+F', self.handle_find),
632
+ ('Ctrl+S', self.handle_resave),
633
+ ('Ctrl+L', lambda: self.load_from_network_obj(directory=self.last_load)),
634
+ ('F', lambda: self.toggle_can() if self.brush_mode and self.machine_window is None else None),
635
+ ('D', lambda: self.toggle_threed() if self.brush_mode and self.machine_window is None else None),
636
+ ('A', lambda: self.machine_window.switch_foreground() if self.machine_window is not None else None),
637
+ ]
638
+
639
+ # Create shortcuts for both popup windows
640
+ for key_seq, func in shortcuts_config:
641
+ # Canvas popup window shortcuts
642
+ shortcut1 = QShortcut(QKeySequence(key_seq), self.popup_window)
643
+ shortcut1.activated.connect(func)
644
+ self.popup_shortcuts.append(shortcut1)
645
+
646
+ # Control popup window shortcuts
647
+ shortcut2 = QShortcut(QKeySequence(key_seq), self.control_popup_window)
648
+ shortcut2.activated.connect(func)
649
+ self.popup_shortcuts.append(shortcut2)
650
+
651
+ def handle_undo(self):
652
+ """Handle undo shortcut"""
653
+ try:
654
+ self.load_channel(self.last_change[1], self.last_change[0], True)
655
+ except:
656
+ pass
657
+
658
+ def handle_popup_scroll(self, qt_event):
659
+ """Handle scroll events in popup window"""
660
+ # Get the matplotlib canvas from the popup window
661
+ canvas = self.popup_window.centralWidget()
662
+ if canvas == self.canvas:
663
+ # Create a mock matplotlib event to pass to your existing scroll handler
664
+ # You might need to adapt this based on how your on_mpl_scroll method works
665
+ # For now, we'll try to call it directly with the Qt event
666
+ try:
667
+ # If your scroll handler needs a matplotlib event, you may need to
668
+ # create a mock event or adapt the handler
669
+ pass # You'll need to implement the scroll forwarding here
670
+ except:
671
+ pass
672
+
673
+ def ensure_window_order(self):
674
+ """Ensure control panel and machine window stay above canvas"""
675
+ if hasattr(self, 'control_popup_window') and self.control_popup_window.isVisible():
676
+ self.control_popup_window.raise_()
677
+
678
+ if self.machine_window is not None and self.machine_window.isVisible():
679
+ self.machine_window.raise_()
680
+
681
+ def return_canvas(self):
682
+ """Return canvas and control panel to main window"""
683
+ if hasattr(self, 'popup_window'):
684
+ # Clean up popup shortcuts
685
+ if hasattr(self, 'popup_shortcuts'):
686
+ for shortcut in self.popup_shortcuts:
687
+ shortcut.deleteLater()
688
+ del self.popup_shortcuts
689
+
690
+ # Remove event filters when returning
691
+ self.popup_window.removeEventFilter(self)
692
+ if hasattr(self, 'control_popup_window'):
693
+ self.control_popup_window.removeEventFilter(self)
694
+
695
+ # Remove canvas from popup
696
+ self.canvas.setParent(None)
697
+ self.is_popped = False
698
+
699
+ # Remove control panel from popup
700
+ if hasattr(self, 'popped_control_panel') and hasattr(self, 'control_popup_window'):
701
+ self.popped_control_panel.setParent(None)
702
+
703
+ # Remove slider container from popup
704
+ if hasattr(self, 'popped_slider_container') and hasattr(self, 'control_popup_window'):
705
+ self.popped_slider_container.setParent(None)
706
+
707
+ # Remove placeholder
708
+ if hasattr(self, 'canvas_placeholder'):
709
+ self.canvas_placeholder.setParent(None)
710
+ del self.canvas_placeholder
711
+
712
+ # Reset canvas to original size and size policy
713
+ if hasattr(self, 'original_figure_size'):
714
+ self.figure.set_size_inches(self.original_figure_size)
715
+ if hasattr(self, 'original_canvas_size_policy'):
716
+ self.canvas.setSizePolicy(self.original_canvas_size_policy)
717
+
718
+ # Reset canvas minimum and maximum sizes to allow proper resizing
719
+ self.canvas.setMinimumSize(0, 0) # Remove any minimum size constraints
720
+ self.canvas.setMaximumSize(16777215, 16777215) # Reset to Qt's default maximum
721
+
722
+ # Return canvas to left panel
723
+ self.left_layout.insertWidget(0, self.canvas) # Insert at top
724
+
725
+ # Return control panel to left panel (after canvas)
726
+ if hasattr(self, 'popped_control_panel'):
727
+ self.left_layout.insertWidget(1, self.popped_control_panel) # Insert after canvas
728
+ del self.popped_control_panel
729
+
730
+ # Return slider container to left panel (after control panel)
731
+ if hasattr(self, 'popped_slider_container'):
732
+ self.left_layout.insertWidget(2, self.popped_slider_container) # Insert after control panel
733
+ del self.popped_slider_container
734
+
735
+ # Force the canvas to redraw with proper sizing
736
+ self.canvas.draw()
737
+
738
+ # Reset the main window layout to ensure proper proportions
739
+ # Get the main widget and force layout recalculation
740
+ main_widget = self.centralWidget()
741
+ if main_widget:
742
+ main_widget.updateGeometry()
743
+ main_widget.update()
744
+
745
+ # Close both popup windows
746
+ self.popup_window.close()
747
+ if hasattr(self, 'control_popup_window'):
748
+ self.control_popup_window.close()
749
+ del self.control_popup_window
750
+
751
+ # Clean up stored size references
752
+ if hasattr(self, 'original_figure_size'):
753
+ del self.original_figure_size
754
+ if hasattr(self, 'original_canvas_size_policy'):
755
+ del self.original_canvas_size_policy
756
+
757
+ def on_popup_close(self, event):
758
+ """Return canvas when popup is closed"""
759
+ self.return_canvas()
760
+ event.accept()
761
+
469
762
  def start_left_scroll(self):
470
763
  """Start scrolling left when left arrow is pressed."""
471
764
  # Single increment first
@@ -500,7 +793,6 @@ class ImageViewerWindow(QMainWindow):
500
793
  self.slice_slider.setValue(new_value)
501
794
  elif self.scroll_direction > 0 and new_value <= self.slice_slider.maximum():
502
795
  self.slice_slider.setValue(new_value)
503
-
504
796
 
505
797
  def create_highlight_overlay(self, node_indices=None, edge_indices=None, overlay1_indices = None, overlay2_indices = None, bounds = False):
506
798
  """
@@ -514,6 +806,17 @@ class ImageViewerWindow(QMainWindow):
514
806
  self.mini_overlay = False #If this method is ever being called, it means we are rendering the entire overlay so mini overlay needs to reset.
515
807
  self.mini_overlay_data = None
516
808
 
809
+
810
+ if not self.high_button.isChecked():
811
+
812
+ if len(self.clicked_values['edges']) > 0:
813
+ self.format_for_upperright_table(self.clicked_values['edges'], title = 'Selected Edges')
814
+ if len(self.clicked_values['nodes']) > 0:
815
+ self.format_for_upperright_table(self.clicked_values['nodes'], title = 'Selected Nodes')
816
+
817
+ return
818
+
819
+
517
820
  def process_chunk(chunk_data, indices_to_check):
518
821
  """Process a single chunk of the array to create highlight mask"""
519
822
  mask = np.isin(chunk_data, indices_to_check)
@@ -550,7 +853,7 @@ class ImageViewerWindow(QMainWindow):
550
853
  current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
551
854
  current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
552
855
 
553
- if not node_indices and not edge_indices and not overlay1_indices and not overlay2_indices:
856
+ if not node_indices and not edge_indices and not overlay1_indices and not overlay2_indices and self.machine_window is None:
554
857
  self.highlight_overlay = None
555
858
  self.highlight_bounds = None
556
859
  self.update_display(preserve_zoom=(current_xlim, current_ylim))
@@ -623,7 +926,7 @@ class ImageViewerWindow(QMainWindow):
623
926
  self.highlight_overlay = np.maximum(self.highlight_overlay, overlay1_overlay)
624
927
  if overlay2_overlay is not None:
625
928
  self.highlight_overlay = np.maximum(self.highlight_overlay, overlay2_overlay)
626
-
929
+
627
930
  # Update display
628
931
  self.update_display(preserve_zoom=(current_xlim, current_ylim))
629
932
 
@@ -822,9 +1125,6 @@ class ImageViewerWindow(QMainWindow):
822
1125
 
823
1126
 
824
1127
 
825
-
826
-
827
-
828
1128
 
829
1129
  #METHODS RELATED TO RIGHT CLICK:
830
1130
 
@@ -1991,7 +2291,10 @@ class ImageViewerWindow(QMainWindow):
1991
2291
  del my_network.network_lists[0][i]
1992
2292
  del my_network.network_lists[1][i]
1993
2293
  del my_network.network_lists[2][i]
1994
-
2294
+ for node in self.clicked_values['nodes']:
2295
+ del my_network.node_centroids[node]
2296
+ del my_network.node_identities[node]
2297
+ del my_network.communities[node]
1995
2298
 
1996
2299
 
1997
2300
  if len(self.clicked_values['edges']) > 0:
@@ -2006,6 +2309,8 @@ class ImageViewerWindow(QMainWindow):
2006
2309
  del my_network.network_lists[0][i]
2007
2310
  del my_network.network_lists[1][i]
2008
2311
  del my_network.network_lists[2][i]
2312
+ for node in self.clicked_values['edges']:
2313
+ del my_network.edge_centroids[edge]
2009
2314
 
2010
2315
  my_network.network_lists = my_network.network_lists
2011
2316
 
@@ -2021,7 +2326,7 @@ class ImageViewerWindow(QMainWindow):
2021
2326
  for column in range(model.columnCount(None)):
2022
2327
  self.network_table.resizeColumnToContents(column)
2023
2328
 
2024
- self.show_centroid_dialog()
2329
+ #self.show_centroid_dialog()
2025
2330
  except Exception as e:
2026
2331
  print(f"Error: {e}")
2027
2332
 
@@ -2147,11 +2452,16 @@ class ImageViewerWindow(QMainWindow):
2147
2452
  current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2148
2453
  current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2149
2454
 
2150
- if self.high_button.isChecked():
2455
+ if self.high_button.isChecked() and self.machine_window is None:
2151
2456
  if self.highlight_overlay is None and ((len(self.clicked_values['nodes']) + len(self.clicked_values['edges'])) > 0):
2152
2457
  if self.needs_mini:
2153
2458
  self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
2154
2459
  self.needs_mini = False
2460
+ else:
2461
+ self.create_highlight_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
2462
+ else:
2463
+ self.create_highlight_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
2464
+
2155
2465
 
2156
2466
  self.update_display(preserve_zoom=(current_xlim, current_ylim))
2157
2467
 
@@ -2164,7 +2474,6 @@ class ImageViewerWindow(QMainWindow):
2164
2474
  self.pan_button.setChecked(False)
2165
2475
 
2166
2476
  self.pen_button.setChecked(False)
2167
- self.pan_mode = False
2168
2477
  self.brush_mode = False
2169
2478
  self.can = False
2170
2479
  self.threed = False
@@ -2187,6 +2496,7 @@ class ImageViewerWindow(QMainWindow):
2187
2496
  current_xlim = self.ax.get_xlim()
2188
2497
  current_ylim = self.ax.get_ylim()
2189
2498
  self.update_display(preserve_zoom=(current_xlim, current_ylim))
2499
+ self.pan_mode = False
2190
2500
 
2191
2501
  else:
2192
2502
  if self.machine_window is None:
@@ -2234,15 +2544,31 @@ class ImageViewerWindow(QMainWindow):
2234
2544
  # Store current channel visibility state
2235
2545
  self.pre_pan_channel_state = self.channel_visible.copy()
2236
2546
 
2237
- # Create static background from currently visible channels
2238
- self.create_pan_background()
2239
-
2240
- # Hide all channels and show only the background
2241
- self.channel_visible = [False] * 4
2242
- self.is_pan_preview = True
2243
-
2244
- # Update display to show only background
2245
- self.update_display_pan_mode()
2547
+ current_xlim = self.ax.get_xlim()
2548
+ current_ylim = self.ax.get_ylim()
2549
+
2550
+ if (abs(current_xlim[1] - current_xlim[0]) * abs(current_ylim[0] - current_ylim[1]) > 400 * 400 and not self.shape[2] * self.shape[1] > 9000 * 9000 * 6) or self.shape[2] * self.shape[1] < 3000 * 3000:
2551
+
2552
+ # Create static background from currently visible channels
2553
+ self.create_pan_background()
2554
+
2555
+ # Hide all channels and show only the background
2556
+ self.channel_visible = [False] * 4
2557
+ self.is_pan_preview = True
2558
+
2559
+ # Get current downsample factor
2560
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2561
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2562
+ # Update display to show only background
2563
+ self.update_display_pan_mode(current_xlim, current_ylim)
2564
+ self.needs_update = False
2565
+ else:
2566
+ self.needs_update = True
2567
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2568
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2569
+ # Update display to show only background
2570
+ self._first_pan_done = False
2571
+ self.update_display(current_xlim, current_ylim)
2246
2572
 
2247
2573
  else:
2248
2574
  current_xlim = self.ax.get_xlim()
@@ -2714,7 +3040,6 @@ class ImageViewerWindow(QMainWindow):
2714
3040
  self.canvas.blit(self.ax.bbox)
2715
3041
 
2716
3042
  elif self.panning and self.pan_start is not None:
2717
-
2718
3043
  # Calculate the movement
2719
3044
  dx = event.xdata - self.pan_start[0]
2720
3045
  dy = event.ydata - self.pan_start[1]
@@ -2728,22 +3053,30 @@ class ImageViewerWindow(QMainWindow):
2728
3053
  new_ylim = [ylim[0] - dy, ylim[1] - dy]
2729
3054
 
2730
3055
  # Get image bounds using cached dimensions
2731
- if self.img_width is not None: # Changed from self.channel_data[0] check
3056
+ if self.img_width is not None:
2732
3057
  # Ensure new limits don't go beyond image bounds
2733
3058
  if new_xlim[0] < 0:
2734
3059
  new_xlim = [0, xlim[1] - xlim[0]]
2735
- elif new_xlim[1] > self.img_width: # Changed from img_width variable lookup
3060
+ elif new_xlim[1] > self.img_width:
2736
3061
  new_xlim = [self.img_width - (xlim[1] - xlim[0]), self.img_width]
2737
3062
 
2738
3063
  if new_ylim[0] < 0:
2739
3064
  new_ylim = [0, ylim[1] - ylim[0]]
2740
- elif new_ylim[1] > self.img_height: # Changed from img_height variable lookup
3065
+ elif new_ylim[1] > self.img_height:
2741
3066
  new_ylim = [self.img_height - (ylim[1] - ylim[0]), self.img_height]
2742
3067
 
2743
3068
  # Apply new limits
2744
3069
  self.ax.set_xlim(new_xlim)
2745
3070
  self.ax.set_ylim(new_ylim)
2746
- self.canvas.draw_idle() # Changed from draw() to draw_idle()
3071
+
3072
+ # Only call draw_idle if we have a pan background OR if this isn't the first pan
3073
+ if self.pan_background_image is not None or self._first_pan_done == True:
3074
+ self.canvas.draw_idle()
3075
+ else:
3076
+ # For the first pan without background, mark that we've done the first pan
3077
+ self._first_pan_done = True
3078
+ # Force a proper display update instead of draw_idle
3079
+ self.update_display(preserve_zoom=(new_xlim, new_ylim))
2747
3080
 
2748
3081
  # Update pan start position
2749
3082
  self.pan_start = (event.xdata, event.ydata)
@@ -2781,8 +3114,9 @@ class ImageViewerWindow(QMainWindow):
2781
3114
  self.pan_background_image = self.create_composite_for_pan()
2782
3115
  self.pan_zoom_state = (current_xlim, current_ylim)
2783
3116
 
3117
+
2784
3118
  def create_composite_for_pan(self):
2785
- """Create a properly rendered composite image for panning"""
3119
+ """Create a properly rendered composite image for panning with downsample support"""
2786
3120
  # Get active channels and dimensions (copied from update_display)
2787
3121
  active_channels = [i for i in range(4) if self.channel_data[i] is not None]
2788
3122
  if active_channels:
@@ -2793,8 +3127,58 @@ class ImageViewerWindow(QMainWindow):
2793
3127
  else:
2794
3128
  return None
2795
3129
 
2796
- # Create a blank RGBA composite to accumulate all channels
2797
- composite = np.zeros((min_height, min_width, 4), dtype=np.float32)
3130
+ # Store original dimensions for coordinate mapping
3131
+ self.original_dims = (min_height, min_width)
3132
+
3133
+ # Get current downsample factor
3134
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3135
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3136
+
3137
+
3138
+ # Calculate the visible region in pixel coordinates
3139
+ x_min = max(0, int(np.floor(current_xlim[0] + 0.5)))
3140
+ x_max = min(min_width, int(np.ceil(current_xlim[1] + 0.5)))
3141
+ y_min = max(0, int(np.floor(current_ylim[1] + 0.5))) # Note: y is flipped
3142
+ y_max = min(min_height, int(np.ceil(current_ylim[0] + 0.5)))
3143
+
3144
+ box_len = x_max - x_min
3145
+ box_height = y_max - y_min
3146
+ x_min = max(0, x_min - box_len)
3147
+ x_max = min(self.shape[2], x_max + box_len)
3148
+ y_min = max(0, y_min - box_height)
3149
+ y_max = min(self.shape[1], y_max + box_height)
3150
+
3151
+ # If using image pyramids
3152
+ size = (x_max - x_min) * (y_max - y_min)
3153
+ val = int(np.ceil(size/(3000 * 3000)))
3154
+ if self.shape[1] * self.shape[2] > 3000 * 3000 * val:
3155
+ val = 3
3156
+
3157
+ self.validate_downsample_input(text = val, update = False)
3158
+
3159
+ downsample_factor = self.downsample_factor
3160
+
3161
+ # Calculate display dimensions (downsampled)
3162
+ display_height = min_height // downsample_factor
3163
+ display_width = min_width // downsample_factor
3164
+
3165
+ # Helper function to downsample image (same as in update_display)
3166
+ def downsample_image(image, factor):
3167
+ if factor == 1:
3168
+ return image
3169
+
3170
+ # Handle different image types
3171
+ if len(image.shape) == 2:
3172
+ # Grayscale
3173
+ return image[::factor, ::factor]
3174
+ elif len(image.shape) == 3:
3175
+ # RGB/RGBA
3176
+ return image[::factor, ::factor, :]
3177
+ else:
3178
+ return image
3179
+
3180
+ # Create a blank RGBA composite to accumulate all channels (using display dimensions)
3181
+ composite = np.zeros((display_height, display_width, 4), dtype=np.float32)
2798
3182
 
2799
3183
  # Process each visible channel exactly like update_display does
2800
3184
  for channel in range(4):
@@ -2811,24 +3195,27 @@ class ImageViewerWindow(QMainWindow):
2811
3195
  else:
2812
3196
  current_image = self.channel_data[channel]
2813
3197
 
3198
+ # Downsample the image for rendering
3199
+ display_image = downsample_image(current_image, downsample_factor)
3200
+
2814
3201
  if is_rgb and self.channel_data[channel].shape[-1] == 3:
2815
3202
  # RGB image - convert to RGBA and blend
2816
- rgb_alpha = np.ones((*current_image.shape[:2], 4), dtype=np.float32)
2817
- rgb_alpha[:, :, :3] = current_image.astype(np.float32) / 255.0
3203
+ rgb_alpha = np.ones((*display_image.shape[:2], 4), dtype=np.float32)
3204
+ rgb_alpha[:, :, :3] = display_image.astype(np.float32) / 255.0
2818
3205
  rgb_alpha[:, :, 3] = 0.7 # Same alpha as update_display
2819
3206
  composite = self.blend_layers(composite, rgb_alpha)
2820
3207
 
2821
3208
  elif is_rgb and self.channel_data[channel].shape[-1] == 4:
2822
3209
  # RGBA image - blend directly
2823
- rgba_image = current_image.astype(np.float32) / 255.0
3210
+ rgba_image = display_image.astype(np.float32) / 255.0
2824
3211
  composite = self.blend_layers(composite, rgba_image)
2825
3212
 
2826
3213
  else:
2827
3214
  # Regular channel processing (same logic as update_display)
2828
3215
  if self.min_max[channel][0] == None:
2829
- self.min_max[channel][0] = np.min(current_image)
3216
+ self.min_max[channel][0] = np.min(self.channel_data[channel])
2830
3217
  if self.min_max[channel][1] == None:
2831
- self.min_max[channel][1] = np.max(current_image)
3218
+ self.min_max[channel][1] = np.max(self.channel_data[channel])
2832
3219
 
2833
3220
  img_min = self.min_max[channel][0]
2834
3221
  img_max = self.min_max[channel][1]
@@ -2840,16 +3227,16 @@ class ImageViewerWindow(QMainWindow):
2840
3227
  vmin = img_min + (img_max - img_min) * self.channel_brightness[channel]['min']
2841
3228
  vmax = img_min + (img_max - img_min) * self.channel_brightness[channel]['max']
2842
3229
 
2843
- # Normalize the image
3230
+ # Normalize the downsampled image
2844
3231
  if vmin == vmax:
2845
- normalized_image = np.zeros_like(current_image)
3232
+ normalized_image = np.zeros_like(display_image)
2846
3233
  else:
2847
- normalized_image = np.clip((current_image - vmin) / (vmax - vmin), 0, 1)
3234
+ normalized_image = np.clip((display_image - vmin) / (vmax - vmin), 0, 1)
2848
3235
 
2849
3236
  # Apply channel color and alpha
2850
3237
  if channel == 2 and self.machine_window is not None:
2851
3238
  # Special case for machine window channel 2
2852
- channel_rgba = self.apply_machine_colormap(current_image)
3239
+ channel_rgba = self.apply_machine_colormap(display_image)
2853
3240
  else:
2854
3241
  # Regular channel with custom color
2855
3242
  color = self.base_colors[channel]
@@ -2862,21 +3249,24 @@ class ImageViewerWindow(QMainWindow):
2862
3249
  # Blend this channel into the composite
2863
3250
  composite = self.blend_layers(composite, channel_rgba)
2864
3251
 
2865
- # Add highlight overlays if they exist (same logic as update_display)
3252
+ # Add highlight overlays if they exist (with downsampling)
2866
3253
  if self.mini_overlay and self.highlight and self.machine_window is None:
2867
- highlight_rgba = self.create_highlight_rgba(self.mini_overlay_data, yellow=True)
3254
+ display_overlay = downsample_image(self.mini_overlay_data, downsample_factor)
3255
+ highlight_rgba = self.create_highlight_rgba(display_overlay, yellow=True)
2868
3256
  composite = self.blend_layers(composite, highlight_rgba)
2869
3257
  elif self.highlight_overlay is not None and self.highlight:
2870
3258
  highlight_slice = self.highlight_overlay[self.current_slice]
3259
+ display_highlight = downsample_image(highlight_slice, downsample_factor)
2871
3260
  if self.machine_window is None:
2872
- highlight_rgba = self.create_highlight_rgba(highlight_slice, yellow=True)
3261
+ highlight_rgba = self.create_highlight_rgba(display_highlight, yellow=True)
2873
3262
  else:
2874
- highlight_rgba = self.create_highlight_rgba(highlight_slice, yellow=False)
3263
+ highlight_rgba = self.create_highlight_rgba(display_highlight, yellow=False)
2875
3264
  composite = self.blend_layers(composite, highlight_rgba)
2876
3265
 
2877
3266
  # Convert to 0-255 range for display
2878
3267
  return (composite * 255).astype(np.uint8)
2879
3268
 
3269
+
2880
3270
  def apply_machine_colormap(self, image):
2881
3271
  """Apply the special machine window colormap for channel 2"""
2882
3272
  rgba = np.zeros((*image.shape, 4), dtype=np.float32)
@@ -2902,7 +3292,7 @@ class ImageViewerWindow(QMainWindow):
2902
3292
  if yellow:
2903
3293
  # Yellow highlight
2904
3294
  mask = highlight_data > 0
2905
- rgba[mask] = [1, 1, 0, 0.5] # Yellow with alpha 0.5
3295
+ rgba[mask] = [1, 1, 0, 0.8] # Yellow with alpha 0.5
2906
3296
  else:
2907
3297
  # Multi-color highlight for machine window
2908
3298
  mask_1 = (highlight_data == 1)
@@ -2914,7 +3304,31 @@ class ImageViewerWindow(QMainWindow):
2914
3304
 
2915
3305
  def blend_layers(self, base, overlay):
2916
3306
  """Alpha blend two RGBA layers"""
2917
- # Standard alpha blending formula
3307
+
3308
+ def resize_overlay_to_base(overlay_arr, base_arr):
3309
+ base_height, base_width = base_arr.shape[:2]
3310
+ overlay_height, overlay_width = overlay_arr.shape[:2]
3311
+
3312
+ # First crop if overlay is larger
3313
+ cropped_overlay = overlay_arr[:base_height, :base_width]
3314
+
3315
+ # Then pad if still smaller after cropping
3316
+ current_height, current_width = cropped_overlay.shape[:2]
3317
+ pad_height = base_height - current_height
3318
+ pad_width = base_width - current_width
3319
+
3320
+ if pad_height > 0 or pad_width > 0:
3321
+ cropped_overlay = np.pad(cropped_overlay,
3322
+ ((0, pad_height), (0, pad_width), (0, 0)),
3323
+ mode='constant', constant_values=0)
3324
+
3325
+ return cropped_overlay
3326
+
3327
+ # Resize the ENTIRE overlay array to match base dimensions
3328
+ if overlay.shape[:2] != base.shape[:2]:
3329
+ overlay = resize_overlay_to_base(overlay, base)
3330
+
3331
+ # Now extract alpha channels (they should be the same size)
2918
3332
  alpha_overlay = overlay[:, :, 3:4]
2919
3333
  alpha_base = base[:, :, 3:4]
2920
3334
 
@@ -2935,31 +3349,105 @@ class ImageViewerWindow(QMainWindow):
2935
3349
 
2936
3350
  return result
2937
3351
 
2938
- def update_display_pan_mode(self):
2939
- """Lightweight display update for pan preview mode"""
3352
+ def update_display_pan_mode(self, current_xlim, current_ylim):
3353
+ """Lightweight display update for pan preview mode with downsample support"""
2940
3354
 
2941
3355
  if self.is_pan_preview and self.pan_background_image is not None:
2942
3356
  # Clear and setup axes
2943
3357
  self.ax.clear()
2944
3358
  self.ax.set_facecolor('black')
2945
3359
 
2946
- # Get dimensions
2947
- height, width = self.pan_background_image.shape[:2]
2948
-
3360
+ # Get original dimensions (before downsampling)
3361
+ if hasattr(self, 'original_dims') and self.original_dims:
3362
+ height, width = self.original_dims
3363
+ else:
3364
+ # Fallback to pan background image dimensions
3365
+ height, width = self.pan_background_image.shape[:2]
3366
+ # If we have downsample factor, scale back up
3367
+ downsample_factor = getattr(self, 'downsample_factor', 1)
3368
+ height *= downsample_factor
3369
+ width *= downsample_factor
3370
+
3371
+ def crop_image(image, y_start, y_end, x_start, x_end):
3372
+ # Crop
3373
+ if len(image.shape) == 2:
3374
+ cropped = image[y_start:y_end, x_start:x_end]
3375
+ elif len(image.shape) == 3:
3376
+ cropped = image[y_start:y_end, x_start:x_end, :]
3377
+ else:
3378
+ cropped = image
3379
+
3380
+ return cropped
3381
+
3382
+
3383
+ downsample_factor = self.downsample_factor
3384
+ min_height, min_width = self.original_dims
3385
+
3386
+ # Calculate the visible region in pixel coordinates
3387
+ x_min = max(0, int(np.floor(current_xlim[0] + 0.5)))
3388
+ x_max = min(min_width, int(np.ceil(current_xlim[1] + 0.5)))
3389
+ y_min = max(0, int(np.floor(current_ylim[1] + 0.5))) # Note: y is flipped
3390
+ y_max = min(min_height, int(np.ceil(current_ylim[0] + 0.5)))
3391
+
3392
+ box_len = x_max - x_min
3393
+ box_height = y_max - y_min
3394
+ x_min = max(0, x_min - box_len)
3395
+ x_max = min(self.shape[2], x_max + box_len)
3396
+ y_min = max(0, y_min - box_height)
3397
+ y_max = min(self.shape[1], y_max + box_height)
3398
+
3399
+ # Add some padding to avoid edge artifacts during pan/zoom
3400
+ padding = max(10, downsample_factor * 2)
3401
+ x_min_padded = max(0, x_min - padding)
3402
+ x_max_padded = min(min_width, x_max + padding)
3403
+ y_min_padded = max(0, y_min - padding)
3404
+ y_max_padded = min(min_height, y_max + padding)
3405
+
3406
+ # Convert coordinates to downsampled space for cropping
3407
+ downsample_factor = self.downsample_factor
3408
+
3409
+ # Convert to downsampled coordinates for cropping
3410
+ x_min_ds = x_min // downsample_factor
3411
+ x_max_ds = x_max // downsample_factor
3412
+ y_min_ds = y_min // downsample_factor
3413
+ y_max_ds = y_max // downsample_factor
3414
+
3415
+ # Add padding in downsampled space
3416
+ padding_ds = max(10 // downsample_factor, 2)
3417
+ x_min_padded_ds = max(0, x_min_ds - padding_ds)
3418
+ x_max_padded_ds = min(self.pan_background_image.shape[1], x_max_ds + padding_ds)
3419
+ y_min_padded_ds = max(0, y_min_ds - padding_ds)
3420
+ y_max_padded_ds = min(self.pan_background_image.shape[0], y_max_ds + padding_ds)
3421
+
3422
+ # Crop using downsampled coordinates
3423
+ display_image = crop_image(
3424
+ self.pan_background_image, y_min_padded_ds, y_max_padded_ds,
3425
+ x_min_padded_ds, x_max_padded_ds)
3426
+
3427
+ # Calculate the extent for the cropped region (in original coordinates)
3428
+ crop_extent = (x_min_padded - 0.5, x_max_padded - 0.5,
3429
+ y_max_padded - 0.5, y_min_padded - 0.5)
3430
+
2949
3431
  # Display the composite background with preserved zoom
2950
- self.ax.imshow(self.pan_background_image,
2951
- extent=(-0.5, width-0.5, height-0.5, -0.5),
3432
+ # Use extent to stretch downsampled image back to original coordinate space
3433
+ self.ax.imshow(display_image,
3434
+ extent=crop_extent,
2952
3435
  aspect='equal')
2953
3436
 
2954
3437
  # Restore the zoom state from when pan began
2955
- if hasattr(self, 'pan_zoom_state'):
2956
- self.ax.set_xlim(self.pan_zoom_state[0])
2957
- self.ax.set_ylim(self.pan_zoom_state[1])
3438
+ self.ax.set_xlim(current_xlim)
3439
+ self.ax.set_ylim(current_ylim)
3440
+
3441
+ # Get downsample factor for title display
3442
+ downsample_factor = getattr(self, 'downsample_factor', 1)
2958
3443
 
2959
3444
  # Style the axes (same as update_display)
2960
3445
  self.ax.set_xlabel('X')
2961
- self.ax.set_ylabel('Y')
2962
- self.ax.set_title(f'Slice {self.current_slice}')
3446
+ self.ax.set_ylabel('Y')
3447
+ if downsample_factor > 1:
3448
+ self.ax.set_title(f'Slice {self.current_slice}')
3449
+ else:
3450
+ self.ax.set_title(f'Slice {self.current_slice}')
2963
3451
  self.ax.xaxis.label.set_color('black')
2964
3452
  self.ax.yaxis.label.set_color('black')
2965
3453
  self.ax.title.set_color('black')
@@ -2967,7 +3455,7 @@ class ImageViewerWindow(QMainWindow):
2967
3455
  for spine in self.ax.spines.values():
2968
3456
  spine.set_color('black')
2969
3457
 
2970
- # Add measurement points if they exist (same as update_display)
3458
+ # Add measurement points if they exist (coordinates remain in original space)
2971
3459
  for point in self.measurement_points:
2972
3460
  x1, y1, z1 = point['point1']
2973
3461
  x2, y2, z2 = point['point2']
@@ -2999,6 +3487,15 @@ class ImageViewerWindow(QMainWindow):
2999
3487
 
3000
3488
  if self.pan_mode:
3001
3489
 
3490
+ # Update display to show only background
3491
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3492
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3493
+ # Update display to show only background
3494
+ if self.pan_background_image is not None:
3495
+ self.update_display_pan_mode(current_xlim, current_ylim)
3496
+ else:
3497
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
3498
+
3002
3499
  self.panning = False
3003
3500
  self.pan_start = None
3004
3501
  self.canvas.setCursor(Qt.CursorShape.OpenHandCursor)
@@ -3019,9 +3516,13 @@ class ImageViewerWindow(QMainWindow):
3019
3516
  self.show_crop_dialog(args)
3020
3517
 
3021
3518
  elif self.zoom_mode: #Optional targeted zoom
3519
+ # Calculate aspect ratio to avoid zooming into very thin rectangles
3520
+ aspect_ratio = width / height if height > 0 else float('inf')
3022
3521
 
3023
- self.ax.set_xlim([x0, x0 + width])
3024
- self.ax.set_ylim([y0 + height, y0])
3522
+ # Skip zoom if the rectangle is too narrow/thin (adjust thresholds as needed)
3523
+ if width > 10 and height > 10 and 0.1 < aspect_ratio < 10:
3524
+ self.ax.set_xlim([x0, x0 + width])
3525
+ self.ax.set_ylim([y0 + height, y0])
3025
3526
 
3026
3527
  self.zoom_changed = True # Flag that zoom has changed
3027
3528
 
@@ -3093,7 +3594,10 @@ class ImageViewerWindow(QMainWindow):
3093
3594
  except:
3094
3595
  pass
3095
3596
  self.selection_rect = None
3096
- self.canvas.draw()
3597
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3598
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3599
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
3600
+ #self.canvas.draw()
3097
3601
 
3098
3602
  elif self.zoom_mode:
3099
3603
  # Handle zoom mode press
@@ -3126,11 +3630,13 @@ class ImageViewerWindow(QMainWindow):
3126
3630
 
3127
3631
  new_xlim = [xdata - x_range, xdata + x_range]
3128
3632
  new_ylim = [ydata - y_range, ydata + y_range]
3633
+
3634
+ shift_pressed = 'shift' in event.modifiers
3129
3635
 
3130
- if (new_xlim[0] <= self.original_xlim[0] or
3131
- new_xlim[1] >= self.original_xlim[1] or
3132
- new_ylim[0] <= self.original_ylim[0] or
3133
- new_ylim[1] >= self.original_ylim[1]):
3636
+ if (new_xlim[0] <= 0 or
3637
+ new_xlim[1] >= self.shape[2] or
3638
+ new_ylim[0] <= 0 or
3639
+ new_ylim[1] >= self.shape[1]) or shift_pressed:
3134
3640
  self.ax.set_xlim(self.original_xlim)
3135
3641
  self.ax.set_ylim(self.original_ylim)
3136
3642
  else:
@@ -3142,7 +3648,10 @@ class ImageViewerWindow(QMainWindow):
3142
3648
  if not hasattr(self, 'zoom_changed'):
3143
3649
  self.zoom_changed = False
3144
3650
 
3145
- self.canvas.draw()
3651
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3652
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3653
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
3654
+ #self.canvas.draw()
3146
3655
 
3147
3656
  # Handle brush mode cleanup with paint session management
3148
3657
  if self.brush_mode and hasattr(self, 'painting') and self.painting:
@@ -3277,7 +3786,7 @@ class ImageViewerWindow(QMainWindow):
3277
3786
 
3278
3787
  if not hasattr(self, 'zoom_changed'):
3279
3788
  self.zoom_changed = False
3280
-
3789
+
3281
3790
  elif event.button == 3: # Right click - zoom out
3282
3791
  x_range = (current_xlim[1] - current_xlim[0])
3283
3792
  y_range = (current_ylim[1] - current_ylim[0])
@@ -3296,10 +3805,11 @@ class ImageViewerWindow(QMainWindow):
3296
3805
  self.ax.set_ylim(new_ylim)
3297
3806
 
3298
3807
 
3299
- self.zoom_changed = False # Flag that zoom has changed
3300
-
3301
-
3302
- self.canvas.draw()
3808
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3809
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3810
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
3811
+
3812
+ #self.canvas.draw()
3303
3813
 
3304
3814
  elif event.button == 3: # Right click
3305
3815
  self.create_context_menu(event)
@@ -3606,11 +4116,85 @@ class ImageViewerWindow(QMainWindow):
3606
4116
  help_button = menubar.addAction("Help")
3607
4117
  help_button.triggered.connect(self.help_me)
3608
4118
 
4119
+ # Initialize downsample factor
4120
+ self.downsample_factor = 1
4121
+
4122
+ # Create container widget for corner controls
4123
+ corner_widget = QWidget()
4124
+ corner_layout = QHBoxLayout(corner_widget)
4125
+ corner_layout.setContentsMargins(5, 0, 5, 0)
4126
+
4127
+
4128
+ # Add after your other buttons
4129
+ self.popup_button = QPushButton("⤴") # or "🔗" or "⤴"
4130
+ self.popup_button.setFixedSize(40, 40)
4131
+ self.popup_button.setToolTip("Pop out canvas")
4132
+ self.popup_button.clicked.connect(self.popup_canvas)
4133
+ corner_layout.addWidget(self.popup_button)
4134
+
4135
+ # Add some spacing
4136
+ corner_layout.addSpacing(10)
4137
+
4138
+ # Add camera button
3609
4139
  cam_button = QPushButton("📷")
3610
4140
  cam_button.setFixedSize(40, 40)
3611
- cam_button.setStyleSheet("font-size: 24px;") # Makes emoji larger
4141
+ cam_button.setStyleSheet("font-size: 24px;")
3612
4142
  cam_button.clicked.connect(self.snap)
3613
- menubar.setCornerWidget(cam_button, Qt.Corner.TopRightCorner)
4143
+ corner_layout.addWidget(cam_button)
4144
+
4145
+ # Set as corner widget
4146
+ menubar.setCornerWidget(corner_widget, Qt.Corner.TopRightCorner)
4147
+
4148
+ def on_downsample_changed(self, text):
4149
+ """Called whenever the text in the downsample input changes"""
4150
+ try:
4151
+ if text.strip() == "":
4152
+ self.downsample_factor = 1
4153
+ else:
4154
+ value = float(text)
4155
+ if value <= 0:
4156
+ self.downsample_factor = 1
4157
+ else:
4158
+ self.downsample_factor = int(value) if value == int(value) else value
4159
+ except (ValueError, TypeError):
4160
+ self.downsample_factor = 1
4161
+
4162
+ def validate_downsample_input(self, text = None, update = True):
4163
+ """Called when user finishes editing (loses focus or presses Enter)"""
4164
+ if text:
4165
+ self.downsample_factor = text
4166
+ else:
4167
+ try: # If enabled for manual display downsampling
4168
+ text = self.downsample_input.text().strip()
4169
+ if text == "":
4170
+ # Empty input - set to default
4171
+ self.downsample_factor = 1
4172
+ self.downsample_input.setText("1")
4173
+ else:
4174
+ value = int(text)
4175
+ if value < 1:
4176
+ # Invalid value - reset to default
4177
+ self.downsample_factor = 1
4178
+ self.downsample_input.setText("1")
4179
+ else:
4180
+ # Valid value - use it (prefer int if possible)
4181
+ if value == int(value):
4182
+ self.downsample_factor = int(value)
4183
+ self.downsample_input.setText(str(int(value)))
4184
+ else:
4185
+ self.downsample_factor = value
4186
+ self.downsample_input.setText(f"{value:.1f}")
4187
+ except:
4188
+ # Invalid input - reset to default
4189
+ self.downsample_factor = 1
4190
+
4191
+ self.throttle = self.shape[1] * self.shape[2] > 3000 * 3000 * self.downsample_factor
4192
+
4193
+ # Optional: Trigger display update if you want immediate effect
4194
+ if update:
4195
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
4196
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
4197
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
3614
4198
 
3615
4199
  def snap(self):
3616
4200
  try:
@@ -3643,7 +4227,14 @@ class ImageViewerWindow(QMainWindow):
3643
4227
  filename += '.png'
3644
4228
  format_type = 'png'
3645
4229
 
3646
- # Method 1: Save with axes bbox (recommended)
4230
+ if self.downsample_factor > 1:
4231
+ self.pan_mode = True # Update display will ignore downsamples if this is true so we can just use it here
4232
+ self.downsample_factor = 1
4233
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
4234
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
4235
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
4236
+
4237
+ # Save with axes bbox
3647
4238
  bbox = self.ax.get_window_extent().transformed(self.figure.dpi_scale_trans.inverted())
3648
4239
  self.figure.savefig(filename,
3649
4240
  dpi=300,
@@ -3654,6 +4245,8 @@ class ImageViewerWindow(QMainWindow):
3654
4245
  pad_inches=0)
3655
4246
 
3656
4247
  print(f"Axes snapshot saved: {filename}")
4248
+
4249
+ self.toggle_pan_mode() # Assesses pan state since we messed with its vars potentially
3657
4250
 
3658
4251
  except Exception as e:
3659
4252
  print(f"Error saving snapshot: {e}")
@@ -4372,7 +4965,8 @@ class ImageViewerWindow(QMainWindow):
4372
4965
 
4373
4966
  if directory != "":
4374
4967
 
4375
- self.reset(network = True, xy_scale = 1, z_scale = 1, edges = True, network_overlay = True, id_overlay = True)
4968
+ self.reset(network = True, xy_scale = 1, z_scale = 1, nodes = True, edges = True, network_overlay = True, id_overlay = True, update = False)
4969
+
4376
4970
 
4377
4971
  my_network.assemble(directory)
4378
4972
 
@@ -4399,7 +4993,7 @@ class ImageViewerWindow(QMainWindow):
4399
4993
  if channel is not None:
4400
4994
  self.slice_slider.setEnabled(True)
4401
4995
  self.slice_slider.setMinimum(0)
4402
- self.slice_slider.setMaximum(channel.shape[0] - 1)
4996
+ self.slice_slider.setMaximum(self.shape[0] - 1)
4403
4997
  self.slice_slider.setValue(0)
4404
4998
  self.current_slice = 0
4405
4999
  break
@@ -4685,6 +5279,8 @@ class ImageViewerWindow(QMainWindow):
4685
5279
  """Load a channel and enable active channel selection if needed."""
4686
5280
 
4687
5281
  try:
5282
+
5283
+ self.hold_update = True
4688
5284
  if not data: # For solo loading
4689
5285
  filename, _ = QFileDialog.getOpenFileName(
4690
5286
  self,
@@ -4752,9 +5348,13 @@ class ImageViewerWindow(QMainWindow):
4752
5348
  try:
4753
5349
  if len(self.channel_data[channel_index].shape) == 3: # potentially 2D RGB
4754
5350
  if self.channel_data[channel_index].shape[-1] in (3, 4): # last dim is 3 or 4
4755
- if self.confirm_rgb_dialog():
4756
- # User confirmed it's 2D RGB, expand to 4D
5351
+ if not data and self.shape is None:
5352
+ if self.confirm_rgb_dialog():
5353
+ # User confirmed it's 2D RGB, expand to 4D
5354
+ self.channel_data[channel_index] = np.expand_dims(self.channel_data[channel_index], axis=0)
5355
+ elif self.shape[0] == 1: # this can only be true if the user already loaded in a 2d image
4757
5356
  self.channel_data[channel_index] = np.expand_dims(self.channel_data[channel_index], axis=0)
5357
+
4758
5358
  except:
4759
5359
  pass
4760
5360
 
@@ -4861,6 +5461,11 @@ class ImageViewerWindow(QMainWindow):
4861
5461
  pass
4862
5462
 
4863
5463
  self.shape = self.channel_data[channel_index].shape
5464
+ if self.shape[1] * self.shape[2] > 3000 * 3000 * self.downsample_factor:
5465
+ self.throttle = True
5466
+ else:
5467
+ self.throttle = False
5468
+
4864
5469
 
4865
5470
  self.img_height, self.img_width = self.shape[1], self.shape[2]
4866
5471
  self.original_ylim, self.original_xlim = (self.shape[1] + 0.5, -0.5), (-0.5, self.shape[2] - 0.5)
@@ -4874,11 +5479,15 @@ class ImageViewerWindow(QMainWindow):
4874
5479
  self.current_operation = []
4875
5480
  self.current_operation_type = None
4876
5481
 
4877
- if not end_paint:
5482
+ if self.pan_mode:
5483
+ self.pan_button.click()
5484
+ if self.show_channels:
5485
+ self.channel_buttons[channel_index].click()
5486
+ self.channel_buttons[channel_index].click()
5487
+ elif not end_paint:
4878
5488
 
4879
5489
  self.update_display(reset_resize = reset_resize, preserve_zoom = preserve_zoom)
4880
5490
 
4881
-
4882
5491
 
4883
5492
  except Exception as e:
4884
5493
 
@@ -4890,7 +5499,7 @@ class ImageViewerWindow(QMainWindow):
4890
5499
  f"Failed to load tiff file: {str(e)}"
4891
5500
  )
4892
5501
 
4893
- def delete_channel(self, channel_index, called = True):
5502
+ def delete_channel(self, channel_index, called = True, update = True):
4894
5503
  """Delete the specified channel and update the display."""
4895
5504
  if called:
4896
5505
  # Confirm deletion
@@ -4936,11 +5545,13 @@ class ImageViewerWindow(QMainWindow):
4936
5545
  else:
4937
5546
  # If no channels are available, disable active channel selector
4938
5547
  self.active_channel_combo.setEnabled(False)
5548
+ self.shape = None # Also there is not an active shape anymore
4939
5549
 
4940
- # Update display
4941
- self.update_display()
5550
+ if update:
5551
+ # Update display
5552
+ self.update_display()
4942
5553
 
4943
- def reset(self, nodes = False, network = False, xy_scale = 1, z_scale = 1, edges = False, search_region = False, network_overlay = False, id_overlay = False):
5554
+ def reset(self, nodes = False, network = False, xy_scale = 1, z_scale = 1, edges = False, search_region = False, network_overlay = False, id_overlay = False, update = True):
4944
5555
  """Method to flexibly reset certain fields to free up the RAM as desired"""
4945
5556
 
4946
5557
  # Set scales first before any clearing operations
@@ -4961,10 +5572,10 @@ class ImageViewerWindow(QMainWindow):
4961
5572
  self.selection_table.setModel(PandasModel(empty_df))
4962
5573
 
4963
5574
  if nodes:
4964
- self.delete_channel(0, False)
5575
+ self.delete_channel(0, False, update = update)
4965
5576
 
4966
5577
  if edges:
4967
- self.delete_channel(1, False)
5578
+ self.delete_channel(1, False, update = update)
4968
5579
  try:
4969
5580
  if search_region:
4970
5581
  my_network.search_region = None
@@ -4972,10 +5583,10 @@ class ImageViewerWindow(QMainWindow):
4972
5583
  pass
4973
5584
 
4974
5585
  if network_overlay:
4975
- self.delete_channel(2, False)
5586
+ self.delete_channel(2, False, update = update)
4976
5587
 
4977
5588
  if id_overlay:
4978
- self.delete_channel(3, False)
5589
+ self.delete_channel(3, False, update = update)
4979
5590
 
4980
5591
 
4981
5592
 
@@ -5109,9 +5720,14 @@ class ImageViewerWindow(QMainWindow):
5109
5720
  self.current_slice = slice_value
5110
5721
  if self.mini_overlay == True: #If we are rendering the highlight overlay for selected values one at a time.
5111
5722
  self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
5112
- self.update_display(preserve_zoom=view_settings)
5723
+ if not self.hold_update:
5724
+ self.update_display(preserve_zoom=view_settings)
5725
+ else:
5726
+ self.hold_update = False
5113
5727
  #if self.machine_window is not None:
5114
5728
  #self.machine_window.poke_segmenter()
5729
+ if self.pan_mode:
5730
+ self.pan_button.click()
5115
5731
  self.pending_slice = None
5116
5732
 
5117
5733
  def update_brightness(self, channel_index, values):
@@ -5127,51 +5743,59 @@ class ImageViewerWindow(QMainWindow):
5127
5743
  self.update_display(preserve_zoom = (current_xlim, current_ylim))
5128
5744
 
5129
5745
 
5130
-
5131
-
5132
- def update_display(self, preserve_zoom=None, dims = None, called = False, reset_resize = False):
5133
- """Update the display with currently visible channels and highlight overlay."""
5746
+ def update_display(self, preserve_zoom=None, dims=None, called=False, reset_resize=False, skip=False):
5747
+ """Optimized display update with view-based cropping for performance."""
5134
5748
  try:
5135
- self.figure.clear()
5749
+ # Initialize reusable components if they don't exist
5750
+ if not hasattr(self, 'channel_images'):
5751
+ self.channel_images = {}
5752
+ self.highlight_image = None
5753
+ self.measurement_artists = []
5754
+ self.axes_initialized = False
5755
+ self.original_dims = None
5756
+
5757
+ # Handle special states (pan, static background)
5136
5758
  if self.pan_background_image is not None:
5137
- # Restore previously visible channels
5138
5759
  self.channel_visible = self.pre_pan_channel_state.copy()
5139
5760
  self.is_pan_preview = False
5140
5761
  self.pan_background_image = None
5141
5762
  if self.resume:
5142
5763
  self.machine_window.segmentation_worker.resume()
5143
5764
  self.resume = False
5765
+ if self.prev_down != self.downsample_factor:
5766
+ self.validate_downsample_input(text = self.prev_down)
5767
+ return
5768
+
5144
5769
  if self.static_background is not None:
5145
- # NEW: Convert virtual strokes to real data before cleanup
5770
+ # Your existing virtual strokes conversion logic
5146
5771
  if (hasattr(self, 'virtual_draw_operations') and self.virtual_draw_operations) or \
5147
5772
  (hasattr(self, 'virtual_erase_operations') and self.virtual_erase_operations) or \
5148
5773
  (hasattr(self, 'current_operation') and self.current_operation):
5149
- # Finish current operation first
5150
5774
  if hasattr(self, 'current_operation') and self.current_operation:
5151
5775
  self.pm.finish_current_virtual_operation()
5152
- # Now convert to real data
5153
5776
  self.pm.convert_virtual_strokes_to_data()
5154
5777
 
5155
- # Restore hidden channels
5156
5778
  try:
5157
5779
  for i in self.restore_channels:
5158
5780
  self.channel_visible[i] = True
5159
5781
  self.restore_channels = []
5160
5782
  except:
5161
5783
  pass
5162
-
5163
5784
  self.static_background = None
5164
-
5785
+
5786
+ # Your existing machine_window logic
5165
5787
  if self.machine_window is None:
5166
5788
  try:
5167
- 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, :, :])
5168
- self.load_channel(self.temp_chan, self.channel_data[4], data = True, end_paint = True)
5789
+ self.channel_data[4][self.current_slice, :, :] = n3d.overlay_arrays_simple(
5790
+ self.channel_data[self.temp_chan][self.current_slice, :, :],
5791
+ self.channel_data[4][self.current_slice, :, :])
5792
+ self.load_channel(self.temp_chan, self.channel_data[4], data=True, end_paint=True)
5169
5793
  self.channel_data[4] = None
5170
5794
  self.channel_visible[4] = False
5171
5795
  except:
5172
5796
  pass
5173
5797
 
5174
- # Get active channels and their dimensions
5798
+ # Get dimensions
5175
5799
  active_channels = [i for i in range(4) if self.channel_data[i] is not None]
5176
5800
  if dims is None:
5177
5801
  if active_channels:
@@ -5180,247 +5804,260 @@ class ImageViewerWindow(QMainWindow):
5180
5804
  min_height = min(d[0] for d in dims)
5181
5805
  min_width = min(d[1] for d in dims)
5182
5806
  else:
5183
- min_height = 1
5184
- min_width = 1
5807
+ min_height = min_width = 1
5185
5808
  else:
5186
- min_height = dims[0]
5187
- min_width = dims[1]
5809
+ min_height, min_width = dims[:2]
5810
+
5811
+ # Store original dimensions for pixel coordinate conversion
5812
+ self.original_dims = (min_height, min_width)
5813
+
5814
+ # Initialize axes only once or when needed
5815
+ if not self.axes_initialized or not hasattr(self, 'ax') or self.ax is None:
5816
+ self.figure.clear()
5817
+ self.figure.patch.set_facecolor('white')
5818
+ self.ax = self.figure.add_subplot(111)
5819
+ self.ax.set_facecolor('black')
5820
+ self.axes_initialized = True
5821
+
5822
+ # Style the axes once
5823
+ self.ax.set_xlabel('X')
5824
+ self.ax.set_ylabel('Y')
5825
+ self.ax.xaxis.label.set_color('black')
5826
+ self.ax.yaxis.label.set_color('black')
5827
+ self.ax.tick_params(colors='black')
5828
+ for spine in self.ax.spines.values():
5829
+ spine.set_color('black')
5830
+ else:
5831
+ # Clear only the image data, keep axes structure
5832
+ for img in list(self.ax.get_images()):
5833
+ img.remove()
5834
+ # Clear measurement points
5835
+ for artist in self.measurement_artists:
5836
+ artist.remove()
5837
+ self.measurement_artists.clear()
5838
+
5839
+ # Determine the current view bounds (either from preserve_zoom or current state)
5840
+ if preserve_zoom:
5841
+ current_xlim, current_ylim = preserve_zoom
5842
+ else:
5843
+ current_xlim = (-0.5, self.shape[2] - 0.5)
5844
+ current_ylim = (self.shape[1] - 0.5, -0.5)
5845
+
5846
+ # Calculate the visible region in pixel coordinates
5847
+ x_min = max(0, int(np.floor(current_xlim[0] + 0.5)))
5848
+ x_max = min(min_width, int(np.ceil(current_xlim[1] + 0.5)))
5849
+ y_min = max(0, int(np.floor(current_ylim[1] + 0.5))) # Note: y is flipped
5850
+ y_max = min(min_height, int(np.ceil(current_ylim[0] + 0.5)))
5188
5851
 
5189
- # Set axes limits before displaying any images
5852
+ if self.pan_mode:
5853
+ box_len = (x_max - x_min)
5854
+ box_height = (y_max - y_min)
5855
+ x_min = max(0, x_min - box_len)
5856
+ x_max = min(self.shape[2], x_max + box_len)
5857
+ y_min = max(0, y_min - box_height)
5858
+ y_max = min(self.shape[1], y_max + box_height)
5859
+
5860
+ size = (x_max - x_min) * (y_max - y_min)
5861
+ val = int(np.ceil(size/(3000 * 3000)))
5862
+ self.validate_downsample_input(text = val, update = False)
5863
+
5864
+ downsample_factor = self.downsample_factor
5865
+
5866
+ # Add some padding to avoid edge artifacts during pan/zoom
5867
+ padding = max(10, downsample_factor * 2)
5868
+ x_min_padded = max(0, x_min - padding)
5869
+ x_max_padded = min(min_width, x_max + padding)
5870
+ y_min_padded = max(0, y_min - padding)
5871
+ y_max_padded = min(min_height, y_max + padding)
5872
+
5873
+ # Calculate the extent for the cropped region (in original coordinates)
5874
+ crop_extent = (x_min_padded - 0.5, x_max_padded - 0.5,
5875
+ y_max_padded - 0.5, y_min_padded - 0.5)
5876
+
5877
+ # Set limits to original dimensions (important for pixel queries)
5190
5878
  self.ax.set_xlim(-0.5, min_width - 0.5)
5191
5879
  self.ax.set_ylim(min_height - 0.5, -0.5)
5880
+ self.ax.set_title(f'Slice {self.current_slice}')
5881
+ self.ax.title.set_color('black')
5192
5882
 
5193
- # Create subplot with tight layout and white figure background
5194
- self.figure.patch.set_facecolor('white')
5195
- self.ax = self.figure.add_subplot(111)
5196
-
5197
- # Store current zoom limits if they exist and weren't provided
5198
-
5199
- current_xlim, current_ylim = preserve_zoom if preserve_zoom else (None, None)
5200
-
5201
- # Define base colors for each channel with increased intensity
5202
5883
  base_colors = self.base_colors
5203
- # Set only the axes (image area) background to black
5204
- self.ax.set_facecolor('black')
5205
5884
 
5206
- # Display each visible channel
5885
+ # Helper function to crop and downsample image
5886
+ def crop_and_downsample_image(image, y_start, y_end, x_start, x_end, factor):
5887
+ # Crop first
5888
+ if len(image.shape) == 2:
5889
+ cropped = image[y_start:y_end, x_start:x_end]
5890
+ elif len(image.shape) == 3:
5891
+ cropped = image[y_start:y_end, x_start:x_end, :]
5892
+ else:
5893
+ cropped = image
5894
+
5895
+ # Then downsample if needed
5896
+ if factor == 1:
5897
+ return cropped
5898
+
5899
+ if len(cropped.shape) == 2:
5900
+ return cropped[::factor, ::factor]
5901
+ elif len(cropped.shape) == 3:
5902
+ return cropped[::factor, ::factor, :]
5903
+ else:
5904
+ return cropped
5905
+
5906
+
5907
+ # Update channel images efficiently with cropping and downsampling
5207
5908
  for channel in range(4):
5208
- if (self.channel_visible[channel] and
5209
- self.channel_data[channel] is not None):
5210
-
5211
- # Check if we're dealing with RGB data
5212
- is_rgb = len(self.channel_data[channel].shape) == 4 and (self.channel_data[channel].shape[-1] == 3 or self.channel_data[channel].shape[-1] == 4)
5909
+ if self.channel_visible[channel] and self.channel_data[channel] is not None:
5910
+ # Get current image data
5911
+ is_rgb = len(self.channel_data[channel].shape) == 4 and (
5912
+ self.channel_data[channel].shape[-1] in [3, 4])
5213
5913
 
5214
5914
  if len(self.channel_data[channel].shape) == 3 and not is_rgb:
5215
5915
  current_image = self.channel_data[channel][self.current_slice, :, :]
5216
5916
  elif is_rgb:
5217
- current_image = self.channel_data[channel][self.current_slice] # Already has RGB channels
5917
+ current_image = self.channel_data[channel][self.current_slice]
5218
5918
  else:
5219
5919
  current_image = self.channel_data[channel]
5220
5920
 
5921
+ # Crop and downsample the image for rendering
5922
+ display_image = crop_and_downsample_image(
5923
+ current_image, y_min_padded, y_max_padded,
5924
+ x_min_padded, x_max_padded, downsample_factor)
5925
+
5221
5926
  if is_rgb and self.channel_data[channel].shape[-1] in [3, 4]:
5222
- # For RGB/RGBA images, use brightness/contrast to control alpha instead
5223
-
5224
- # Calculate alpha based on brightness settings
5927
+ # RGB handling (keep your existing logic)
5225
5928
  brightness_min = self.channel_brightness[channel]['min']
5226
5929
  brightness_max = self.channel_brightness[channel]['max']
5227
-
5228
- # Map brightness range to alpha range (0.0 to 1.0)
5229
- # brightness_min controls minimum alpha, brightness_max controls maximum alpha
5230
5930
  alpha_range = brightness_max - brightness_min
5231
- base_alpha = brightness_min
5232
- # You can adjust these multipliers to control the alpha range
5233
- final_alpha = base_alpha + alpha_range # Scale to reasonable alpha range
5234
- final_alpha = np.clip(final_alpha, 0.0, 1.0) # Ensure valid alpha range
5931
+ base_alpha = brightness_min
5932
+ final_alpha = np.clip(base_alpha + alpha_range, 0.0, 1.0)
5235
5933
 
5236
- # Display the image with brightness-controlled alpha
5237
- if current_image.shape[-1] == 4:
5238
- # For RGBA, multiply existing alpha by our brightness-controlled alpha
5239
- img_with_alpha = current_image.copy()
5934
+ if display_image.shape[-1] == 4:
5935
+ img_with_alpha = display_image.copy()
5240
5936
  img_with_alpha[..., 3] = img_with_alpha[..., 3] * final_alpha
5241
- self.ax.imshow(img_with_alpha)
5937
+ # Use crop_extent to place in correct location
5938
+ im = self.ax.imshow(img_with_alpha, extent=crop_extent)
5242
5939
  else:
5243
- # For RGB, apply brightness-controlled alpha directly
5244
- self.ax.imshow(current_image, alpha=final_alpha)
5245
-
5940
+ im = self.ax.imshow(display_image, alpha=final_alpha, extent=crop_extent)
5246
5941
  else:
5247
- # Regular channel processing with colormap (your existing code)
5248
- # Calculate brightness/contrast limits from entire volume
5942
+ # Regular channel processing with optimized normalization
5249
5943
  if self.min_max[channel][0] is None:
5250
- self.min_max[channel][0] = np.min(self.channel_data[channel])
5251
- if self.min_max[channel][1] is None:
5252
- self.min_max[channel][1] = np.max(self.channel_data[channel])
5253
- img_min = self.min_max[channel][0]
5254
- img_max = self.min_max[channel][1]
5944
+ # For very large arrays, consider sampling for min/max
5945
+ if self.channel_data[channel].size > 1000000:
5946
+ sample = self.channel_data[channel][::max(1, self.channel_data[channel].shape[0]//100)]
5947
+ self.min_max[channel] = [np.min(sample), np.max(sample)]
5948
+ else:
5949
+ self.min_max[channel] = [np.min(self.channel_data[channel]),
5950
+ np.max(self.channel_data[channel])]
5951
+
5952
+ img_min, img_max = self.min_max[channel]
5255
5953
 
5256
- # Calculate vmin and vmax, ensuring we don't get a zero range
5257
5954
  if img_min == img_max:
5258
- vmin = img_min
5259
- vmax = img_min + 1
5955
+ vmin, vmax = img_min, img_min + 1
5956
+ normalized_image = np.zeros_like(display_image)
5260
5957
  else:
5261
5958
  vmin = img_min + (img_max - img_min) * self.channel_brightness[channel]['min']
5262
5959
  vmax = img_min + (img_max - img_min) * self.channel_brightness[channel]['max']
5263
-
5264
- # Normalize the image safely
5265
- if vmin == vmax:
5266
- normalized_image = np.zeros_like(current_image)
5267
- else:
5268
- normalized_image = np.clip((current_image - vmin) / (vmax - vmin), 0, 1)
5960
+
5961
+ if vmin == vmax:
5962
+ normalized_image = np.zeros_like(display_image)
5963
+ else:
5964
+ normalized_image = np.clip((display_image - vmin) / (vmax - vmin), 0, 1)
5269
5965
 
5270
5966
  if channel == 2 and self.machine_window is not None:
5271
5967
  custom_cmap = LinearSegmentedColormap.from_list(
5272
5968
  f'custom_{channel}',
5273
- [(0, 0, 0, 0), # transparent for 0
5274
- (0.5, 1, 0.5, 1), # light green for 1
5275
- (1, 0.5, 0.5, 1)] # light red for 2
5969
+ [(0, 0, 0, 0), (0.5, 1, 0.5, 1), (1, 0.5, 0.5, 1)]
5276
5970
  )
5277
- self.ax.imshow(current_image,
5278
- cmap=custom_cmap,
5279
- vmin=0,
5280
- vmax=2,
5281
- alpha=0.7,
5282
- interpolation='nearest',
5283
- extent=(-0.5, min_width-0.5, min_height-0.5, -0.5))
5971
+ im = self.ax.imshow(display_image, cmap=custom_cmap, vmin=0, vmax=2,
5972
+ alpha=0.7, interpolation='nearest', extent=crop_extent)
5284
5973
  else:
5285
- # Create custom colormap with higher intensity
5286
5974
  color = base_colors[channel]
5287
5975
  custom_cmap = LinearSegmentedColormap.from_list(
5288
- f'custom_{channel}',
5289
- [(0,0,0,0), (*color,1)]
5290
- )
5976
+ f'custom_{channel}', [(0,0,0,0), (*color,1)])
5291
5977
 
5292
- # Display the image with slightly higher alpha
5293
- self.ax.imshow(normalized_image,
5294
- alpha=0.7,
5295
- cmap=custom_cmap,
5296
- vmin=0,
5297
- vmax=1,
5298
- extent=(-0.5, min_width-0.5, min_height-0.5, -0.5))
5978
+ im = self.ax.imshow(normalized_image, alpha=0.7, cmap=custom_cmap,
5979
+ vmin=0, vmax=1, extent=crop_extent)
5299
5980
 
5981
+ # Handle preview, overlays, and measurements (apply cropping here too)
5300
5982
  if self.preview and not called:
5301
- self.create_highlight_overlay_slice(self.targs, bounds = self.bounds)
5983
+ self.create_highlight_overlay_slice(self.targs, bounds=self.bounds)
5302
5984
 
5303
- # Add highlight overlay if it exists
5985
+ # Overlay handling (optimized with cropping and downsampling)
5304
5986
  if self.mini_overlay and self.highlight and self.machine_window is None:
5305
- highlight_cmap = LinearSegmentedColormap.from_list(
5306
- 'highlight',
5307
- [(0, 0, 0, 0), (1, 1, 0, 1)] # yellow
5308
- )
5309
- self.ax.imshow(self.mini_overlay_data,
5310
- cmap=highlight_cmap,
5311
- alpha=0.8)
5312
- elif self.highlight_overlay is not None and self.highlight and self.machine_window is None:
5313
- highlight_slice = self.highlight_overlay[self.current_slice]
5314
- highlight_cmap = LinearSegmentedColormap.from_list(
5315
- 'highlight',
5316
- [(0, 0, 0, 0), (1, 1, 0, 1)] # yellow
5317
- )
5318
- self.ax.imshow(highlight_slice,
5319
- cmap=highlight_cmap,
5320
- alpha=0.8)
5987
+ highlight_cmap = LinearSegmentedColormap.from_list('highlight', [(0, 0, 0, 0), (1, 1, 0, 1)])
5988
+ display_overlay = crop_and_downsample_image(
5989
+ self.mini_overlay_data, y_min_padded, y_max_padded,
5990
+ x_min_padded, x_max_padded, downsample_factor)
5991
+ self.ax.imshow(display_overlay, cmap=highlight_cmap, alpha=0.8, extent=crop_extent)
5321
5992
  elif self.highlight_overlay is not None and self.highlight:
5322
5993
  highlight_slice = self.highlight_overlay[self.current_slice]
5323
- highlight_cmap = LinearSegmentedColormap.from_list(
5324
- 'highlight',
5325
- [(0, 0, 0, 0), # transparent for 0
5326
- (1, 1, 0, 1), # bright yellow for 1
5327
- (0, 0.7, 1, 1)] # cool blue for 2
5328
- )
5329
- self.ax.imshow(highlight_slice,
5330
- cmap=highlight_cmap,
5331
- vmin=0,
5332
- vmax=2, # Important: set vmax to 2 to accommodate both values
5333
- alpha=0.3)
5334
-
5335
- if self.channel_data[4] is not None:
5336
-
5337
- highlight_slice = self.channel_data[4][self.current_slice]
5338
- img_min = self.min_max[4][0]
5339
- img_max = self.min_max[4][1]
5340
-
5341
- # Calculate vmin and vmax, ensuring we don't get a zero range
5342
- if img_min == img_max:
5343
- vmin = img_min
5344
- vmax = img_min + 1
5345
- else:
5346
- vmin = img_min + (img_max - img_min) * self.channel_brightness[4]['min']
5347
- vmax = img_min + (img_max - img_min) * self.channel_brightness[4]['max']
5348
-
5349
- # Normalize the image safely
5350
- if vmin == vmax:
5351
- normalized_image = np.zeros_like(highlight_slice)
5994
+ display_highlight = crop_and_downsample_image(
5995
+ highlight_slice, y_min_padded, y_max_padded,
5996
+ x_min_padded, x_max_padded, downsample_factor)
5997
+ if self.machine_window is None:
5998
+ highlight_cmap = LinearSegmentedColormap.from_list('highlight', [(0, 0, 0, 0), (1, 1, 0, 1)])
5999
+ self.ax.imshow(display_highlight, cmap=highlight_cmap, alpha=0.8, extent=crop_extent)
5352
6000
  else:
5353
- normalized_image = np.clip((highlight_slice - vmin) / (vmax - vmin), 0, 1)
5354
-
5355
- color = base_colors[self.temp_chan]
5356
- custom_cmap = LinearSegmentedColormap.from_list(
5357
- f'custom_{4}',
5358
- [(0,0,0,0), (*color,1)]
5359
- )
5360
-
5361
-
5362
- self.ax.imshow(normalized_image,
5363
- alpha=0.7,
5364
- cmap=custom_cmap,
5365
- vmin=0,
5366
- vmax=1)
5367
-
5368
- # Style the axes
5369
- self.ax.set_xlabel('X')
5370
- self.ax.set_ylabel('Y')
5371
- self.ax.set_title(f'Slice {self.current_slice}')
5372
-
5373
- # Make axis labels and ticks black for visibility against white background
5374
- self.ax.xaxis.label.set_color('black')
5375
- self.ax.yaxis.label.set_color('black')
5376
- self.ax.title.set_color('black')
5377
- self.ax.tick_params(colors='black')
5378
- for spine in self.ax.spines.values():
5379
- spine.set_color('black')
6001
+ highlight_cmap = LinearSegmentedColormap.from_list('highlight',
6002
+ [(0, 0, 0, 0), (1, 1, 0, 1), (0, 0.7, 1, 1)])
6003
+ self.ax.imshow(display_highlight, cmap=highlight_cmap, vmin=0, vmax=2, alpha=0.3, extent=crop_extent)
5380
6004
 
5381
- # Adjust the layout to ensure the plot fits well in the figure
5382
- self.figure.tight_layout()
5383
-
5384
- # Redraw measurement points and their labels
6005
+ # Redraw measurement points efficiently (no cropping needed - these are vector graphics)
6006
+ # Only draw points that are within the visible region for additional performance
5385
6007
  for point in self.measurement_points:
5386
6008
  x1, y1, z1 = point['point1']
5387
6009
  x2, y2, z2 = point['point2']
5388
6010
  pair_idx = point['pair_index']
5389
6011
 
5390
- # Draw points and labels if they're on current slice
5391
- if z1 == self.current_slice:
5392
- self.ax.plot(x1, y1, 'yo', markersize=8)
5393
- self.ax.text(x1, y1+5, str(pair_idx),
5394
- color='white', ha='center', va='bottom')
5395
- if z2 == self.current_slice:
5396
- self.ax.plot(x2, y2, 'yo', markersize=8)
5397
- self.ax.text(x2, y2+5, str(pair_idx),
5398
- color='white', ha='center', va='bottom')
6012
+ # Check if points are in visible region
6013
+ point1_visible = (z1 == self.current_slice and
6014
+ current_xlim[0] <= x1 <= current_xlim[1] and
6015
+ current_ylim[1] <= y1 <= current_ylim[0])
6016
+ point2_visible = (z2 == self.current_slice and
6017
+ current_xlim[0] <= x2 <= current_xlim[1] and
6018
+ current_ylim[1] <= y2 <= current_ylim[0])
6019
+
6020
+ if point1_visible:
6021
+ pt1 = self.ax.plot(x1, y1, 'yo', markersize=8)[0]
6022
+ txt1 = self.ax.text(x1, y1+5, str(pair_idx), color='white', ha='center', va='bottom')
6023
+ self.measurement_artists.extend([pt1, txt1])
5399
6024
 
5400
- # Draw line if both points are on current slice
5401
- if z1 == z2 == self.current_slice:
5402
- self.ax.plot([x1, x2], [y1, y2], 'r--', alpha=0.5)
5403
-
5404
- if active_channels:
5405
- self.ax.set_xlim(-0.5, min_width - 0.5)
5406
- self.ax.set_ylim(min_height - 0.5, -0.5)
6025
+ if point2_visible:
6026
+ pt2 = self.ax.plot(x2, y2, 'yo', markersize=8)[0]
6027
+ txt2 = self.ax.text(x2, y2+5, str(pair_idx), color='white', ha='center', va='bottom')
6028
+ self.measurement_artists.extend([pt2, txt2])
6029
+
6030
+ if z1 == z2 == self.current_slice and (point1_visible or point2_visible):
6031
+ line = self.ax.plot([x1, x2], [y1, y2], 'r--', alpha=0.5)[0]
6032
+ self.measurement_artists.append(line)
6033
+
6034
+ # Store current view limits for next update
6035
+ self.ax._current_xlim = current_xlim
6036
+ self.ax._current_ylim = current_ylim
5407
6037
 
6038
+ # Handle resizing
5408
6039
  if self.resizing:
5409
6040
  self.original_xlim = self.ax.get_xlim()
5410
6041
  self.original_ylim = self.ax.get_ylim()
5411
- # Restore zoom limits if they existed
6042
+
6043
+ # Restore zoom (this sets the final view, not the data extent)
5412
6044
  if current_xlim is not None and current_ylim is not None:
5413
6045
  self.ax.set_xlim(current_xlim)
5414
6046
  self.ax.set_ylim(current_ylim)
6047
+
5415
6048
  if reset_resize:
5416
6049
  self.resizing = False
5417
6050
 
5418
- self.canvas.draw()
6051
+ # Use draw_idle for better performance
6052
+ self.canvas.draw_idle()
6053
+
6054
+ except Exception as e:
6055
+ pass
6056
+ #import traceback
6057
+ #print(traceback.format_exc())
6058
+
5419
6059
 
5420
6060
 
5421
- except:
5422
- import traceback
5423
- print(traceback.format_exc())
5424
6061
 
5425
6062
  def get_channel_image(self, channel):
5426
6063
  """Find the matplotlib image object for a specific channel."""
@@ -5452,7 +6089,10 @@ class ImageViewerWindow(QMainWindow):
5452
6089
  stats['num_nodes'] = my_network.network.number_of_nodes()
5453
6090
  stats['num_edges'] = my_network.network.number_of_edges()
5454
6091
  except:
5455
- pass
6092
+ try:
6093
+ stats['num_nodes'] = len(np.unique(my_network.nodes)) - 1
6094
+ except:
6095
+ pass
5456
6096
 
5457
6097
  try:
5458
6098
  idens = invert_dict(my_network.node_identities)
@@ -6463,7 +7103,7 @@ class BrightnessContrastDialog(QDialog):
6463
7103
  self.debounce_timer.setSingleShot(True)
6464
7104
  self.debounce_timer.timeout.connect(self._apply_pending_updates)
6465
7105
  self.pending_updates = {}
6466
- self.debounce_delay = 300 # 300ms delay
7106
+ self.debounce_delay = 20 # 300ms delay
6467
7107
 
6468
7108
  # Connect signals
6469
7109
  slider.valueChanged.connect(lambda values, ch=i: self.on_slider_change(ch, values))
@@ -7378,11 +8018,10 @@ class ComIdDialog(QDialog):
7378
8018
  self.umap.setChecked(True)
7379
8019
  layout.addRow("Generate UMAP?:", self.umap)
7380
8020
 
7381
- # weighted checkbox (default True)
7382
- self.label = QPushButton("Label")
7383
- self.label.setCheckable(True)
7384
- self.label.setChecked(False)
7385
- layout.addRow("If using above - label UMAP points?:", self.label)
8021
+ self.label = QComboBox()
8022
+ self.label.addItems(["No Label", "By Community", "By Neighborhood (If already calculated via 'Analyze -> Network -> Convert Network Communities...')"])
8023
+ self.label.setCurrentIndex(0)
8024
+ layout.addRow("Label UMAP Points How?:", self.label)
7386
8025
 
7387
8026
  self.limit = QLineEdit("")
7388
8027
  layout.addRow("Min Community Size for UMAP (Smaller communities will be ignored in graph, does not apply if empty)", self.limit)
@@ -7402,6 +8041,12 @@ class ComIdDialog(QDialog):
7402
8041
 
7403
8042
  try:
7404
8043
 
8044
+ if self.parent().prev_coms is not None:
8045
+ temp = my_network.communities
8046
+ my_network.communities = self.parent().prev_coms
8047
+ else:
8048
+ temp = None
8049
+
7405
8050
  if my_network.node_identities is None:
7406
8051
  print("Node identities must be set")
7407
8052
 
@@ -7414,7 +8059,9 @@ class ComIdDialog(QDialog):
7414
8059
  mode = self.mode.currentIndex()
7415
8060
 
7416
8061
  umap = self.umap.isChecked()
7417
- label = self.label.isChecked()
8062
+
8063
+ label = self.label.currentIndex()
8064
+
7418
8065
  proportional = self.proportional.isChecked()
7419
8066
  limit = int(self.limit.text()) if self.limit.text().strip() else 0
7420
8067
 
@@ -7427,10 +8074,13 @@ class ComIdDialog(QDialog):
7427
8074
 
7428
8075
  else:
7429
8076
 
7430
- info, names = my_network.community_id_info_per_com(umap = umap, label = label, limit = limit, proportional = proportional)
8077
+ info, names = my_network.community_id_info_per_com(umap = umap, label = label, limit = limit, proportional = proportional, neighbors = temp)
7431
8078
 
7432
8079
  self.parent().format_for_upperright_table(info, 'Community', names, 'Average of Community Makeup')
7433
8080
 
8081
+ if self.parent().prev_coms is not None:
8082
+ my_network.communities = temp
8083
+
7434
8084
  self.accept()
7435
8085
 
7436
8086
  except Exception as e:
@@ -7786,10 +8436,12 @@ class NearNeighDialog(QDialog):
7786
8436
  title = f"Nearest {num} Neighbor(s) Distance of {targ} from {root}"
7787
8437
  header = f"Shortest Distance to Closest {num} {targ}(s)"
7788
8438
  header2 = f"{root} Node ID"
8439
+ header3 = f'Theoretical Uniform Distance to Closest {num} {targ}(s)'
7789
8440
  else:
7790
8441
  title = f"Nearest {num} Neighbor(s) Distance Between Nodes"
7791
8442
  header = f"Shortest Distance to Closest {num} Nodes"
7792
8443
  header2 = "Root Node ID"
8444
+ header3 = f'Simulated Theoretical Uniform Distance to Closest {num} Nodes'
7793
8445
 
7794
8446
  if centroids and my_network.node_centroids is None:
7795
8447
  self.parent().show_centroid_dialog()
@@ -7797,15 +8449,22 @@ class NearNeighDialog(QDialog):
7797
8449
  return
7798
8450
 
7799
8451
  if not numpy:
7800
- 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, centroids = centroids)
8452
+ avg, output, quant_overlay, pred = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num, heatmap = heatmap, threed = threed, quant = quant, centroids = centroids)
7801
8453
  else:
7802
- 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, centroids = centroids)
8454
+ avg, output, overlay, quant_overlay, pred = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num, heatmap = heatmap, threed = threed, numpy = True, quant = quant, centroids = centroids)
7803
8455
  self.parent().load_channel(3, overlay, data = True)
7804
8456
 
7805
8457
  if quant_overlay is not None:
7806
8458
  self.parent().load_channel(2, quant_overlay, data = True)
8459
+
8460
+ avg = {header:avg}
8461
+
8462
+ if pred is not None:
8463
+
8464
+ avg[header3] = pred
8465
+
7807
8466
 
7808
- self.parent().format_for_upperright_table([avg], metric = f'Avg {title}', title = f'Avg {title}')
8467
+ self.parent().format_for_upperright_table(avg, 'Category', 'Value', title = f'Avg {title}')
7809
8468
  self.parent().format_for_upperright_table(output, header2, header, title = title)
7810
8469
 
7811
8470
  self.accept()
@@ -7830,7 +8489,7 @@ class NearNeighDialog(QDialog):
7830
8489
  root = available[0]
7831
8490
 
7832
8491
  for targ in available:
7833
- avg, _, _ = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num, centroids = centroids)
8492
+ avg, _, _, _ = my_network.nearest_neighbors_avg(root, targ, my_network.xy_scale, my_network.z_scale, num = num, centroids = centroids)
7834
8493
  output_dict[f"{root} vs {targ}"] = avg
7835
8494
 
7836
8495
  del available[0]
@@ -8742,15 +9401,6 @@ class ResizeDialog(QDialog):
8742
9401
  undo_button.clicked.connect(lambda: self.run_resize(undo = True))
8743
9402
  layout.addRow(undo_button)
8744
9403
 
8745
- if my_network.xy_scale != my_network.z_scale:
8746
- norm_button_upsize = QPushButton(f"Normalize Scaling with Upsample")
8747
- norm_button_upsize.clicked.connect(lambda: self.run_resize(upsize = True, special = True))
8748
- layout.addRow(norm_button_upsize)
8749
-
8750
- norm_button_downsize = QPushButton("Normalize Scaling with Downsample")
8751
- norm_button_downsize.clicked.connect(lambda: self.run_resize(upsize = False, special = True))
8752
- layout.addRow(norm_button_downsize)
8753
-
8754
9404
  run_button = QPushButton("Run Resize")
8755
9405
  run_button.clicked.connect(self.run_resize)
8756
9406
  layout.addRow(run_button)
@@ -8764,7 +9414,7 @@ class ResizeDialog(QDialog):
8764
9414
 
8765
9415
  def run_resize(self, undo = False, upsize = True, special = False):
8766
9416
  try:
8767
- self.parent().resizing = True
9417
+ self.parent().resizing = False
8768
9418
  # Get parameters
8769
9419
  try:
8770
9420
  resize = float(self.resize.text()) if self.resize.text() else None
@@ -8778,6 +9428,12 @@ class ResizeDialog(QDialog):
8778
9428
 
8779
9429
  resize = resize if resize is not None else (zsize, ysize, xsize)
8780
9430
 
9431
+ if (self.parent().shape[1] * resize) < 1 or (self.parent().shape[2] * resize) < 1:
9432
+ print("Incompatible x/y dimensions")
9433
+ return
9434
+ elif (self.parent().shape[0] * resize) < 1:
9435
+ resize = (1, resize, resize)
9436
+
8781
9437
  if special:
8782
9438
  if upsize:
8783
9439
  if (my_network.z_scale > my_network.xy_scale):
@@ -8819,11 +9475,7 @@ class ResizeDialog(QDialog):
8819
9475
  new_shape = tuple(int(dim * resize) for dim in array_shape)
8820
9476
  else:
8821
9477
  new_shape = tuple(int(dim * factor) for dim, factor in zip(array_shape, resize))
8822
-
8823
- #if any(dim < 1 for dim in new_shape):
8824
- #QMessageBox.critical(self, "Error", f"Resize would result in invalid dimensions: {new_shape}")
8825
- #self.reset_fields()
8826
- #return
9478
+
8827
9479
 
8828
9480
  cubic = self.cubic.isChecked()
8829
9481
  order = 3 if cubic else 0
@@ -8837,7 +9489,7 @@ class ResizeDialog(QDialog):
8837
9489
  for channel in range(4):
8838
9490
  if self.parent().channel_data[channel] is not None:
8839
9491
  resized_data = n3d.resize(self.parent().channel_data[channel], resize, order)
8840
- self.parent().load_channel(channel, channel_data=resized_data, data=True, assign_shape = False)
9492
+ self.parent().load_channel(channel, channel_data=resized_data, data=True)
8841
9493
 
8842
9494
 
8843
9495
 
@@ -8858,7 +9510,7 @@ class ResizeDialog(QDialog):
8858
9510
  for channel in range(4):
8859
9511
  if self.parent().channel_data[channel] is not None:
8860
9512
  resized_data = n3d.upsample_with_padding(self.parent().channel_data[channel], original_shape = self.parent().original_shape)
8861
- self.parent().load_channel(channel, channel_data=resized_data, data=True, assign_shape = False)
9513
+ self.parent().load_channel(channel, channel_data=resized_data, data=True)
8862
9514
 
8863
9515
  if self.parent().mini_overlay_data is not None:
8864
9516
 
@@ -9664,6 +10316,7 @@ class MachineWindow(QMainWindow):
9664
10316
 
9665
10317
  self.num_chunks = 0
9666
10318
 
10319
+
9667
10320
  except:
9668
10321
  return
9669
10322
 
@@ -10126,9 +10779,8 @@ class MachineWindow(QMainWindow):
10126
10779
  print("Finished segmentation moved to Overlay 2. Use File -> Save(As) for disk saving.")
10127
10780
 
10128
10781
  def closeEvent(self, event):
10129
-
10130
10782
  try:
10131
- if self.parent().isVisible():
10783
+ if self.parent() and self.parent().isVisible():
10132
10784
  if self.confirm_close_dialog():
10133
10785
  # Clean up resources before closing
10134
10786
  if self.brush_button.isChecked():
@@ -10141,7 +10793,6 @@ class MachineWindow(QMainWindow):
10141
10793
  # Kill the segmentation thread and wait for it to finish
10142
10794
  self.kill_segmentation()
10143
10795
  time.sleep(0.2) # Give additional time for cleanup
10144
-
10145
10796
  try:
10146
10797
  self.parent().channel_data[0] = self.parent().reduce_rgb_dimension(self.parent().channel_data[0], 'weight')
10147
10798
  self.update_display()
@@ -10149,10 +10800,20 @@ class MachineWindow(QMainWindow):
10149
10800
  pass
10150
10801
 
10151
10802
  self.parent().machine_window = None
10803
+ event.accept() # IMPORTANT: Accept the close event
10152
10804
  else:
10153
- event.ignore()
10154
- except:
10155
- pass
10805
+ event.ignore() # User cancelled, ignore the close
10806
+ else:
10807
+ # Parent doesn't exist or isn't visible, just close
10808
+ if hasattr(self, 'parent') and self.parent():
10809
+ self.parent().machine_window = None
10810
+ event.accept()
10811
+ except Exception as e:
10812
+ print(f"Error in closeEvent: {e}")
10813
+ # Even if there's an error, allow the window to close
10814
+ if hasattr(self, 'parent') and self.parent():
10815
+ self.parent().machine_window = None
10816
+ event.accept()
10156
10817
 
10157
10818
 
10158
10819
 
@@ -10172,10 +10833,7 @@ class SegmentationWorker(QThread):
10172
10833
  self.mem_lock = mem_lock
10173
10834
  self._stop = False
10174
10835
  self._paused = False # Add pause flag
10175
- if self.machine_window.parent().shape[1] * self.machine_window.parent().shape[2] > 3000 * 3000: #arbitrary throttle for large arrays.
10176
- self.update_interval = 10
10177
- else:
10178
- self.update_interval = 1 # Increased to 1s
10836
+ self.update_interval = 2 # Increased to 2s
10179
10837
  self.chunks_since_update = 0
10180
10838
  self.chunks_per_update = 5 # Only update every 5 chunks
10181
10839
  self.poked = False # If it should wake up or not
@@ -10218,11 +10876,14 @@ class SegmentationWorker(QThread):
10218
10876
  if self._stop:
10219
10877
  break
10220
10878
 
10221
- for z,y,x in foreground_coords:
10222
- self.overlay[z,y,x] = 1
10223
- for z,y,x in background_coords:
10224
- self.overlay[z,y,x] = 2
10225
-
10879
+ if foreground_coords:
10880
+ fg_array = np.array(list(foreground_coords))
10881
+ self.overlay[fg_array[:, 0], fg_array[:, 1], fg_array[:, 2]] = 1
10882
+
10883
+ if background_coords:
10884
+ bg_array = np.array(list(background_coords))
10885
+ self.overlay[bg_array[:, 0], bg_array[:, 1], bg_array[:, 2]] = 2
10886
+
10226
10887
  self.chunks_since_update += 1
10227
10888
  current_time = time.time()
10228
10889
  if (self.chunks_since_update >= self.chunks_per_update and
@@ -10247,27 +10908,6 @@ class SegmentationWorker(QThread):
10247
10908
  import traceback
10248
10909
  traceback.print_exc()
10249
10910
 
10250
- def run_batch(self):
10251
- try:
10252
- foreground_coords, _ = self.segmenter.segment_volume()
10253
-
10254
- # Modify the array directly
10255
- self.overlay.fill(False)
10256
- for z,y,x in foreground_coords:
10257
- # Check for pause/stop during batch processing too
10258
- self._check_pause()
10259
- if self._stop:
10260
- break
10261
- self.overlay[z,y,x] = True
10262
-
10263
- self.finished.emit()
10264
-
10265
- except Exception as e:
10266
- print(f"Error in segmentation: {e}")
10267
- raise
10268
-
10269
-
10270
-
10271
10911
 
10272
10912
  class ThresholdWindow(QMainWindow):
10273
10913
  def __init__(self, parent=None, accepted_mode=0):