nettracer3d 0.9.4__py3-none-any.whl → 0.9.6__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.
@@ -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, QEvent)
8
+ from PyQt6.QtCore import (QPoint, Qt, QAbstractTableModel, QTimer, QThread, pyqtSignal, QObject, QCoreApplication, QEvent, QEventLoop)
9
9
  import numpy as np
10
10
  import time
11
11
  from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
@@ -210,6 +210,11 @@ class ImageViewerWindow(QMainWindow):
210
210
  buttons_widget = QWidget()
211
211
  buttons_layout = QHBoxLayout(buttons_widget)
212
212
 
213
+ self.reset_view = QPushButton("🏠")
214
+ self.reset_view.setFixedSize(20, 20)
215
+ self.reset_view.clicked.connect(self.home)
216
+ control_layout.addWidget(self.reset_view)
217
+
213
218
  # "Create" zoom button
214
219
  self.zoom_button = QPushButton("🔍")
215
220
  self.zoom_button.setCheckable(True)
@@ -293,14 +298,6 @@ class ImageViewerWindow(QMainWindow):
293
298
 
294
299
  control_layout.addWidget(channel_container)
295
300
 
296
- self.show_channels = QPushButton("✓")
297
- self.show_channels.setCheckable(True)
298
- self.show_channels.setChecked(True)
299
- self.show_channels.setFixedSize(20, 20)
300
- self.show_channels.clicked.connect(self.toggle_chan_load)
301
- control_layout.addWidget(self.show_channels)
302
- self.chan_load = True
303
-
304
301
  # Create the main widget and layout
305
302
  main_widget = QWidget()
306
303
  self.setCentralWidget(main_widget)
@@ -1005,7 +1002,15 @@ class ImageViewerWindow(QMainWindow):
1005
1002
  pass
1006
1003
 
1007
1004
  # Update display
1008
- self.update_display(preserve_zoom=(current_xlim, current_ylim), called = True)
1005
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
1006
+
1007
+ if self.pan_mode:
1008
+ self.create_pan_background()
1009
+ current_xlim = self.ax.get_xlim()
1010
+ current_ylim = self.ax.get_ylim()
1011
+ self.update_display_pan_mode(current_xlim, current_ylim)
1012
+
1013
+
1009
1014
 
1010
1015
  def create_mini_overlay(self, node_indices = None, edge_indices = None):
1011
1016
 
@@ -1979,6 +1984,13 @@ class ImageViewerWindow(QMainWindow):
1979
1984
  except:
1980
1985
  pass
1981
1986
 
1987
+
1988
+ if my_network.network is not None:
1989
+ try:
1990
+ info_dict['Neighbors'] = list(my_network.network.neighbors(label))
1991
+ except:
1992
+ pass
1993
+
1982
1994
  if my_network.communities is not None:
1983
1995
  try:
1984
1996
  info_dict['Community'] = my_network.communities[label]
@@ -2012,6 +2024,24 @@ class ImageViewerWindow(QMainWindow):
2012
2024
 
2013
2025
  info_dict['Object Class'] = 'Edge'
2014
2026
 
2027
+ try:
2028
+ # Get the existing DataFrame from the model
2029
+ original_df = self.network_table.model()._data
2030
+
2031
+ # Create mask for rows where one column is any original node AND the other column is any neighbor
2032
+ mask = (
2033
+ (original_df.iloc[:, 0].isin(self.clicked_values['nodes'])) |
2034
+ (original_df.iloc[:, 1].isin(self.clicked_values['nodes'])) |
2035
+ (original_df.iloc[:, 2].isin(self.clicked_values['edges']))
2036
+ )
2037
+
2038
+ filtered_df = original_df[mask].copy()
2039
+ node_list = list(set(filtered_df.iloc[:, 0].to_list() + filtered_df.iloc[:, 1].to_list()))
2040
+ info_dict["Num Nodes"] = len(node_list)
2041
+ info_dict['Nodes'] = node_list
2042
+ except:
2043
+ pass
2044
+
2015
2045
  if my_network.edge_centroids is not None:
2016
2046
  try:
2017
2047
  info_dict['Centroid'] = my_network.edge_centroids[label]
@@ -2440,19 +2470,18 @@ class ImageViewerWindow(QMainWindow):
2440
2470
  print(f"Error: {e}")
2441
2471
 
2442
2472
 
2443
- def toggle_chan_load(self):
2473
+ def home(self):
2474
+
2475
+ self.update_display()
2476
+
2444
2477
 
2445
- if self.show_channels.isChecked():
2446
- self.chan_load = True
2447
- else:
2448
- self.chan_load = False
2449
2478
 
2450
2479
  def toggle_highlight(self):
2451
2480
  self.highlight = self.high_button.isChecked()
2452
2481
  current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2453
2482
  current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2454
2483
 
2455
- if self.high_button.isChecked() and self.machine_window is None:
2484
+ if self.high_button.isChecked() and self.machine_window is None and not self.preview:
2456
2485
  if self.highlight_overlay is None and ((len(self.clicked_values['nodes']) + len(self.clicked_values['edges'])) > 0):
2457
2486
  if self.needs_mini:
2458
2487
  self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
@@ -2471,7 +2500,8 @@ class ImageViewerWindow(QMainWindow):
2471
2500
  self.zoom_mode = self.zoom_button.isChecked()
2472
2501
 
2473
2502
  if self.zoom_mode:
2474
- self.pan_button.setChecked(False)
2503
+ if self.pan_mode:
2504
+ self.pan_button.click()
2475
2505
 
2476
2506
  self.pen_button.setChecked(False)
2477
2507
  self.brush_mode = False
@@ -2547,33 +2577,23 @@ class ImageViewerWindow(QMainWindow):
2547
2577
  current_xlim = self.ax.get_xlim()
2548
2578
  current_ylim = self.ax.get_ylim()
2549
2579
 
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:
2580
+ # Create static background from currently visible channels
2581
+ self.create_pan_background()
2582
+
2583
+ # Hide all channels and show only the background
2584
+ self.channel_visible = [False] * 4
2585
+ self.is_pan_preview = True
2586
+
2587
+ # Get current downsample factor
2588
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2589
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2590
+ # Update display to show only background
2591
+ self.update_display_pan_mode(current_xlim, current_ylim)
2551
2592
 
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)
2572
2593
 
2573
2594
  else:
2574
- current_xlim = self.ax.get_xlim()
2575
- current_ylim = self.ax.get_ylim()
2576
- self.update_display(preserve_zoom=(current_xlim, current_ylim))
2595
+ self.setEnabled(True)
2596
+ self.update_display(preserve_zoom=(self.ax.get_xlim(), self.ax.get_ylim()))
2577
2597
  if self.machine_window is None:
2578
2598
  self.canvas.setCursor(Qt.CursorShape.ArrowCursor)
2579
2599
  else:
@@ -2584,6 +2604,9 @@ class ImageViewerWindow(QMainWindow):
2584
2604
  self.brush_mode = self.pen_button.isChecked()
2585
2605
  if self.brush_mode:
2586
2606
 
2607
+ if self.pan_mode:
2608
+ self.pan_button.click()
2609
+
2587
2610
  self.pm = painting.PaintManager(parent = self)
2588
2611
 
2589
2612
  # Start virtual paint session
@@ -3069,14 +3092,7 @@ class ImageViewerWindow(QMainWindow):
3069
3092
  self.ax.set_xlim(new_xlim)
3070
3093
  self.ax.set_ylim(new_ylim)
3071
3094
 
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))
3095
+ self.canvas.draw_idle()
3080
3096
 
3081
3097
  # Update pan start position
3082
3098
  self.pan_start = (event.xdata, event.ydata)
@@ -3105,18 +3121,36 @@ class ImageViewerWindow(QMainWindow):
3105
3121
 
3106
3122
 
3107
3123
  def create_pan_background(self):
3108
- """Create a static background image from currently visible channels with proper rendering"""
3109
- # Store current zoom state
3110
- current_xlim = self.ax.get_xlim()
3111
- current_ylim = self.ax.get_ylim()
3112
-
3113
- # Render all visible channels with proper colors/brightness into a single composite
3114
- self.pan_background_image = self.create_composite_for_pan()
3115
- self.pan_zoom_state = (current_xlim, current_ylim)
3116
-
3124
+ """Create a static background image from currently visible channels with proper rendering"""
3125
+ # Store current zoom state
3126
+ current_xlim = self.ax.get_xlim()
3127
+ current_ylim = self.ax.get_ylim()
3128
+
3129
+ # Try GPU acceleration first, fallback to CPU
3130
+ try:
3131
+ import cupy as cp
3132
+ self.use_gpu = True
3133
+ except ImportError:
3134
+ self.use_gpu = False
3135
+
3136
+ # Render all visible channels with proper colors/brightness into a single composite
3137
+ self.channel_visible = self.pre_pan_channel_state.copy()
3138
+ try:
3139
+ if self.use_gpu:
3140
+ self.pan_background_image = self.create_composite_for_pan_gpu()
3141
+ else:
3142
+ self.pan_background_image = self.create_composite_for_pan()
3143
+ except Exception as e:
3144
+ print(f'GPU implementation failed: {e}, falling back to CPU')
3145
+ self.use_gpu = False
3146
+ self.pan_background_image = self.create_composite_for_pan()
3147
+
3148
+ self.pan_zoom_state = (current_xlim, current_ylim)
3117
3149
 
3118
- def create_composite_for_pan(self):
3119
- """Create a properly rendered composite image for panning with downsample support"""
3150
+ def create_composite_for_pan_gpu(self):
3151
+ """Create a properly rendered composite image for panning with GPU acceleration"""
3152
+ import cupy as cp
3153
+
3120
3154
  # Get active channels and dimensions (copied from update_display)
3121
3155
  active_channels = [i for i in range(4) if self.channel_data[i] is not None]
3122
3156
  if active_channels:
@@ -3134,6 +3168,28 @@ class ImageViewerWindow(QMainWindow):
3134
3168
  current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3135
3169
  current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3136
3170
 
3171
+ # Helper function to crop and downsample image on GPU
3172
+ def crop_and_downsample_image_gpu(image_cp, y_start, y_end, x_start, x_end, factor):
3173
+ # Crop first
3174
+ if len(image_cp.shape) == 2:
3175
+ cropped = image_cp[y_start:y_end, x_start:x_end]
3176
+ elif len(image_cp.shape) == 3:
3177
+ cropped = image_cp[y_start:y_end, x_start:x_end, :]
3178
+ else:
3179
+ cropped = image_cp
3180
+
3181
+ # Then downsample if needed
3182
+ if factor == 1:
3183
+ return cropped
3184
+
3185
+ if len(cropped.shape) == 2:
3186
+ return cropped[::factor, ::factor]
3187
+ elif len(cropped.shape) == 3:
3188
+ return cropped[::factor, ::factor, :]
3189
+ else:
3190
+ return cropped
3191
+
3192
+ min_height, min_width = self.original_dims
3137
3193
 
3138
3194
  # Calculate the visible region in pixel coordinates
3139
3195
  x_min = max(0, int(np.floor(current_xlim[0] + 0.5)))
@@ -3141,41 +3197,281 @@ class ImageViewerWindow(QMainWindow):
3141
3197
  y_min = max(0, int(np.floor(current_ylim[1] + 0.5))) # Note: y is flipped
3142
3198
  y_max = min(min_height, int(np.ceil(current_ylim[0] + 0.5)))
3143
3199
 
3144
- box_len = x_max - x_min
3145
- box_height = y_max - y_min
3200
+ box_len = int((x_max - x_min)/2)
3201
+ box_height = int((y_max - y_min)/2)
3146
3202
  x_min = max(0, x_min - box_len)
3147
3203
  x_max = min(self.shape[2], x_max + box_len)
3148
3204
  y_min = max(0, y_min - box_height)
3149
3205
  y_max = min(self.shape[1], y_max + box_height)
3150
3206
 
3151
- # If using image pyramids
3152
3207
  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
-
3208
+ val = int(np.ceil(size/(2000 * 2000)))
3157
3209
  self.validate_downsample_input(text = val, update = False)
3158
3210
 
3159
3211
  downsample_factor = self.downsample_factor
3160
3212
 
3161
- # Calculate display dimensions (downsampled)
3162
- display_height = min_height // downsample_factor
3163
- display_width = min_width // downsample_factor
3213
+ # Add some padding to avoid edge artifacts during pan/zoom
3214
+ padding = max(10, downsample_factor * 2)
3215
+ x_min_padded = max(0, x_min - padding)
3216
+ x_max_padded = min(min_width, x_max + padding)
3217
+ y_min_padded = max(0, y_min - padding)
3218
+ y_max_padded = min(min_height, y_max + padding)
3219
+
3220
+ display_height = (y_max_padded - y_min_padded) // downsample_factor
3221
+ display_width = (x_max_padded - x_min_padded) // downsample_factor
3164
3222
 
3165
- # Helper function to downsample image (same as in update_display)
3166
- def downsample_image(image, factor):
3167
- if factor == 1:
3168
- return image
3223
+ # Create a blank RGBA composite to accumulate all channels (using display dimensions) - on GPU
3224
+ composite = cp.zeros((display_height, display_width, 4), dtype=cp.float32)
3225
+
3226
+ # Process each visible channel exactly like update_display does
3227
+ for channel in range(4):
3228
+ if (self.channel_visible[channel] and
3229
+ self.channel_data[channel] is not None):
3230
+
3231
+ # Get current slice data (same logic as update_display)
3232
+ 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)
3233
+
3234
+ if len(self.channel_data[channel].shape) == 3 and not is_rgb:
3235
+ current_image = self.channel_data[channel][self.current_slice, :, :]
3236
+ elif is_rgb:
3237
+ current_image = self.channel_data[channel][self.current_slice]
3238
+ else:
3239
+ current_image = self.channel_data[channel]
3240
+
3241
+ # Convert to CuPy array and crop/downsample on GPU
3242
+ current_image_cp = cp.asarray(current_image)
3243
+ display_image_cp = crop_and_downsample_image_gpu(
3244
+ current_image_cp, y_min_padded, y_max_padded,
3245
+ x_min_padded, x_max_padded, downsample_factor)
3246
+
3247
+ if is_rgb and self.channel_data[channel].shape[-1] == 3:
3248
+ # RGB image - convert to RGBA and blend
3249
+ rgb_alpha = cp.ones((*display_image_cp.shape[:2], 4), dtype=cp.float32)
3250
+ rgb_alpha[:, :, :3] = display_image_cp.astype(cp.float32) / 255.0
3251
+ rgb_alpha[:, :, 3] = 0.7 # Same alpha as update_display
3252
+ composite = self.blend_layers_gpu(composite, rgb_alpha)
3253
+
3254
+ elif is_rgb and self.channel_data[channel].shape[-1] == 4:
3255
+ # RGBA image - blend directly
3256
+ rgba_image = display_image_cp.astype(cp.float32) / 255.0
3257
+ composite = self.blend_layers_gpu(composite, rgba_image)
3258
+
3259
+ else:
3260
+ # Regular channel processing (same logic as update_display)
3261
+ if self.min_max[channel][0] == None:
3262
+ self.min_max[channel][0] = cp.asnumpy(cp.min(current_image_cp))
3263
+ if self.min_max[channel][1] == None:
3264
+ self.min_max[channel][1] = cp.asnumpy(cp.max(current_image_cp))
3265
+
3266
+ img_min = self.min_max[channel][0]
3267
+ img_max = self.min_max[channel][1]
3268
+
3269
+ if img_min == img_max:
3270
+ vmin = img_min
3271
+ vmax = img_min + 1
3272
+ else:
3273
+ vmin = img_min + (img_max - img_min) * self.channel_brightness[channel]['min']
3274
+ vmax = img_min + (img_max - img_min) * self.channel_brightness[channel]['max']
3275
+
3276
+ # Normalize the downsampled image on GPU
3277
+ if vmin == vmax:
3278
+ normalized_image = cp.zeros_like(display_image_cp)
3279
+ else:
3280
+ normalized_image = cp.clip((display_image_cp - vmin) / (vmax - vmin), 0, 1)
3281
+
3282
+ # Apply channel color and alpha
3283
+ if channel == 2 and self.machine_window is not None:
3284
+ # Special case for machine window channel 2
3285
+ channel_rgba = self.apply_machine_colormap_gpu(display_image_cp)
3286
+ else:
3287
+ # Regular channel with custom color
3288
+ color = self.base_colors[channel]
3289
+ channel_rgba = cp.zeros((*normalized_image.shape, 4), dtype=cp.float32)
3290
+ channel_rgba[:, :, 0] = normalized_image * color[0] # R
3291
+ channel_rgba[:, :, 1] = normalized_image * color[1] # G
3292
+ channel_rgba[:, :, 2] = normalized_image * color[2] # B
3293
+ channel_rgba[:, :, 3] = normalized_image * 0.7 # A (same alpha as update_display)
3294
+
3295
+ # Blend this channel into the composite
3296
+ composite = self.blend_layers_gpu(composite, channel_rgba)
3297
+
3298
+ # Add highlight overlays if they exist (with downsampling)
3299
+ if self.mini_overlay and self.highlight and self.machine_window is None:
3300
+ mini_overlay_cp = cp.asarray(self.mini_overlay_data)
3301
+ display_overlay = crop_and_downsample_image_gpu(mini_overlay_cp, y_min_padded, y_max_padded, x_min_padded, x_max_padded, downsample_factor)
3302
+ highlight_rgba = self.create_highlight_rgba_gpu(display_overlay, yellow=True)
3303
+ composite = self.blend_layers_gpu(composite, highlight_rgba)
3304
+ elif self.highlight_overlay is not None and self.highlight:
3305
+ highlight_slice = self.highlight_overlay[self.current_slice]
3306
+ highlight_slice_cp = cp.asarray(highlight_slice)
3307
+ display_highlight = crop_and_downsample_image_gpu(highlight_slice_cp, y_min_padded, y_max_padded, x_min_padded, x_max_padded, downsample_factor)
3308
+ if self.machine_window is None:
3309
+ highlight_rgba = self.create_highlight_rgba_gpu(display_highlight, yellow=True)
3310
+ else:
3311
+ highlight_rgba = self.create_highlight_rgba_gpu(display_highlight, yellow=False)
3312
+ composite = self.blend_layers_gpu(composite, highlight_rgba)
3313
+
3314
+ # Convert back to CPU and to 0-255 range for display
3315
+ composite_cpu = cp.asnumpy(composite)
3316
+ return (composite_cpu * 255).astype(np.uint8)
3317
+
3318
+ def apply_machine_colormap_gpu(self, image_cp):
3319
+ """Apply the special machine window colormap for channel 2 - GPU version"""
3320
+ import cupy as cp
3321
+
3322
+ rgba = cp.zeros((*image_cp.shape, 4), dtype=cp.float32)
3323
+
3324
+ # Transparent for 0
3325
+ mask_0 = (image_cp == 0)
3326
+ rgba[mask_0] = cp.array([0, 0, 0, 0])
3327
+
3328
+ # Light green for 1
3329
+ mask_1 = (image_cp == 1)
3330
+ rgba[mask_1] = cp.array([0.5, 1, 0.5, 0.7])
3331
+
3332
+ # Light red for 2
3333
+ mask_2 = (image_cp == 2)
3334
+ rgba[mask_2] = cp.array([1, 0.5, 0.5, 0.7])
3335
+
3336
+ return rgba
3337
+
3338
+ def create_highlight_rgba_gpu(self, highlight_data_cp, yellow=True):
3339
+ """Create RGBA highlight overlay - GPU version"""
3340
+ import cupy as cp
3341
+
3342
+ rgba = cp.zeros((*highlight_data_cp.shape, 4), dtype=cp.float32)
3343
+
3344
+ if yellow:
3345
+ # Yellow highlight
3346
+ mask = highlight_data_cp > 0
3347
+ rgba[mask] = cp.array([1, 1, 0, 0.8]) # Yellow with alpha 0.8
3348
+ else:
3349
+ # Multi-color highlight for machine window
3350
+ mask_1 = (highlight_data_cp == 1)
3351
+ mask_2 = (highlight_data_cp == 2)
3352
+ rgba[mask_1] = cp.array([1, 1, 0, 0.5]) # Yellow for 1
3353
+ rgba[mask_2] = cp.array([0, 0.7, 1, 0.5]) # Blue for 2
3354
+
3355
+ return rgba
3356
+
3357
+ def blend_layers_gpu(self, base_cp, overlay_cp):
3358
+ """Alpha blend two RGBA layers - GPU version"""
3359
+ import cupy as cp
3360
+
3361
+ def resize_overlay_to_base_gpu(overlay_arr_cp, base_arr_cp):
3362
+ base_height, base_width = base_arr_cp.shape[:2]
3363
+ overlay_height, overlay_width = overlay_arr_cp.shape[:2]
3364
+
3365
+ # First crop if overlay is larger
3366
+ cropped_overlay = overlay_arr_cp[:base_height, :base_width]
3367
+
3368
+ # Then pad if still smaller after cropping
3369
+ current_height, current_width = cropped_overlay.shape[:2]
3370
+ pad_height = base_height - current_height
3371
+ pad_width = base_width - current_width
3372
+
3373
+ if pad_height > 0 or pad_width > 0:
3374
+ cropped_overlay = cp.pad(cropped_overlay,
3375
+ ((0, pad_height), (0, pad_width), (0, 0)),
3376
+ mode='constant', constant_values=0)
3169
3377
 
3170
- # Handle different image types
3378
+ return cropped_overlay
3379
+
3380
+ # Resize the ENTIRE overlay array to match base dimensions
3381
+ if overlay_cp.shape[:2] != base_cp.shape[:2]:
3382
+ overlay_cp = resize_overlay_to_base_gpu(overlay_cp, base_cp)
3383
+
3384
+ # Now extract alpha channels (they should be the same size)
3385
+ alpha_overlay = overlay_cp[:, :, 3:4]
3386
+ alpha_base = base_cp[:, :, 3:4]
3387
+
3388
+ # Calculate output alpha
3389
+ alpha_out = alpha_overlay + alpha_base * (1 - alpha_overlay)
3390
+
3391
+ # Calculate output RGB
3392
+ rgb_out = cp.zeros_like(base_cp[:, :, :3])
3393
+ mask = alpha_out[:, :, 0] > 0
3394
+
3395
+ rgb_out[mask] = (overlay_cp[mask, :3] * alpha_overlay[mask] +
3396
+ base_cp[mask, :3] * alpha_base[mask] * (1 - alpha_overlay[mask])) / alpha_out[mask]
3397
+
3398
+ # Combine RGB and alpha
3399
+ result = cp.zeros_like(base_cp)
3400
+ result[:, :, :3] = rgb_out
3401
+ result[:, :, 3:4] = alpha_out
3402
+
3403
+ return result
3404
+
3405
+ def create_composite_for_pan(self):
3406
+ """Create a properly rendered composite image for panning with downsample support"""
3407
+ # Get active channels and dimensions (copied from update_display)
3408
+ active_channels = [i for i in range(4) if self.channel_data[i] is not None]
3409
+ if active_channels:
3410
+ dims = [(self.channel_data[i].shape[1:3] if len(self.channel_data[i].shape) >= 3 else
3411
+ self.channel_data[i].shape) for i in active_channels]
3412
+ min_height = min(d[0] for d in dims)
3413
+ min_width = min(d[1] for d in dims)
3414
+ else:
3415
+ return None
3416
+
3417
+ # Store original dimensions for coordinate mapping
3418
+ self.original_dims = (min_height, min_width)
3419
+
3420
+ # Get current downsample factor
3421
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3422
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3423
+
3424
+ # Helper function to crop and downsample image
3425
+ def crop_and_downsample_image(image, y_start, y_end, x_start, x_end, factor):
3426
+ # Crop first
3171
3427
  if len(image.shape) == 2:
3172
- # Grayscale
3173
- return image[::factor, ::factor]
3428
+ cropped = image[y_start:y_end, x_start:x_end]
3174
3429
  elif len(image.shape) == 3:
3175
- # RGB/RGBA
3176
- return image[::factor, ::factor, :]
3430
+ cropped = image[y_start:y_end, x_start:x_end, :]
3177
3431
  else:
3178
- return image
3432
+ cropped = image
3433
+
3434
+ # Then downsample if needed
3435
+ if factor == 1:
3436
+ return cropped
3437
+
3438
+ if len(cropped.shape) == 2:
3439
+ return cropped[::factor, ::factor]
3440
+ elif len(cropped.shape) == 3:
3441
+ return cropped[::factor, ::factor, :]
3442
+ else:
3443
+ return cropped
3444
+
3445
+ min_height, min_width = self.original_dims
3446
+
3447
+ # Calculate the visible region in pixel coordinates
3448
+ x_min = max(0, int(np.floor(current_xlim[0] + 0.5)))
3449
+ x_max = min(min_width, int(np.ceil(current_xlim[1] + 0.5)))
3450
+ y_min = max(0, int(np.floor(current_ylim[1] + 0.5))) # Note: y is flipped
3451
+ y_max = min(min_height, int(np.ceil(current_ylim[0] + 0.5)))
3452
+
3453
+ box_len = int((x_max - x_min)/2)
3454
+ box_height = int((y_max - y_min)/2)
3455
+ x_min = max(0, x_min - box_len)
3456
+ x_max = min(self.shape[2], x_max + box_len)
3457
+ y_min = max(0, y_min - box_height)
3458
+ y_max = min(self.shape[1], y_max + box_height)
3459
+
3460
+ size = (x_max - x_min) * (y_max - y_min)
3461
+ val = max(2, int(np.ceil(size/(2000 * 2000))))
3462
+ self.validate_downsample_input(text = val, update = False)
3463
+
3464
+ downsample_factor = self.downsample_factor
3465
+
3466
+ # Add some padding to avoid edge artifacts during pan/zoom
3467
+ padding = max(10, downsample_factor * 2)
3468
+ x_min_padded = max(0, x_min - padding)
3469
+ x_max_padded = min(min_width, x_max + padding)
3470
+ y_min_padded = max(0, y_min - padding)
3471
+ y_max_padded = min(min_height, y_max + padding)
3472
+
3473
+ display_height = (y_max_padded - y_min_padded) // downsample_factor
3474
+ display_width = (x_max_padded - x_min_padded) // downsample_factor
3179
3475
 
3180
3476
  # Create a blank RGBA composite to accumulate all channels (using display dimensions)
3181
3477
  composite = np.zeros((display_height, display_width, 4), dtype=np.float32)
@@ -3195,8 +3491,10 @@ class ImageViewerWindow(QMainWindow):
3195
3491
  else:
3196
3492
  current_image = self.channel_data[channel]
3197
3493
 
3198
- # Downsample the image for rendering
3199
- display_image = downsample_image(current_image, downsample_factor)
3494
+ # Crop and downsample the image for rendering
3495
+ display_image = crop_and_downsample_image(
3496
+ current_image, y_min_padded, y_max_padded,
3497
+ x_min_padded, x_max_padded, downsample_factor)
3200
3498
 
3201
3499
  if is_rgb and self.channel_data[channel].shape[-1] == 3:
3202
3500
  # RGB image - convert to RGBA and blend
@@ -3251,12 +3549,12 @@ class ImageViewerWindow(QMainWindow):
3251
3549
 
3252
3550
  # Add highlight overlays if they exist (with downsampling)
3253
3551
  if self.mini_overlay and self.highlight and self.machine_window is None:
3254
- display_overlay = downsample_image(self.mini_overlay_data, downsample_factor)
3552
+ display_overlay = crop_and_downsample_image(self.mini_overlay_data, y_min_padded, y_max_padded, x_min_padded, x_max_padded, downsample_factor)
3255
3553
  highlight_rgba = self.create_highlight_rgba(display_overlay, yellow=True)
3256
3554
  composite = self.blend_layers(composite, highlight_rgba)
3257
3555
  elif self.highlight_overlay is not None and self.highlight:
3258
3556
  highlight_slice = self.highlight_overlay[self.current_slice]
3259
- display_highlight = downsample_image(highlight_slice, downsample_factor)
3557
+ display_highlight = crop_and_downsample_image(highlight_slice, y_min_padded, y_max_padded, x_min_padded, x_max_padded, downsample_factor)
3260
3558
  if self.machine_window is None:
3261
3559
  highlight_rgba = self.create_highlight_rgba(display_highlight, yellow=True)
3262
3560
  else:
@@ -3266,7 +3564,6 @@ class ImageViewerWindow(QMainWindow):
3266
3564
  # Convert to 0-255 range for display
3267
3565
  return (composite * 255).astype(np.uint8)
3268
3566
 
3269
-
3270
3567
  def apply_machine_colormap(self, image):
3271
3568
  """Apply the special machine window colormap for channel 2"""
3272
3569
  rgba = np.zeros((*image.shape, 4), dtype=np.float32)
@@ -3368,18 +3665,6 @@ class ImageViewerWindow(QMainWindow):
3368
3665
  height *= downsample_factor
3369
3666
  width *= downsample_factor
3370
3667
 
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
3668
  downsample_factor = self.downsample_factor
3384
3669
  min_height, min_width = self.original_dims
3385
3670
 
@@ -3389,8 +3674,8 @@ class ImageViewerWindow(QMainWindow):
3389
3674
  y_min = max(0, int(np.floor(current_ylim[1] + 0.5))) # Note: y is flipped
3390
3675
  y_max = min(min_height, int(np.ceil(current_ylim[0] + 0.5)))
3391
3676
 
3392
- box_len = x_max - x_min
3393
- box_height = y_max - y_min
3677
+ box_len = int((x_max - x_min)/2)
3678
+ box_height = int((y_max - y_min)/2)
3394
3679
  x_min = max(0, x_min - box_len)
3395
3680
  x_max = min(self.shape[2], x_max + box_len)
3396
3681
  y_min = max(0, y_min - box_height)
@@ -3418,11 +3703,6 @@ class ImageViewerWindow(QMainWindow):
3418
3703
  x_max_padded_ds = min(self.pan_background_image.shape[1], x_max_ds + padding_ds)
3419
3704
  y_min_padded_ds = max(0, y_min_ds - padding_ds)
3420
3705
  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
3706
 
3427
3707
  # Calculate the extent for the cropped region (in original coordinates)
3428
3708
  crop_extent = (x_min_padded - 0.5, x_max_padded - 0.5,
@@ -3430,7 +3710,7 @@ class ImageViewerWindow(QMainWindow):
3430
3710
 
3431
3711
  # Display the composite background with preserved zoom
3432
3712
  # Use extent to stretch downsampled image back to original coordinate space
3433
- self.ax.imshow(display_image,
3713
+ self.ax.imshow(self.pan_background_image,
3434
3714
  extent=crop_extent,
3435
3715
  aspect='equal')
3436
3716
 
@@ -3490,11 +3770,17 @@ class ImageViewerWindow(QMainWindow):
3490
3770
  # Update display to show only background
3491
3771
  current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3492
3772
  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:
3773
+ #self.update_display(preserve_zoom = (current_xlim, current_ylim))
3774
+ self.setEnabled(False)
3775
+
3776
+ try:
3777
+ self.create_pan_background()
3778
+ current_xlim = self.ax.get_xlim()
3779
+ current_ylim = self.ax.get_ylim()
3495
3780
  self.update_display_pan_mode(current_xlim, current_ylim)
3496
- else:
3497
- self.update_display(preserve_zoom=(current_xlim, current_ylim))
3781
+ finally:
3782
+ # Re-enable the widget when done
3783
+ self.setEnabled(True)
3498
3784
 
3499
3785
  self.panning = False
3500
3786
  self.pan_start = None
@@ -3561,8 +3847,9 @@ class ImageViewerWindow(QMainWindow):
3561
3847
  self.create_highlight_overlay(node_indices=self.clicked_values['nodes'])
3562
3848
 
3563
3849
  # Try to highlight the last selected value in tables
3564
- if self.clicked_values['nodes']:
3850
+ if len(self.clicked_values['nodes']) == 1:
3565
3851
  self.highlight_value_in_tables(self.clicked_values['nodes'][-1])
3852
+ self.handle_info('node')
3566
3853
 
3567
3854
  elif self.active_channel == 1: # Edges
3568
3855
  if not ctrl_pressed:
@@ -3577,8 +3864,9 @@ class ImageViewerWindow(QMainWindow):
3577
3864
  self.create_highlight_overlay(edge_indices=self.clicked_values['edges'])
3578
3865
 
3579
3866
  # Try to highlight the last selected value in tables
3580
- if self.clicked_values['edges']:
3867
+ if len(self.clicked_values['edges']):
3581
3868
  self.highlight_value_in_tables(self.clicked_values['edges'][-1])
3869
+ self.handle_info('edge')
3582
3870
 
3583
3871
  elif not self.selecting and self.selection_start: # If we had a click but never started selection
3584
3872
  # Handle as a normal click
@@ -3957,9 +4245,10 @@ class ImageViewerWindow(QMainWindow):
3957
4245
  load_action.triggered.connect(lambda: self.load_misc('Edge Centroids'))
3958
4246
  load_action = misc_menu.addAction("Load Node Communities")
3959
4247
  load_action.triggered.connect(lambda: self.load_misc('Communities'))
3960
- load_action = misc_menu.addAction("Merge Nodes")
4248
+ node_identities = file_menu.addMenu('Images -> Node Identities')
4249
+ load_action = node_identities.addAction("Merge Labeled Images Into Nodes")
3961
4250
  load_action.triggered.connect(lambda: self.load_misc('Merge Nodes'))
3962
- load_action = misc_menu.addAction("Merge Node IDs from Images")
4251
+ load_action = node_identities.addAction("Assign Node Identities From Overlap With Other Images")
3963
4252
  load_action.triggered.connect(self.show_merge_node_id_dialog)
3964
4253
 
3965
4254
 
@@ -4012,8 +4301,6 @@ class ImageViewerWindow(QMainWindow):
4012
4301
  id_code_action.triggered.connect(lambda: self.show_code_dialog(sort = 'Identity'))
4013
4302
  umap_action = overlay_menu.addAction("Centroid UMAP")
4014
4303
  umap_action.triggered.connect(self.handle_centroid_umap)
4015
- iden_umap_action = overlay_menu.addAction("Identity UMAP (If any nodes were assigned multiple identities)")
4016
- iden_umap_action.triggered.connect(self.handle_iden_umap)
4017
4304
 
4018
4305
  rand_menu = analysis_menu.addMenu("Randomize")
4019
4306
  random_action = rand_menu.addAction("Generate Equivalent Random Network")
@@ -4080,10 +4367,10 @@ class ImageViewerWindow(QMainWindow):
4080
4367
  gennodes_action.triggered.connect(self.show_gennodes_dialog)
4081
4368
  branch_action = generate_menu.addAction("Label Branches")
4082
4369
  branch_action.triggered.connect(lambda: self.show_branch_dialog())
4083
- genvor_action = generate_menu.addAction("Generate Voronoi Diagram (From Node Centroids) - goes in Overlay2")
4370
+ genvor_action = generate_menu.addAction("Generate Voronoi Diagram - goes in Overlay2")
4084
4371
  genvor_action.triggered.connect(self.voronoi)
4085
4372
 
4086
- modify_action = process_menu.addAction("Modify Network")
4373
+ modify_action = process_menu.addAction("Modify Network/Properties")
4087
4374
  modify_action.triggered.connect(self.show_modify_dialog)
4088
4375
 
4089
4376
 
@@ -4102,8 +4389,6 @@ class ImageViewerWindow(QMainWindow):
4102
4389
  idoverlay_action.triggered.connect(self.show_idoverlay_dialog)
4103
4390
  coloroverlay_action = overlay_menu.addAction("Color Nodes (or Edges)")
4104
4391
  coloroverlay_action.triggered.connect(self.show_coloroverlay_dialog)
4105
- #searchoverlay_action = overlay_menu.addAction("Show Search Regions")
4106
- #searchoverlay_action.triggered.connect(self.show_search_dialog)
4107
4392
  shuffle_action = overlay_menu.addAction("Shuffle")
4108
4393
  shuffle_action.triggered.connect(self.show_shuffle_dialog)
4109
4394
  arbitrary_action = image_menu.addAction("Select Objects")
@@ -4433,6 +4718,12 @@ class ImageViewerWindow(QMainWindow):
4433
4718
  # Create new table
4434
4719
  table = CustomTableView(self)
4435
4720
  table.setModel(PandasModel(df))
4721
+
4722
+ try:
4723
+ first_column_name = table.model()._data.columns[0]
4724
+ table.sort_table(first_column_name, ascending=True)
4725
+ except:
4726
+ pass
4436
4727
 
4437
4728
  # Add to tabbed widget
4438
4729
  if title is None:
@@ -4440,17 +4731,27 @@ class ImageViewerWindow(QMainWindow):
4440
4731
  else:
4441
4732
  self.tabbed_data.add_table(f"{title}", table)
4442
4733
 
4734
+
4735
+
4443
4736
  # Adjust column widths to content
4444
4737
  for column in range(table.model().columnCount(None)):
4445
4738
  table.resizeColumnToContents(column)
4446
4739
 
4447
4740
  except:
4448
- pass
4741
+ pass
4449
4742
 
4450
4743
  def show_merge_node_id_dialog(self):
4451
4744
 
4452
- dialog = MergeNodeIdDialog(self)
4453
- dialog.exec()
4745
+ if my_network.nodes is None:
4746
+ QMessageBox.critical(
4747
+ self,
4748
+ "Error",
4749
+ "Please load your segmented cells into 'Nodes' channel first"
4750
+ )
4751
+ return
4752
+ else:
4753
+ dialog = MergeNodeIdDialog(self)
4754
+ dialog.exec()
4454
4755
 
4455
4756
  def show_gray_water_dialog(self):
4456
4757
  """Show the gray watershed parameter dialog."""
@@ -4686,18 +4987,7 @@ class ImageViewerWindow(QMainWindow):
4686
4987
 
4687
4988
  try:
4688
4989
 
4689
- if my_network.nodes is not None:
4690
- shape = my_network.nodes.shape
4691
- else:
4692
- shape = None
4693
-
4694
- if my_network.node_centroids is None:
4695
- self.show_centroid_dialog()
4696
- if my_network.node_centroids is None:
4697
- print("Node centroids must be set")
4698
- return
4699
-
4700
- array = pxt.create_voronoi_3d_kdtree(my_network.node_centroids, shape)
4990
+ array = sdl.smart_dilate(self.channel_data[self.active_channel], dilate_xy = np.max(self.shape), dilate_z = np.max(self.shape), use_dt_dil_amount = np.max(self.shape), fast_dil = False)
4701
4991
  self.load_channel(3, array, True)
4702
4992
 
4703
4993
  except Exception as e:
@@ -4753,10 +5043,6 @@ class ImageViewerWindow(QMainWindow):
4753
5043
  dialog = ColorOverlayDialog(self)
4754
5044
  dialog.exec()
4755
5045
 
4756
- def show_search_dialog(self):
4757
- """Show the search dialog"""
4758
- dialog = SearchOverlayDialog(self)
4759
- dialog.exec()
4760
5046
 
4761
5047
  def show_shuffle_dialog(self):
4762
5048
  """Show the shuffle dialog"""
@@ -4832,29 +5118,6 @@ class ImageViewerWindow(QMainWindow):
4832
5118
  if sort == 'Node Identities':
4833
5119
  my_network.load_node_identities(file_path = filename)
4834
5120
 
4835
- """
4836
- first_value = list(my_network.node_identities.values())[0] # Check that there are not multiple IDs
4837
- if isinstance(first_value, (list, tuple)):
4838
- trump_value, ok = QInputDialog.getText(
4839
- self,
4840
- 'Multiple IDs Detected',
4841
- 'The node identities appear to contain multiple ids per node in a list.\n'
4842
- 'If you desire one node ID to trump all others, enter it here.\n'
4843
- '(Enter "-" to have the first IDs trump all others)\n'
4844
- '(Enter "/" to have multi-ID nodes be split into many nodes sharing a centroid)\n'
4845
- '(Close this window to continue with multi-ID nodes)'
4846
- )
4847
- if not ok or trump_value.strip() == '':
4848
- trump_value = None
4849
- elif trump_value.upper() == '-':
4850
- trump_value = '-'
4851
- elif trump_value.upper() == "/":
4852
- trump_value = '/'
4853
- my_network.node_identities = uncork(my_network.node_identities, trump_value)
4854
- else:
4855
- trump_value = None
4856
- my_network.node_identities = uncork(my_network.node_identities, trump_value)
4857
- """
4858
5121
 
4859
5122
  if hasattr(my_network, 'node_identities') and my_network.node_identities is not None:
4860
5123
  try:
@@ -4903,6 +5166,14 @@ class ImageViewerWindow(QMainWindow):
4903
5166
  elif sort == 'Merge Nodes':
4904
5167
  try:
4905
5168
 
5169
+ if my_network.nodes is None:
5170
+ QMessageBox.critical(
5171
+ self,
5172
+ "Error",
5173
+ "Please load your first set of nodes into the 'Nodes' channel first"
5174
+ )
5175
+ return
5176
+
4906
5177
  if len(np.unique(my_network.nodes)) < 3:
4907
5178
  self.show_label_dialog()
4908
5179
 
@@ -4916,6 +5187,21 @@ class ImageViewerWindow(QMainWindow):
4916
5187
 
4917
5188
  msg.exec()
4918
5189
 
5190
+ # Also if they want centroids:
5191
+ msg2 = QMessageBox()
5192
+ msg2.setWindowTitle("Selection Type")
5193
+ msg2.setText("Would you like to compute node centroids for each image prior to merging?")
5194
+ yes_button = msg2.addButton("Yes", QMessageBox.ButtonRole.AcceptRole)
5195
+ no_button = msg2.addButton("No", QMessageBox.ButtonRole.AcceptRole)
5196
+ msg2.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
5197
+
5198
+ msg2.exec()
5199
+
5200
+ if msg2.clickedButton() == yes_button:
5201
+ centroids = True
5202
+ else:
5203
+ centroids = False
5204
+
4919
5205
  if msg.clickedButton() == tiff_button:
4920
5206
  # Code for selecting TIFF files
4921
5207
  filename, _ = QFileDialog.getOpenFileName(
@@ -4938,7 +5224,7 @@ class ImageViewerWindow(QMainWindow):
4938
5224
  if dialog.exec() == QFileDialog.DialogCode.Accepted:
4939
5225
  selected_path = dialog.directory().absolutePath()
4940
5226
 
4941
- my_network.merge_nodes(selected_path, root_id = self.node_name)
5227
+ my_network.merge_nodes(selected_path, root_id = self.node_name, centroids = centroids)
4942
5228
  self.load_channel(0, my_network.nodes, True)
4943
5229
 
4944
5230
 
@@ -4947,8 +5233,12 @@ class ImageViewerWindow(QMainWindow):
4947
5233
  self.format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity', 'Node Identities')
4948
5234
  except Exception as e:
4949
5235
  print(f"Error loading node identity table: {e}")
5236
+ if centroids:
5237
+ self.format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
5238
+
4950
5239
 
4951
5240
  except Exception as e:
5241
+
4952
5242
  QMessageBox.critical(
4953
5243
  self,
4954
5244
  "Error Merging",
@@ -5448,12 +5738,8 @@ class ImageViewerWindow(QMainWindow):
5448
5738
  if all(not btn.isEnabled() for btn in self.channel_buttons[:channel_index]):
5449
5739
  self.set_active_channel(channel_index)
5450
5740
 
5451
- if self.chan_load:
5452
- if not self.channel_buttons[channel_index].isChecked():
5453
- self.channel_buttons[channel_index].click()
5454
- else:
5455
- if self.channel_buttons[channel_index].isChecked():
5456
- self.channel_buttons[channel_index].click()
5741
+ if not self.channel_buttons[channel_index].isChecked():
5742
+ self.channel_buttons[channel_index].click()
5457
5743
 
5458
5744
  self.min_max[channel_index][0] = np.min(self.channel_data[channel_index])
5459
5745
  self.min_max[channel_index][1] = np.max(self.channel_data[channel_index])
@@ -5470,6 +5756,8 @@ class ImageViewerWindow(QMainWindow):
5470
5756
  except:
5471
5757
  pass
5472
5758
 
5759
+ if self.shape == self.channel_data[channel_index].shape:
5760
+ preserve_zoom = (self.ax.get_xlim(), self.ax.get_ylim())
5473
5761
  self.shape = self.channel_data[channel_index].shape
5474
5762
  if self.shape[1] * self.shape[2] > 3000 * 3000 * self.downsample_factor:
5475
5763
  self.throttle = True
@@ -5559,9 +5847,9 @@ class ImageViewerWindow(QMainWindow):
5559
5847
 
5560
5848
  if update:
5561
5849
  # Update display
5562
- self.update_display()
5850
+ self.update_display(preserve_zoom = (self.ax.get_xlim(), self.ax.get_ylim()))
5563
5851
 
5564
- 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):
5852
+ 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, node_identities = False):
5565
5853
  """Method to flexibly reset certain fields to free up the RAM as desired"""
5566
5854
 
5567
5855
  # Set scales first before any clearing operations
@@ -5581,6 +5869,9 @@ class ImageViewerWindow(QMainWindow):
5581
5869
  # Clear selection table
5582
5870
  self.selection_table.setModel(PandasModel(empty_df))
5583
5871
 
5872
+ if node_identities:
5873
+ my_network.node_identities = None
5874
+
5584
5875
  if nodes:
5585
5876
  self.delete_channel(0, False, update = update)
5586
5877
 
@@ -5691,6 +5982,9 @@ class ImageViewerWindow(QMainWindow):
5691
5982
  def toggle_channel(self, channel_index):
5692
5983
  """Toggle visibility of a channel."""
5693
5984
  # Store current zoom settings before toggling
5985
+ if self.pan_mode:
5986
+ self.pan_button.click()
5987
+
5694
5988
  current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
5695
5989
  current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
5696
5990
 
@@ -5728,7 +6022,9 @@ class ImageViewerWindow(QMainWindow):
5728
6022
  # Now convert to real data
5729
6023
  self.pm.convert_virtual_strokes_to_data()
5730
6024
  self.current_slice = slice_value
5731
- if self.mini_overlay == True: #If we are rendering the highlight overlay for selected values one at a time.
6025
+ if self.preview:
6026
+ self.create_highlight_overlay_slice(self.targs, bounds=self.bounds)
6027
+ elif self.mini_overlay == True: #If we are rendering the highlight overlay for selected values one at a time.
5732
6028
  self.create_mini_overlay(node_indices = self.clicked_values['nodes'], edge_indices = self.clicked_values['edges'])
5733
6029
  if not self.hold_update:
5734
6030
  self.update_display(preserve_zoom=view_settings)
@@ -5763,9 +6059,9 @@ class ImageViewerWindow(QMainWindow):
5763
6059
  self.measurement_artists = []
5764
6060
  self.axes_initialized = False
5765
6061
  self.original_dims = None
5766
-
6062
+
5767
6063
  # Handle special states (pan, static background)
5768
- if self.pan_background_image is not None:
6064
+ if self.pan_background_image is not None and not self.pan_mode:
5769
6065
  self.channel_visible = self.pre_pan_channel_state.copy()
5770
6066
  self.is_pan_preview = False
5771
6067
  self.pan_background_image = None
@@ -5774,7 +6070,6 @@ class ImageViewerWindow(QMainWindow):
5774
6070
  self.resume = False
5775
6071
  if self.prev_down != self.downsample_factor:
5776
6072
  self.validate_downsample_input(text = self.prev_down)
5777
- return
5778
6073
 
5779
6074
  if self.static_background is not None:
5780
6075
  # Your existing virtual strokes conversion logic
@@ -5989,8 +6284,8 @@ class ImageViewerWindow(QMainWindow):
5989
6284
  vmin=0, vmax=1, extent=crop_extent)
5990
6285
 
5991
6286
  # Handle preview, overlays, and measurements (apply cropping here too)
5992
- if self.preview and not called:
5993
- self.create_highlight_overlay_slice(self.targs, bounds=self.bounds)
6287
+ #if self.preview and not called:
6288
+ # self.create_highlight_overlay_slice(self.targs, bounds=self.bounds)
5994
6289
 
5995
6290
  # Overlay handling (optimized with cropping and downsampling)
5996
6291
  if self.mini_overlay and self.highlight and self.machine_window is None:
@@ -6204,12 +6499,6 @@ class ImageViewerWindow(QMainWindow):
6204
6499
 
6205
6500
  my_network.centroid_umap()
6206
6501
 
6207
- def handle_iden_umap(self):
6208
-
6209
- if my_network.node_identities is None:
6210
- return
6211
-
6212
- my_network.identity_umap()
6213
6502
 
6214
6503
  def closeEvent(self, event):
6215
6504
  """Override closeEvent to close all windows when main window closes"""
@@ -6226,6 +6515,8 @@ class ImageViewerWindow(QMainWindow):
6226
6515
  # Force quit the application
6227
6516
  QCoreApplication.quit()
6228
6517
 
6518
+ exit()
6519
+
6229
6520
 
6230
6521
 
6231
6522
  #TABLE RELATED:
@@ -6468,11 +6759,7 @@ class CustomTableView(QTableView):
6468
6759
  self.resizeColumnToContents(col)
6469
6760
 
6470
6761
  except Exception as e:
6471
- QMessageBox.critical(
6472
- self,
6473
- "Error",
6474
- f"Error sorting table: {str(e)}"
6475
- )
6762
+ pass
6476
6763
 
6477
6764
  def save_table_as(self, file_type):
6478
6765
  """Save the table data as either CSV or Excel file."""
@@ -6992,8 +7279,13 @@ class PropertiesDialog(QDialog):
6992
7279
  self.network.setChecked(self.check_checked(my_network.network))
6993
7280
  layout.addRow("Network Status", self.network)
6994
7281
 
7282
+ self.node_identities = QPushButton("Node Identities")
7283
+ self.node_identities.setCheckable(True)
7284
+ self.node_identities.setChecked(self.check_checked(my_network.node_identities))
7285
+ layout.addRow("Identities Status", self.node_identities)
7286
+
6995
7287
  # Add Run button
6996
- run_button = QPushButton("Enter")
7288
+ run_button = QPushButton("Enter (Erases Unchecked Properties)")
6997
7289
  run_button.clicked.connect(self.run_properties)
6998
7290
  layout.addWidget(run_button)
6999
7291
 
@@ -7030,8 +7322,9 @@ class PropertiesDialog(QDialog):
7030
7322
  id_overlay = not self.id_overlay.isChecked()
7031
7323
  search_region = not self.search_region.isChecked()
7032
7324
  network = not self.network.isChecked()
7325
+ node_identities = not self.node_identities.isChecked()
7033
7326
 
7034
- self.parent().reset(nodes = nodes, edges = edges, search_region = search_region, network_overlay = network_overlay, id_overlay = id_overlay, network = network, xy_scale = xy_scale, z_scale = z_scale)
7327
+ self.parent().reset(nodes = nodes, edges = edges, search_region = search_region, network_overlay = network_overlay, id_overlay = id_overlay, network = network, xy_scale = xy_scale, z_scale = z_scale, node_identities = node_identities)
7035
7328
 
7036
7329
  self.accept()
7037
7330
 
@@ -7239,7 +7532,7 @@ class ColorDialog(QDialog):
7239
7532
  self.parent().base_colors[i] = new_color
7240
7533
 
7241
7534
  # Update the display
7242
- self.parent().update_display()
7535
+ self.parent().update_display(preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
7243
7536
  self.accept()
7244
7537
 
7245
7538
  class ArbitraryDialog(QDialog):
@@ -7459,78 +7752,252 @@ class ArbitraryDialog(QDialog):
7459
7752
  except Exception as e:
7460
7753
  QMessageBox.critical(self, "Error", f"Error processing selections: {str(e)}")
7461
7754
 
7462
- class MergeNodeIdDialog(QDialog):
7463
7755
 
7756
+ class MergeNodeIdDialog(QDialog):
7464
7757
  def __init__(self, parent=None):
7758
+
7465
7759
  super().__init__(parent)
7760
+
7466
7761
  self.setWindowTitle("Merging Node Identities From Folder Dialog.\nNote that you should prelabel or prewatershed your current node objects before doing this. (See Process -> Image) It does not label them for you.")
7467
7762
  self.setModal(True)
7468
7763
 
7469
7764
  layout = QFormLayout(self)
7470
-
7471
7765
  self.search = QLineEdit("")
7472
7766
  layout.addRow("Step-out distance (from current nodes image - ignore if you dilated them previously or don't want):", self.search)
7473
-
7474
7767
  self.xy_scale = QLineEdit(f"{my_network.xy_scale}")
7475
7768
  layout.addRow("xy_scale:", self.xy_scale)
7476
-
7477
7769
  self.z_scale = QLineEdit(f"{my_network.z_scale}")
7478
7770
  layout.addRow("z_scale:", self.z_scale)
7771
+ self.mode_selector = QComboBox()
7772
+ self.mode_selector.addItems(["Auto-Binarize(Otsu)/Presegmented", "Manual (Interactive Thresholder)"])
7773
+ self.mode_selector.setCurrentIndex(1) # Default to Mode 1
7774
+ layout.addRow("Binarization Strategy:", self.mode_selector)
7479
7775
 
7480
- # Add Run button
7481
- self.include = QPushButton("Include Negative Gates?")
7776
+ self.umap = QPushButton("If using manual threshold: Also generate identity UMAP?")
7777
+ self.umap.setCheckable(True)
7778
+ self.umap.setChecked(True)
7779
+ layout.addWidget(self.umap)
7780
+
7781
+ self.include = QPushButton("Include When a Node is Negative for an ID?")
7482
7782
  self.include.setCheckable(True)
7483
- self.include.setChecked(True)
7783
+ self.include.setChecked(False)
7484
7784
  layout.addWidget(self.include)
7485
-
7486
- # Add Run button
7785
+
7487
7786
  run_button = QPushButton("Get Directory")
7488
7787
  run_button.clicked.connect(self.run)
7489
7788
  layout.addWidget(run_button)
7490
7789
 
7491
- def run(self):
7790
+ def wait_for_threshold_processing(self):
7791
+ """
7792
+ Opens ThresholdWindow and waits for user to process the image.
7793
+ Returns True if completed, False if cancelled.
7794
+ The thresholded image will be available in the main window after completion.
7795
+ """
7796
+ # Create event loop to wait for user
7797
+ loop = QEventLoop()
7798
+ result = {'completed': False}
7799
+
7800
+ # Create the threshold window
7801
+ thresh_window = ThresholdWindow(self.parent(), 4)
7802
+
7803
+ # Connect signals
7804
+ def on_processing_complete():
7805
+ result['completed'] = True
7806
+ loop.quit()
7807
+
7808
+ def on_processing_cancelled():
7809
+ result['completed'] = False
7810
+ loop.quit()
7811
+
7812
+ thresh_window.processing_complete.connect(on_processing_complete)
7813
+ thresh_window.processing_cancelled.connect(on_processing_cancelled)
7814
+
7815
+ # Show window and wait
7816
+ thresh_window.show()
7817
+ thresh_window.raise_()
7818
+ thresh_window.activateWindow()
7819
+
7820
+ # Block until user clicks "Apply Threshold & Continue" or "Cancel"
7821
+ loop.exec()
7822
+
7823
+ # Clean up
7824
+ thresh_window.deleteLater()
7825
+
7826
+ return result['completed']
7492
7827
 
7828
+ def run(self):
7493
7829
  try:
7494
7830
 
7495
7831
  search = float(self.search.text()) if self.search.text().strip() else 0
7496
7832
  xy_scale = float(self.xy_scale.text()) if self.xy_scale.text().strip() else 1
7497
7833
  z_scale = float(self.z_scale.text()) if self.z_scale.text().strip() else 1
7498
-
7499
-
7500
7834
  data = self.parent().channel_data[0]
7501
7835
  include = self.include.isChecked()
7502
-
7836
+ umap = self.umap.isChecked()
7837
+
7503
7838
  if data is None:
7504
7839
  return
7505
-
7506
-
7507
-
7840
+
7508
7841
  dialog = QFileDialog(self)
7509
7842
  dialog.setOption(QFileDialog.Option.DontUseNativeDialog)
7510
7843
  dialog.setOption(QFileDialog.Option.ReadOnly)
7511
7844
  dialog.setFileMode(QFileDialog.FileMode.Directory)
7512
7845
  dialog.setViewMode(QFileDialog.ViewMode.Detail)
7513
-
7846
+
7514
7847
  if dialog.exec() == QFileDialog.DialogCode.Accepted:
7515
7848
  selected_path = dialog.directory().absolutePath()
7516
-
7849
+ else:
7850
+ return # User cancelled directory selection
7851
+
7517
7852
  if search > 0:
7518
- data = sdl.smart_dilate(data, 1, 1, GPU = False, fast_dil = False, use_dt_dil_amount = search, xy_scale = xy_scale, z_scale = z_scale)
7853
+ data = sdl.smart_dilate(data, 1, 1, GPU=False, fast_dil=False,
7854
+ use_dt_dil_amount=search, xy_scale=xy_scale, z_scale=z_scale)
7855
+
7856
+ # Check if manual mode is selected
7857
+ if self.mode_selector.currentIndex() == 1: # Manual mode
7519
7858
 
7520
- my_network.merge_node_ids(selected_path, data, include)
7859
+ if my_network.node_identities is None: # Prepare modular dict
7521
7860
 
7522
- self.parent().format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity')
7861
+ my_network.node_identities = {}
7523
7862
 
7524
- QMessageBox.information(
7525
- self,
7526
- "Success",
7527
- "Node Identities Merged. New IDs represent presence of corresponding img foreground with +, absence with -. Please save your new identities as csv, then use File -> Load -> Load From Excel Helper to bulk search and rename desired combinations. (Press Help [above] for more info)"
7528
- )
7863
+ nodes = list(np.unique(data))
7864
+ if 0 in nodes:
7865
+ del nodes[0]
7866
+ for node in nodes:
7867
+
7868
+ my_network.node_identities[node] = [] # Assign to lists at first
7869
+ else:
7870
+ for node, iden in my_network.node_identities.items():
7871
+ try:
7872
+ my_network.node_identities[node] = ast.literal_eval(iden)
7873
+ except:
7874
+ my_network.node_identities[node] = [iden]
7875
+
7876
+ id_dicts = my_network.get_merge_node_dictionaries(selected_path, data)
7877
+
7878
+ # For loop example - get threshold for multiple images/data
7879
+ results = []
7880
+
7881
+ img_list = n3d.directory_info(selected_path)
7882
+ data_backup = copy.deepcopy(data)
7883
+ self.parent().load_channel(0, data, data = True)
7884
+ self.hide()
7885
+ self.parent().highlight_overlay = None
7886
+
7887
+ good_list = []
7888
+
7889
+ for i, img in enumerate(img_list):
7890
+
7891
+ if img.endswith('.tiff') or img.endswith('.tif'):
7892
+
7893
+ print(f"Please threshold {img}")
7894
+
7895
+
7896
+ mask = tifffile.imread(f'{selected_path}/{img}')
7897
+ self.parent().load_channel(2, mask, data = True)
7898
+
7899
+ # Wait for user to threshold this data
7900
+ self.parent().special_dict = id_dicts[i]
7901
+ processing_completed = self.wait_for_threshold_processing()
7902
+
7903
+ if not processing_completed:
7904
+ # User cancelled, ask if they want to continue
7905
+ reply = QMessageBox.question(self, 'Continue?',
7906
+ f'Threshold cancelled for item {i+1}. Continue with remaining items?',
7907
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
7908
+ if reply == QMessageBox.StandardButton.No:
7909
+ break
7910
+ continue
7911
+
7912
+ # At this point, the thresholded image is in the main window's memory
7913
+ # Get the processed/thresholded data from wherever ThresholdWindow stored it
7914
+ thresholded_vals = list(np.unique(self.parent().channel_data[0]))
7915
+ if 0 in thresholded_vals:
7916
+ del thresholded_vals[0]
7917
+
7918
+ if img.endswith('.tiff'):
7919
+ base_name = img[:-5]
7920
+ elif img.endswith('.tif'):
7921
+ base_name = img[:-4]
7922
+ else:
7923
+ base_name = img
7924
+
7925
+ assigned = {}
7926
+
7927
+ for node in my_network.node_identities.keys():
7928
+
7929
+ try:
7930
+
7931
+ if int(node) in thresholded_vals:
7932
+
7933
+ my_network.node_identities[node].append(f'{base_name}+')
7934
+
7935
+ elif include:
7936
+
7937
+ my_network.node_identities[node].append(f'{base_name}-')
7938
+
7939
+ except:
7940
+ pass
7941
+
7942
+ # Process the thresholded data
7943
+ self.parent().highlight_overlay = None
7944
+ self.parent().load_channel(0, data_backup, data = True)
7945
+ good_list.append(base_name)
7946
+
7947
+ modify_dict = copy.deepcopy(my_network.node_identities)
7948
+
7949
+ for node, iden in my_network.node_identities.items():
7950
+
7951
+ try:
7952
+
7953
+ if len(iden) == 1:
7954
+
7955
+ modify_dict[node] = str(iden[0]) # Singleton lists become bare strings
7956
+ elif len(iden) == 0:
7957
+ del modify_dict[node]
7958
+ else:
7959
+ modify_dict[node] = str(iden) # We hold multi element lists as strings for compatibility
7960
+
7961
+ except:
7962
+ pass
7963
+
7964
+ my_network.node_identities = modify_dict
7965
+
7966
+ self.parent().format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity')
7967
+
7968
+ all_keys = id_dicts[0].keys()
7969
+ result = {key: np.array([d[key] for d in id_dicts]) for key in all_keys}
7970
+
7971
+
7972
+ self.parent().format_for_upperright_table(result, 'NodeID', good_list, 'Mean Intensity')
7973
+ if umap:
7974
+ my_network.identity_umap(result)
7529
7975
 
7530
- self.accept()
7976
+
7977
+ QMessageBox.information(
7978
+ self,
7979
+ "Success",
7980
+ "Node Identities Merged. New IDs represent presence of corresponding img foreground with +, absence with -. Please save your new identities as csv, then use File -> Load -> Load From Excel Helper to bulk search and rename desired combinations. (Press Help [above] for more info)"
7981
+ )
7982
+
7983
+ self.accept()
7984
+ else:
7985
+ my_network.merge_node_ids(selected_path, data, include)
7986
+
7987
+ self.parent().format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity')
7988
+
7989
+ QMessageBox.information(
7990
+ self,
7991
+ "Success",
7992
+ "Node Identities Merged. New IDs represent presence of corresponding img foreground with +, absence with -. Please save your new identities as csv, then use File -> Load -> Load From Excel Helper to bulk search and rename desired combinations. (Press Help [above] for more info)"
7993
+ )
7994
+
7995
+ self.accept()
7531
7996
 
7532
7997
  except Exception as e:
7533
- print(f"Error: {e}")
7998
+ import traceback
7999
+ print(traceback.format_exc())
8000
+ #print(f"Error: {e}")
7534
8001
 
7535
8002
 
7536
8003
  class Show3dDialog(QDialog):
@@ -7643,7 +8110,7 @@ class NetOverlayDialog(QDialog):
7643
8110
 
7644
8111
  my_network.network_overlay = my_network.draw_network()
7645
8112
 
7646
- self.parent().load_channel(2, channel_data = my_network.network_overlay, data = True)
8113
+ self.parent().load_channel(2, channel_data = my_network.network_overlay, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
7647
8114
 
7648
8115
  self.accept()
7649
8116
 
@@ -7651,34 +8118,6 @@ class NetOverlayDialog(QDialog):
7651
8118
 
7652
8119
  print(f"Error with Overlay Generation: {e}")
7653
8120
 
7654
- class SearchOverlayDialog(QDialog):
7655
-
7656
- def __init__(self, parent=None):
7657
-
7658
- super().__init__(parent)
7659
- self.setWindowTitle("Generate Search Region Overlay?")
7660
- self.setModal(True)
7661
-
7662
- layout = QFormLayout(self)
7663
-
7664
- # Add Run button
7665
- run_button = QPushButton("Generate (Will go to Overlay 2)")
7666
- run_button.clicked.connect(self.searchoverlay)
7667
- layout.addWidget(run_button)
7668
-
7669
- def searchoverlay(self):
7670
-
7671
- try:
7672
-
7673
- my_network.id_overlay = my_network.search_region
7674
-
7675
- self.parent().load_channel(3, channel_data = my_network.search_region, data = True)
7676
-
7677
- self.accept()
7678
-
7679
- except Exception as e:
7680
-
7681
- print(f"Error with Overlay Generation: {e}")
7682
8121
 
7683
8122
  class IdOverlayDialog(QDialog):
7684
8123
 
@@ -7732,7 +8171,7 @@ class IdOverlayDialog(QDialog):
7732
8171
  my_network.id_overlay = my_network.draw_edge_indices()
7733
8172
 
7734
8173
 
7735
- self.parent().load_channel(3, channel_data = my_network.id_overlay, data = True)
8174
+ self.parent().load_channel(3, channel_data = my_network.id_overlay, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
7736
8175
 
7737
8176
  self.accept()
7738
8177
 
@@ -7781,7 +8220,7 @@ class ColorOverlayDialog(QDialog):
7781
8220
  self.parent().format_for_upperright_table(legend, f'{self.sort} Id', f'Encoding Val: {self.sort}', 'Legend')
7782
8221
 
7783
8222
 
7784
- self.parent().load_channel(3, channel_data = result, data = True)
8223
+ self.parent().load_channel(3, channel_data = result, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
7785
8224
 
7786
8225
  self.accept()
7787
8226
 
@@ -7849,7 +8288,7 @@ class ShuffleDialog(QDialog):
7849
8288
  except:
7850
8289
  self.parent().highlight_overay = None
7851
8290
  else:
7852
- self.parent().load_channel(accepted_mode, channel_data = target_data, data = True)
8291
+ self.parent().load_channel(accepted_mode, channel_data = target_data, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
7853
8292
  except:
7854
8293
  pass
7855
8294
 
@@ -7861,14 +8300,14 @@ class ShuffleDialog(QDialog):
7861
8300
  except:
7862
8301
  self.parent().highlight_overlay = None
7863
8302
  else:
7864
- self.parent().load_channel(accepted_target, channel_data = active_data, data = True)
8303
+ self.parent().load_channel(accepted_target, channel_data = active_data, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
7865
8304
  except:
7866
8305
  pass
7867
8306
 
7868
8307
 
7869
8308
 
7870
8309
 
7871
- self.parent().update_display()
8310
+ self.parent().update_display(preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
7872
8311
 
7873
8312
  self.accept()
7874
8313
 
@@ -8476,10 +8915,10 @@ class NearNeighDialog(QDialog):
8476
8915
  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)
8477
8916
  else:
8478
8917
  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)
8479
- self.parent().load_channel(3, overlay, data = True)
8918
+ self.parent().load_channel(3, overlay, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
8480
8919
 
8481
8920
  if quant_overlay is not None:
8482
- self.parent().load_channel(2, quant_overlay, data = True)
8921
+ self.parent().load_channel(2, quant_overlay, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
8483
8922
 
8484
8923
  avg = {header:avg}
8485
8924
 
@@ -8595,6 +9034,8 @@ class NeighborIdentityDialog(QDialog):
8595
9034
 
8596
9035
  self.accept()
8597
9036
  except Exception as e:
9037
+ import traceback
9038
+ print(traceback.format_exc())
8598
9039
  print(f"Error: {e}")
8599
9040
 
8600
9041
 
@@ -8826,7 +9267,7 @@ class HeatmapDialog(QDialog):
8826
9267
  else:
8827
9268
 
8828
9269
  heat_dict, overlay = my_network.community_heatmap(num_nodes = nodecount, is3d = is3d, numpy = True)
8829
- self.parent().load_channel(3, overlay, data = True)
9270
+ self.parent().load_channel(3, overlay, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
8830
9271
 
8831
9272
 
8832
9273
  self.parent().format_for_upperright_table(heat_dict, metric='Community', value='ln(Predicted Community Nodecount/Actual)', title="Community Heatmap")
@@ -9183,7 +9624,7 @@ class DegreeDialog(QDialog):
9183
9624
  nodes = n3d.upsample_with_padding(nodes, down_factor, original_shape)
9184
9625
 
9185
9626
  if accepted_mode > 0:
9186
- self.parent().load_channel(3, channel_data = nodes, data = True)
9627
+ self.parent().load_channel(3, channel_data = nodes, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
9187
9628
 
9188
9629
 
9189
9630
  self.accept()
@@ -9246,7 +9687,7 @@ class HubDialog(QDialog):
9246
9687
 
9247
9688
  if img is not None:
9248
9689
 
9249
- self.parent().load_channel(3, channel_data = img, data = True)
9690
+ self.parent().load_channel(3, channel_data = img, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
9250
9691
 
9251
9692
 
9252
9693
  self.accept()
@@ -9305,7 +9746,7 @@ class MotherDialog(QDialog):
9305
9746
  G = my_network.isolate_mothers(self, ret_nodes = True, called = True)
9306
9747
  else:
9307
9748
  G, result = my_network.isolate_mothers(self, ret_nodes = False, called = True)
9308
- self.parent().load_channel(2, channel_data = result, data = True)
9749
+ self.parent().load_channel(2, channel_data = result, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
9309
9750
 
9310
9751
  degree_dict = {}
9311
9752
 
@@ -9380,8 +9821,7 @@ class CodeDialog(QDialog):
9380
9821
 
9381
9822
  self.parent().format_for_upperright_table(output, f'{self.sort} Id', f'Encoding Val: {self.sort}', 'Legend')
9382
9823
 
9383
-
9384
- self.parent().load_channel(3, image, True)
9824
+ self.parent().load_channel(3, image, True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
9385
9825
  self.accept()
9386
9826
 
9387
9827
  except Exception as e:
@@ -9794,7 +10234,7 @@ class BinarizeDialog(QDialog):
9794
10234
  # Update the corresponding property in my_network
9795
10235
  setattr(my_network, network_properties[self.parent().active_channel], result)
9796
10236
 
9797
- self.parent().update_display()
10237
+ self.parent().update_display(preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
9798
10238
  self.accept()
9799
10239
 
9800
10240
  except Exception as e:
@@ -9846,7 +10286,7 @@ class LabelDialog(QDialog):
9846
10286
  # Update the corresponding property in my_network
9847
10287
  setattr(my_network, network_properties[self.parent().active_channel], result)
9848
10288
 
9849
- self.parent().update_display()
10289
+ self.parent().update_display(preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
9850
10290
  self.accept()
9851
10291
 
9852
10292
  except Exception as e:
@@ -9929,7 +10369,7 @@ class SLabelDialog(QDialog):
9929
10369
 
9930
10370
  binary_array = binary_array * label_array
9931
10371
 
9932
- self.parent().load_channel(accepted_target, binary_array, True)
10372
+ self.parent().load_channel(accepted_target, binary_array, True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
9933
10373
 
9934
10374
  self.accept()
9935
10375
 
@@ -10613,6 +11053,10 @@ class MachineWindow(QMainWindow):
10613
11053
 
10614
11054
  def start_segmentation(self):
10615
11055
 
11056
+ if self.parent().pan_mode:
11057
+ self.parent().pan_button.click()
11058
+
11059
+
10616
11060
  self.parent().static_background = None
10617
11061
 
10618
11062
  self.kill_segmentation()
@@ -10934,11 +11378,15 @@ class SegmentationWorker(QThread):
10934
11378
 
10935
11379
 
10936
11380
  class ThresholdWindow(QMainWindow):
11381
+ processing_complete = pyqtSignal() # Emitted when user finishes and images are modified
11382
+ processing_cancelled = pyqtSignal() # Emitted when user cancels
11383
+
10937
11384
  def __init__(self, parent=None, accepted_mode=0):
10938
11385
  super().__init__(parent)
10939
11386
  self.setWindowTitle("Threshold")
10940
11387
 
10941
11388
  self.accepted_mode = accepted_mode
11389
+ self.preview = True
10942
11390
 
10943
11391
  # Create central widget and layout
10944
11392
  central_widget = QWidget()
@@ -10970,6 +11418,10 @@ class ThresholdWindow(QMainWindow):
10970
11418
  self.histo_list = list(self.parent().degree_dict.values())
10971
11419
  self.bounds = False
10972
11420
  self.parent().bounds = False
11421
+ elif accepted_mode == 4:
11422
+ self.histo_list = list(self.parent().special_dict.values())
11423
+ self.bounds = False
11424
+ self.parent().bounds = False
10973
11425
 
10974
11426
  elif accepted_mode == 0:
10975
11427
  targ_shape = self.parent().channel_data[self.parent().active_channel].shape
@@ -11041,16 +11493,39 @@ class ThresholdWindow(QMainWindow):
11041
11493
  self.preview.clicked.connect(self.preview_mode)
11042
11494
  form_layout.addRow("Show Preview:", self.preview)
11043
11495
 
11044
- run_button = QPushButton("Apply Threshold")
11045
- run_button.clicked.connect(self.thresh)
11046
- form_layout.addRow(run_button)
11496
+ button_layout = QHBoxLayout()
11497
+
11498
+
11499
+ # Keep your existing Apply Threshold button, but modify its behavior
11500
+ run_button = QPushButton("Apply Threshold/Continue")
11501
+ run_button.clicked.connect(self.apply_and_continue) # New method
11502
+ button_layout.addWidget(run_button)
11047
11503
 
11048
- layout.addLayout(form_layout)
11504
+ # Add Cancel button for external dialog use
11505
+ cancel_button = QPushButton("Cancel/Skip")
11506
+ cancel_button.clicked.connect(self.cancel_processing)
11507
+ button_layout.addWidget(cancel_button)
11049
11508
 
11509
+ form_layout.addRow(button_layout)
11510
+ layout.addLayout(form_layout)
11511
+
11050
11512
  # Set a reasonable default size
11051
11513
  self.setMinimumWidth(400)
11052
11514
  self.setMinimumHeight(400)
11053
11515
 
11516
+ def apply_and_continue(self):
11517
+ """Apply threshold, modify main window images, then signal completion"""
11518
+ self.thresh() # This should modify the main window images
11519
+
11520
+ # Signal that processing is complete
11521
+ self.processing_complete.emit()
11522
+ self.close()
11523
+
11524
+ def cancel_processing(self):
11525
+ """Cancel without applying changes"""
11526
+ self.processing_cancelled.emit()
11527
+ self.close()
11528
+
11054
11529
  def closeEvent(self, event):
11055
11530
  self.parent().preview = False
11056
11531
  self.parent().targs = None
@@ -11102,6 +11577,10 @@ class ThresholdWindow(QMainWindow):
11102
11577
  for node, vol in self.parent().degree_dict.items():
11103
11578
  if min_val <= vol <= max_val:
11104
11579
  output.append(node)
11580
+ elif self.accepted_mode == 4:
11581
+ for node, vol in self.parent().special_dict.items():
11582
+ if min_val <= vol <= max_val:
11583
+ output.append(node)
11105
11584
  return output
11106
11585
 
11107
11586
  def get_values_in_range(self, lst, min_val, max_val):
@@ -11119,11 +11598,18 @@ class ThresholdWindow(QMainWindow):
11119
11598
  for item in self.parent().degree_dict:
11120
11599
  if self.parent().degree_dict[item] in values:
11121
11600
  output.append(item)
11601
+ elif self.accepted_mode == 4:
11602
+ for item in self.parent().special_dict:
11603
+ if self.parent().special_dict[item] in values:
11604
+ output.append(item)
11605
+
11122
11606
  return output
11123
11607
 
11124
11608
 
11125
11609
  def min_value_changed(self):
11126
11610
  try:
11611
+ if not self.preview.isChecked():
11612
+ self.preview.click()
11127
11613
  text = self.min.text()
11128
11614
  if not text: # If empty, ignore
11129
11615
  return
@@ -11171,6 +11657,8 @@ class ThresholdWindow(QMainWindow):
11171
11657
 
11172
11658
  def max_value_changed(self):
11173
11659
  try:
11660
+ if not self.preview.isChecked():
11661
+ self.preview.click()
11174
11662
  text = self.max.text()
11175
11663
  if not text: # If empty, ignore
11176
11664
  return
@@ -11294,7 +11782,6 @@ class ThresholdWindow(QMainWindow):
11294
11782
  f"Error running threshold: {str(e)}"
11295
11783
  )
11296
11784
 
11297
-
11298
11785
  class SmartDilateDialog(QDialog):
11299
11786
  def __init__(self, parent, params):
11300
11787
  super().__init__(parent)
@@ -11336,7 +11823,7 @@ class SmartDilateDialog(QDialog):
11336
11823
 
11337
11824
  result = sdl.smart_dilate(active_data, dilate_xy, dilate_z, GPU = GPU, predownsample = down_factor, fast_dil = predt, use_dt_dil_amount = amount, xy_scale = xy_scale, z_scale = z_scale)
11338
11825
 
11339
- self.parent().load_channel(self.parent().active_channel, result, True)
11826
+ self.parent().load_channel(self.parent().active_channel, result, True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
11340
11827
  self.accept()
11341
11828
 
11342
11829
 
@@ -11477,7 +11964,7 @@ class ErodeDialog(QDialog):
11477
11964
 
11478
11965
  # Add mode selection dropdown
11479
11966
  self.mode_selector = QComboBox()
11480
- self.mode_selector.addItems(["Pseudo3D Binary Kernels (For Fast, small erosions)", "Distance Transform-Based (Slower but more accurate at larger dilations)"])
11967
+ self.mode_selector.addItems(["Pseudo3D Binary Kernels (For Fast, small erosions)", "Distance Transform-Based (Slower but more accurate at larger dilations)", "Preserve Labels (Slower)"])
11481
11968
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
11482
11969
  layout.addRow("Execution Mode:", self.mode_selector)
11483
11970
 
@@ -11513,6 +12000,12 @@ class ErodeDialog(QDialog):
11513
12000
  z_scale = 1
11514
12001
 
11515
12002
  mode = self.mode_selector.currentIndex()
12003
+
12004
+ if mode == 2:
12005
+ mode = 1
12006
+ preserve_labels = True
12007
+ else:
12008
+ preserve_labels = False
11516
12009
 
11517
12010
  # Get the active channel data from parent
11518
12011
  active_data = self.parent().channel_data[self.parent().active_channel]
@@ -11525,14 +12018,13 @@ class ErodeDialog(QDialog):
11525
12018
  amount,
11526
12019
  xy_scale = xy_scale,
11527
12020
  z_scale = z_scale,
11528
- mode = mode
12021
+ mode = mode,
12022
+ preserve_labels = preserve_labels
11529
12023
  )
11530
12024
 
11531
12025
 
11532
- self.parent().load_channel(self.parent().active_channel, result, True)
11533
-
12026
+ self.parent().load_channel(self.parent().active_channel, result, True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
11534
12027
 
11535
- self.parent().update_display(preserve_zoom=(current_xlim, current_ylim))
11536
12028
  self.accept()
11537
12029
 
11538
12030
  except Exception as e:
@@ -11598,7 +12090,7 @@ class HoleDialog(QDialog):
11598
12090
  self.parent().load_channel(3, active_data - result, True)
11599
12091
 
11600
12092
 
11601
- self.parent().update_display()
12093
+ self.parent().update_display(preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
11602
12094
  self.accept()
11603
12095
 
11604
12096
  except Exception as e:
@@ -11676,9 +12168,9 @@ class MaskDialog(QDialog):
11676
12168
 
11677
12169
 
11678
12170
  # Update both the display data and the network object
11679
- self.parent().load_channel(output_target, channel_data = result, data = True)
12171
+ self.parent().load_channel(output_target, channel_data = result, data = True,)
11680
12172
 
11681
- self.parent().update_display()
12173
+ self.parent().update_display(preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
11682
12174
 
11683
12175
  self.accept()
11684
12176
 
@@ -11911,7 +12403,7 @@ class TypeDialog(QDialog):
11911
12403
 
11912
12404
  active_data = active_data.astype(np.float64)
11913
12405
 
11914
- self.parent().load_channel(self.active_chan, active_data, True)
12406
+ self.parent().load_channel(self.active_chan, active_data, True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
11915
12407
 
11916
12408
 
11917
12409
  print(f"Channel {self.active_chan}) dtype now: {self.parent().channel_data[self.active_chan].dtype}")
@@ -11972,6 +12464,8 @@ class SkeletonizeDialog(QDialog):
11972
12464
 
11973
12465
  if remove > 0:
11974
12466
  result = n3d.remove_branches_new(result, remove)
12467
+ result = n3d.dilate_3D(result, 3, 3, 3)
12468
+ result = n3d.skeletonize(result)
11975
12469
 
11976
12470
 
11977
12471
  # Update both the display data and the network object
@@ -11981,7 +12475,7 @@ class SkeletonizeDialog(QDialog):
11981
12475
  # Update the corresponding property in my_network
11982
12476
  setattr(my_network, network_properties[self.parent().active_channel], result)
11983
12477
 
11984
- self.parent().update_display()
12478
+ self.parent().update_display(preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
11985
12479
  self.accept()
11986
12480
 
11987
12481
  except Exception as e:
@@ -12012,7 +12506,7 @@ class DistanceDialog(QDialog):
12012
12506
 
12013
12507
  data = sdl.compute_distance_transform_distance(data, sampling = [my_network.z_scale, my_network.xy_scale, my_network.xy_scale])
12014
12508
 
12015
- self.parent().load_channel(self.parent().active_channel, data, data = True)
12509
+ self.parent().load_channel(self.parent().active_channel, data, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
12016
12510
 
12017
12511
  except Exception as e:
12018
12512
 
@@ -12050,7 +12544,7 @@ class GrayWaterDialog(QDialog):
12050
12544
 
12051
12545
  data = n3d.gray_watershed(data, min_peak_distance, min_intensity)
12052
12546
 
12053
- self.parent().load_channel(self.parent().active_channel, data, data = True)
12547
+ self.parent().load_channel(self.parent().active_channel, data, data = True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
12054
12548
 
12055
12549
  self.accept()
12056
12550
 
@@ -12175,7 +12669,7 @@ class WatershedDialog(QDialog):
12175
12669
  # Update the corresponding property in my_network
12176
12670
  setattr(my_network, network_properties[self.parent().active_channel], result)
12177
12671
 
12178
- self.parent().update_display()
12672
+ self.parent().update_display(preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
12179
12673
  self.accept()
12180
12674
 
12181
12675
  except Exception as e:
@@ -12228,7 +12722,7 @@ class InvertDialog(QDialog):
12228
12722
  # Update the corresponding property in my_network
12229
12723
  setattr(my_network, network_properties[self.parent().active_channel], result)
12230
12724
 
12231
- self.parent().update_display()
12725
+ self.parent().update_display(preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
12232
12726
  self.accept()
12233
12727
 
12234
12728
  except Exception as e:
@@ -12272,7 +12766,7 @@ class ZDialog(QDialog):
12272
12766
  for i in range(len(self.parent().channel_data)):
12273
12767
  try:
12274
12768
  self.parent().channel_data[i] = n3d.z_project(self.parent().channel_data[i], mode)
12275
- self.parent().load_channel(i, self.parent().channel_data[i], True)
12769
+ self.parent().load_channel(i, self.parent().channel_data[i], True, preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
12276
12770
  except:
12277
12771
  pass
12278
12772
 
@@ -12775,7 +13269,7 @@ class BranchDialog(QDialog):
12775
13269
 
12776
13270
  self.parent().load_channel(1, channel_data = output, data = True)
12777
13271
 
12778
- self.parent().update_display()
13272
+ self.parent().update_display(preserve_zoom = (self.parent().ax.get_xlim(), self.parent().ax.get_ylim()))
12779
13273
  self.accept()
12780
13274
 
12781
13275
  except Exception as e:
@@ -12946,6 +13440,12 @@ class ModifyDialog(QDialog):
12946
13440
  self.revid.setChecked(False)
12947
13441
  layout.addRow("Remove Unassigned IDs from Centroid List?:", self.revid)
12948
13442
 
13443
+ self.revdupeid = QPushButton("Make Singleton IDs")
13444
+ self.revdupeid.setCheckable(True)
13445
+ self.revdupeid.setChecked(False)
13446
+ layout.addRow("Force Any Multiple IDs to Pick a Random Single ID?:", self.revdupeid)
13447
+
13448
+
12949
13449
  self.remove = QPushButton("Remove Missing")
12950
13450
  self.remove.setCheckable(True)
12951
13451
  self.remove.setChecked(False)
@@ -13024,6 +13524,7 @@ class ModifyDialog(QDialog):
13024
13524
  try:
13025
13525
 
13026
13526
  revid = self.revid.isChecked()
13527
+ revdupeid = self.revdupeid.isChecked()
13027
13528
  trunk = self.trunk.isChecked()
13028
13529
  if not trunk:
13029
13530
  trunknode = self.trunknode.isChecked()
@@ -13048,6 +13549,20 @@ class ModifyDialog(QDialog):
13048
13549
  except:
13049
13550
  pass
13050
13551
 
13552
+ if revdupeid:
13553
+ try:
13554
+ for node, iden in my_network.node_identities.items():
13555
+ try:
13556
+ import ast
13557
+ import random
13558
+ iden = ast.literal_eval(iden)
13559
+ my_network.node_identities[node] = random.choice(iden)
13560
+ except:
13561
+ pass
13562
+ self.parent().format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity', 'Node Identities')
13563
+ except:
13564
+ pass
13565
+
13051
13566
 
13052
13567
  if remove:
13053
13568
  my_network.purge_properties()
@@ -13155,6 +13670,11 @@ class CentroidDialog(QDialog):
13155
13670
  self.mode_selector.setCurrentIndex(0) # Default to Mode 1
13156
13671
  layout.addRow("Execution Mode:", self.mode_selector)
13157
13672
 
13673
+ self.ignore_empty = QPushButton("Skip ID-less?")
13674
+ self.ignore_empty.setCheckable(True)
13675
+ self.ignore_empty.setChecked(False)
13676
+ layout.addRow("Skip Node Centroids Without Identity Property?:", self.ignore_empty)
13677
+
13158
13678
  # Add Run button
13159
13679
  run_button = QPushButton("Run Calculate Centroids")
13160
13680
  run_button.clicked.connect(self.run_centroids)
@@ -13167,6 +13687,7 @@ class CentroidDialog(QDialog):
13167
13687
  print("Calculating centroids...")
13168
13688
 
13169
13689
  chan = self.mode_selector.currentIndex()
13690
+ ignore_empty = self.ignore_empty.isChecked()
13170
13691
 
13171
13692
  # Get directory (None if empty)
13172
13693
  directory = self.directory.text() if self.directory.text() else None
@@ -13227,6 +13748,12 @@ class CentroidDialog(QDialog):
13227
13748
  except Exception as e:
13228
13749
  print(f"Error loading edge centroid table: {e}")
13229
13750
 
13751
+ if ignore_empty:
13752
+ try:
13753
+ my_network.remove_ids()
13754
+ self.parent().format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
13755
+ except:
13756
+ pass
13230
13757
 
13231
13758
  self.parent().update_display()
13232
13759
  self.accept()