nettracer3d 0.8.1__py3-none-any.whl → 0.8.3__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.

@@ -43,11 +43,15 @@ class ImageViewerWindow(QMainWindow):
43
43
  self.setGeometry(100, 100, 1400, 800)
44
44
 
45
45
  # Initialize channel data and states
46
- self.channel_data = [None] * 4
47
- self.channel_visible = [False] * 4
46
+ self.channel_data = [None] * 5
47
+ self.channel_visible = [False] * 5
48
48
  self.current_slice = 0
49
49
  self.active_channel = 0 # Initialize active channel
50
50
  self.node_name = "Root_Nodes"
51
+ self.last_saved = None
52
+ self.last_saved_name = None
53
+ self.last_load = None
54
+ self.temp_chan = 0
51
55
 
52
56
  self.color_dictionary = {
53
57
  # Reds
@@ -129,7 +133,6 @@ class ImageViewerWindow(QMainWindow):
129
133
  self.img_width = None
130
134
  self.img_height = None
131
135
  self.pre_pan_channel_state = None # Store which channels were visible before pan
132
- self.pan_background = None # Store the static background image during pan
133
136
  self.is_pan_preview = False # Track if we're in pan preview mode
134
137
  self.pre_pan_channel_state = None # Store which channels were visible before pan
135
138
  self.pan_background_image = None # Store the rendered composite image
@@ -152,7 +155,7 @@ class ImageViewerWindow(QMainWindow):
152
155
  self.channel_brightness = [{
153
156
  'min': 0,
154
157
  'max': 1
155
- } for _ in range(4)]
158
+ } for _ in range(5)]
156
159
 
157
160
  # Create the brightness dialog but don't show it yet
158
161
  self.brightness_dialog = BrightnessContrastDialog(self)
@@ -445,6 +448,7 @@ class ImageViewerWindow(QMainWindow):
445
448
  self.paint_timer.timeout.connect(self.flush_paint_updates)
446
449
  self.paint_timer.setSingleShot(True)
447
450
  self.pending_paint_update = False
451
+ self.static_background = None
448
452
 
449
453
  # Threading for paint operations
450
454
  self.paint_queue = queue.Queue()
@@ -453,7 +457,6 @@ class ImageViewerWindow(QMainWindow):
453
457
  self.paint_worker.start()
454
458
 
455
459
  # Background caching for blitting
456
- self.paint_background = None
457
460
  self.paint_session_active = False
458
461
 
459
462
  # Batch paint operations
@@ -1503,6 +1506,11 @@ class ImageViewerWindow(QMainWindow):
1503
1506
  self.targ = QLineEdit("")
1504
1507
  layout.addRow("Node/Edge ID:", self.targ)
1505
1508
 
1509
+ self.mode_selector = QComboBox()
1510
+ self.mode_selector.addItems(["nodes", "edges", "communities"])
1511
+ self.mode_selector.setCurrentIndex(0) # Default to Mode 1
1512
+ layout.addRow("Type to select:", self.mode_selector)
1513
+
1506
1514
  run_button = QPushButton("Enter")
1507
1515
  run_button.clicked.connect(self.run)
1508
1516
  layout.addWidget(run_button)
@@ -1511,20 +1519,22 @@ class ImageViewerWindow(QMainWindow):
1511
1519
 
1512
1520
  try:
1513
1521
 
1522
+ mode = self.mode_selector.currentIndex()
1523
+
1514
1524
  value = int(self.targ.text()) if self.targ.text().strip() else None
1515
1525
 
1516
1526
  if value is None:
1517
1527
  return
1518
1528
 
1519
- if self.parent().active_channel == 1:
1520
-
1521
- mode = 'edges'
1529
+ if mode == 1:
1522
1530
 
1523
1531
  if my_network.edge_centroids is None:
1524
1532
  self.parent().show_centroid_dialog()
1525
1533
 
1526
1534
  num = (self.parent().channel_data[1].shape[0] * self.parent().channel_data[1].shape[1] * self.parent().channel_data[1].shape[2])
1527
1535
 
1536
+ self.parent().clicked_values['edges'] = [value]
1537
+
1528
1538
  if value in my_network.edge_centroids:
1529
1539
 
1530
1540
  # Get centroid coordinates (Z, Y, X)
@@ -1545,30 +1555,42 @@ class ImageViewerWindow(QMainWindow):
1545
1555
 
1546
1556
  else:
1547
1557
 
1548
- mode = 'nodes'
1549
1558
 
1550
1559
  if my_network.node_centroids is None:
1551
1560
  self.parent().show_centroid_dialog()
1552
1561
 
1553
1562
  num = (self.parent().channel_data[0].shape[0] * self.parent().channel_data[0].shape[1] * self.parent().channel_data[0].shape[2])
1554
1563
 
1564
+ if mode == 0:
1565
+ self.parent().clicked_values['nodes'] = [value]
1566
+ elif mode == 2:
1567
+
1568
+ coms = n3d.invert_dict(my_network.communities)
1569
+ self.parent().clicked_values['nodes'] = coms[value]
1570
+ com = value
1571
+ value = coms[value][0]
1572
+
1573
+
1555
1574
  if value in my_network.node_centroids:
1556
1575
  # Get centroid coordinates (Z, Y, X)
1557
1576
  centroid = my_network.node_centroids[value]
1558
1577
  # Set the active channel to nodes (0)
1559
- self.parent().set_active_channel(0)
1578
+ self.parent().set_active_channel(0)
1560
1579
  # Toggle on the nodes channel if it's not already visible
1561
1580
  if not self.parent().channel_visible[0]:
1562
1581
  self.parent().channel_buttons[0].setChecked(True)
1563
1582
  self.parent().toggle_channel(0)
1564
1583
  # Navigate to the Z-slice
1565
1584
  self.parent().slice_slider.setValue(int(centroid[0]))
1566
- print(f"Found node {value} at Z-slice {centroid[0]}")
1585
+ if mode == 0:
1586
+ print(f"Found node {value} at Z-slice {centroid[0]}")
1587
+ elif mode == 2:
1588
+ print(f"Found node {value} from community {com} at Z-slice {centroid[0]}")
1589
+
1567
1590
 
1568
1591
  else:
1569
1592
  print(f"Node {value} not found in centroids dictionary")
1570
1593
 
1571
- self.parent().clicked_values[mode] = [value]
1572
1594
 
1573
1595
  if num > self.parent().mini_thresh:
1574
1596
  self.parent().mini_overlay = True
@@ -2040,8 +2062,15 @@ class ImageViewerWindow(QMainWindow):
2040
2062
  def toggle_zoom_mode(self):
2041
2063
  """Toggle zoom mode on/off."""
2042
2064
  self.zoom_mode = self.zoom_button.isChecked()
2065
+
2043
2066
  if self.zoom_mode:
2044
2067
  self.pan_button.setChecked(False)
2068
+
2069
+ if self.pan_mode or self.brush_mode:
2070
+ current_xlim = self.ax.get_xlim()
2071
+ current_ylim = self.ax.get_ylim()
2072
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
2073
+
2045
2074
  self.pen_button.setChecked(False)
2046
2075
  self.pan_mode = False
2047
2076
  self.brush_mode = False
@@ -2062,6 +2091,11 @@ class ImageViewerWindow(QMainWindow):
2062
2091
  """Toggle pan mode on/off."""
2063
2092
  self.pan_mode = self.pan_button.isChecked()
2064
2093
  if self.pan_mode:
2094
+ if self.brush_mode:
2095
+ current_xlim = self.ax.get_xlim()
2096
+ current_ylim = self.ax.get_ylim()
2097
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
2098
+
2065
2099
  self.zoom_button.setChecked(False)
2066
2100
  self.pen_button.setChecked(False)
2067
2101
  self.zoom_mode = False
@@ -2073,6 +2107,9 @@ class ImageViewerWindow(QMainWindow):
2073
2107
  self.machine_window.silence_button()
2074
2108
  self.canvas.setCursor(Qt.CursorShape.OpenHandCursor)
2075
2109
  else:
2110
+ current_xlim = self.ax.get_xlim()
2111
+ current_ylim = self.ax.get_ylim()
2112
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
2076
2113
  if self.machine_window is None:
2077
2114
  self.canvas.setCursor(Qt.CursorShape.ArrowCursor)
2078
2115
  else:
@@ -2082,12 +2119,23 @@ class ImageViewerWindow(QMainWindow):
2082
2119
  """Toggle brush mode on/off"""
2083
2120
  self.brush_mode = self.pen_button.isChecked()
2084
2121
  if self.brush_mode:
2122
+
2123
+ if self.pan_mode:
2124
+ current_xlim = self.ax.get_xlim()
2125
+ current_ylim = self.ax.get_ylim()
2126
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
2127
+
2085
2128
  self.pan_button.setChecked(False)
2086
2129
  self.zoom_button.setChecked(False)
2087
2130
  self.pan_mode = False
2088
2131
  self.zoom_mode = False
2089
2132
  self.update_brush_cursor()
2090
2133
  else:
2134
+ # Get current zoom and do display update
2135
+ current_xlim = self.ax.get_xlim()
2136
+ current_ylim = self.ax.get_ylim()
2137
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
2138
+
2091
2139
  self.last_change = None
2092
2140
  self.can = False
2093
2141
  self.threed = False
@@ -2208,13 +2256,29 @@ class ImageViewerWindow(QMainWindow):
2208
2256
  self.high_button.click()
2209
2257
  if event.key() == Qt.Key_F and event.modifiers() == Qt.ShiftModifier:
2210
2258
  self.handle_find()
2259
+ if event.key() == Qt.Key_S and event.modifiers() == Qt.ControlModifier:
2260
+ self.handle_resave()
2261
+ if event.key() == Qt.Key_L and event.modifiers() == Qt.ControlModifier:
2262
+ self.load_from_network_obj(directory = self.last_load)
2211
2263
  if self.brush_mode and self.machine_window is None:
2212
2264
  if event.key() == Qt.Key_F:
2213
2265
  self.toggle_can()
2214
2266
  elif event.key() == Qt.Key_D:
2215
2267
  self.toggle_threed()
2216
2268
 
2269
+ def handle_resave(self, asbool = True):
2217
2270
 
2271
+ try:
2272
+
2273
+ if self.last_saved is None:
2274
+
2275
+ self.save_network_3d()
2276
+
2277
+ else:
2278
+ my_network.dump(parent_dir=self.last_saved, name=self.last_save_name)
2279
+
2280
+ except Exception as e:
2281
+ print(f"Error saving: {e}")
2218
2282
 
2219
2283
  def update_brush_cursor(self):
2220
2284
  """Update the cursor to show brush size"""
@@ -2329,7 +2393,7 @@ class ImageViewerWindow(QMainWindow):
2329
2393
  return data_coords[0], data_coords[1]
2330
2394
 
2331
2395
  def on_mouse_press(self, event):
2332
- """Handle mouse press events - OPTIMIZED VERSION."""
2396
+ """Handle mouse press events."""
2333
2397
  if event.inaxes != self.ax:
2334
2398
  return
2335
2399
 
@@ -2337,70 +2401,32 @@ class ImageViewerWindow(QMainWindow):
2337
2401
  self.pan_button.click()
2338
2402
  return
2339
2403
 
2340
- if self.zoom_mode:
2341
- # Handle zoom mode press
2342
- if self.original_xlim is None:
2343
- self.original_xlim = self.ax.get_xlim()
2344
- self.original_ylim = self.ax.get_ylim()
2345
-
2346
- current_xlim = self.ax.get_xlim()
2347
- current_ylim = self.ax.get_ylim()
2348
- xdata = event.xdata
2349
- ydata = event.ydata
2350
-
2351
- if event.button == 1: # Left click - zoom in
2352
- x_range = (current_xlim[1] - current_xlim[0]) / 4
2353
- y_range = (current_ylim[1] - current_ylim[0]) / 4
2354
-
2355
- self.ax.set_xlim([xdata - x_range, xdata + x_range])
2356
- self.ax.set_ylim([ydata - y_range, ydata + y_range])
2404
+ if self.pan_mode:
2357
2405
 
2358
- self.zoom_changed = True # Flag that zoom has changed
2406
+ self.panning = True
2407
+ self.pan_start = (event.xdata, event.ydata)
2408
+
2409
+ if self.pan_background_image is None:
2410
+
2411
+ if self.machine_window is not None:
2412
+ if self.machine_window.segmentation_worker is not None:
2413
+ self.machine_window.segmentation_worker.pause()
2359
2414
 
2360
- if not hasattr(self, 'zoom_changed'):
2361
- self.zoom_changed = False
2415
+ # Store current channel visibility state
2416
+ self.pre_pan_channel_state = self.channel_visible.copy()
2362
2417
 
2363
- elif event.button == 3: # Right click - zoom out
2364
- x_range = (current_xlim[1] - current_xlim[0])
2365
- y_range = (current_ylim[1] - current_ylim[0])
2418
+ # Create static background from currently visible channels
2419
+ self.create_pan_background()
2366
2420
 
2367
- new_xlim = [xdata - x_range, xdata + x_range]
2368
- new_ylim = [ydata - y_range, ydata + y_range]
2421
+ # Hide all channels and show only the background
2422
+ self.channel_visible = [False] * 4
2423
+ self.is_pan_preview = True
2369
2424
 
2370
- if (new_xlim[0] <= self.original_xlim[0] or
2371
- new_xlim[1] >= self.original_xlim[1] or
2372
- new_ylim[0] <= self.original_ylim[0] or
2373
- new_ylim[1] >= self.original_ylim[1]):
2374
- self.ax.set_xlim(self.original_xlim)
2375
- self.ax.set_ylim(self.original_ylim)
2376
- else:
2377
- self.ax.set_xlim(new_xlim)
2378
- self.ax.set_ylim(new_ylim)
2425
+ # Update display to show only background
2426
+ self.update_display_pan_mode()
2427
+ else:
2428
+ self.canvas.setCursor(Qt.CursorShape.ClosedHandCursor)
2379
2429
 
2380
- self.zoom_changed = False # Flag that zoom has changed
2381
-
2382
- if not hasattr(self, 'zoom_changed'):
2383
- self.zoom_changed = False
2384
-
2385
- self.canvas.draw()
2386
-
2387
- elif self.pan_mode:
2388
- self.panning = True
2389
- self.pan_start = (event.xdata, event.ydata)
2390
- self.canvas.setCursor(Qt.CursorShape.ClosedHandCursor)
2391
-
2392
- # Store current channel visibility state
2393
- self.pre_pan_channel_state = self.channel_visible.copy()
2394
-
2395
- # Create static background from currently visible channels
2396
- self.create_pan_background()
2397
-
2398
- # Hide all channels and show only the background
2399
- self.channel_visible = [False] * 4
2400
- self.is_pan_preview = True
2401
-
2402
- # Update display to show only background
2403
- self.update_display_pan_mode()
2404
2430
 
2405
2431
  elif self.brush_mode:
2406
2432
  if event.inaxes != self.ax:
@@ -2408,13 +2434,20 @@ class ImageViewerWindow(QMainWindow):
2408
2434
 
2409
2435
  if event.button == 1 or event.button == 3:
2410
2436
  x, y = int(event.xdata), int(event.ydata)
2437
+ # Get current zoom to preserve it
2438
+
2439
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2440
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2441
+
2411
2442
 
2412
2443
  if event.button == 1 and self.can:
2444
+ self.update_display(preserve_zoom = (current_xlim, current_ylim))
2413
2445
  self.handle_can(x, y)
2414
2446
  return
2415
2447
 
2416
2448
  if event.button == 3:
2417
2449
  self.erase = True
2450
+ self.update_display(preserve_zoom = (current_xlim, current_ylim))
2418
2451
  else:
2419
2452
  self.erase = False
2420
2453
 
@@ -2426,10 +2459,6 @@ class ImageViewerWindow(QMainWindow):
2426
2459
  else:
2427
2460
  channel = 2
2428
2461
 
2429
- # Get current zoom to preserve it
2430
- current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2431
- current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2432
-
2433
2462
  # Paint at initial position
2434
2463
  self.paint_at_position(x, y, self.erase, channel)
2435
2464
 
@@ -2441,11 +2470,22 @@ class ImageViewerWindow(QMainWindow):
2441
2470
 
2442
2471
  # No need to hide other channels or track restore_channels
2443
2472
  self.restore_channels = []
2444
-
2445
- self.update_display(preserve_zoom=(current_xlim, current_ylim), begin_paint=True)
2473
+
2474
+ if self.static_background is None:
2475
+ if self.machine_window is not None:
2476
+ self.update_display(preserve_zoom = (current_xlim, current_ylim))
2477
+ elif not self.erase:
2478
+ self.temp_chan = channel
2479
+ self.channel_data[4] = self.channel_data[channel]
2480
+ self.min_max[4] = copy.deepcopy(self.min_max[channel])
2481
+ self.channel_brightness[4] = copy.deepcopy(self.channel_brightness[channel])
2482
+ self.load_channel(channel, np.zeros_like(self.channel_data[channel]), data = True, preserve_zoom = (current_xlim, current_ylim), begin_paint = True)
2483
+ self.channel_visible[4] = True
2484
+ self.static_background = self.canvas.copy_from_bbox(self.ax.bbox)
2485
+
2446
2486
  self.update_display_slice_optimized(channel, preserve_zoom=(current_xlim, current_ylim))
2447
2487
 
2448
- elif event.button == 3: # Right click (for context menu)
2488
+ elif not self.zoom_mode and event.button == 3: # Right click (for context menu)
2449
2489
  self.create_context_menu(event)
2450
2490
 
2451
2491
  elif event.button == 1: # Left click
@@ -2461,7 +2501,10 @@ class ImageViewerWindow(QMainWindow):
2461
2501
  if erase:
2462
2502
  val = 0
2463
2503
  elif self.machine_window is None:
2464
- val = 255
2504
+ try:
2505
+ val = max(255, self.min_max[4][1])
2506
+ except:
2507
+ val = 255
2465
2508
  elif self.foreground:
2466
2509
  val = 1
2467
2510
  else:
@@ -2498,7 +2541,10 @@ class ImageViewerWindow(QMainWindow):
2498
2541
  if erase:
2499
2542
  val = 0
2500
2543
  elif machine_window is None:
2501
- val = 255
2544
+ try:
2545
+ val = max(255, self.min_max[4][1])
2546
+ except:
2547
+ val = 255
2502
2548
  elif foreground:
2503
2549
  val = 1
2504
2550
  else:
@@ -2536,6 +2582,9 @@ class ImageViewerWindow(QMainWindow):
2536
2582
 
2537
2583
  def handle_can(self, x, y):
2538
2584
 
2585
+ # Update the channel data
2586
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2587
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2539
2588
 
2540
2589
  if self.threed:
2541
2590
  ref = copy.deepcopy(self.channel_data[self.active_channel])
@@ -2559,9 +2608,8 @@ class ImageViewerWindow(QMainWindow):
2559
2608
 
2560
2609
  # Add this mask to the original slice
2561
2610
  the_slice = the_slice | fill_mask # Use logical OR to add the filled region
2562
-
2563
- # Update the channel data
2564
- self.load_channel(self.active_channel, the_slice, True)
2611
+
2612
+ self.load_channel(self.active_channel, the_slice, True, preserve_zoom = (current_xlim, current_ylim))
2565
2613
  else:
2566
2614
 
2567
2615
  ref = copy.deepcopy(self.channel_data[self.active_channel])
@@ -2589,7 +2637,7 @@ class ImageViewerWindow(QMainWindow):
2589
2637
 
2590
2638
  # Update the channel data
2591
2639
  self.channel_data[self.active_channel][self.current_slice] = the_slice
2592
- self.load_channel(self.active_channel, self.channel_data[self.active_channel], True)
2640
+ self.load_channel(self.active_channel, self.channel_data[self.active_channel], True, preserve_zoom = (current_xlim, current_ylim))
2593
2641
 
2594
2642
 
2595
2643
 
@@ -2601,7 +2649,7 @@ class ImageViewerWindow(QMainWindow):
2601
2649
 
2602
2650
  current_time = time.time()
2603
2651
 
2604
- if self.selection_start and not self.selecting and not self.pan_mode and not self.zoom_mode and not self.brush_mode:
2652
+ if self.selection_start and not self.selecting and not self.pan_mode and not self.brush_mode:
2605
2653
  if (abs(event.xdata - self.selection_start[0]) > 1 or
2606
2654
  abs(event.ydata - self.selection_start[1]) > 1):
2607
2655
  self.selecting = True
@@ -2632,6 +2680,7 @@ class ImageViewerWindow(QMainWindow):
2632
2680
  self.canvas.blit(self.ax.bbox)
2633
2681
 
2634
2682
  elif self.panning and self.pan_start is not None:
2683
+
2635
2684
  # Calculate the movement
2636
2685
  dx = event.xdata - self.pan_start[0]
2637
2686
  dy = event.ydata - self.pan_start[1]
@@ -2727,7 +2776,6 @@ class ImageViewerWindow(QMainWindow):
2727
2776
  def end_paint_session(self):
2728
2777
  """Clean up after paint session."""
2729
2778
  self.paint_session_active = False
2730
- self.paint_background = None
2731
2779
  self.last_paint_pos = None
2732
2780
 
2733
2781
  def paint_worker_loop(self):
@@ -2960,6 +3008,7 @@ class ImageViewerWindow(QMainWindow):
2960
3008
 
2961
3009
  def update_display_pan_mode(self):
2962
3010
  """Lightweight display update for pan preview mode"""
3011
+
2963
3012
  if self.is_pan_preview and self.pan_background_image is not None:
2964
3013
  # Clear and setup axes
2965
3014
  self.ax.clear()
@@ -3007,26 +3056,18 @@ class ImageViewerWindow(QMainWindow):
3007
3056
  if z1 == z2 == self.current_slice:
3008
3057
  self.ax.plot([x1, x2], [y1, y2], 'r--', alpha=0.5)
3009
3058
 
3059
+ self.canvas.setCursor(Qt.CursorShape.ClosedHandCursor)
3060
+
3010
3061
  self.canvas.draw_idle()
3011
3062
 
3012
3063
  def on_mouse_release(self, event):
3013
- """Handle mouse release events - OPTIMIZED VERSION."""
3064
+ """Handle mouse release events"""
3014
3065
  if self.pan_mode:
3015
- # Get current view limits before restoring channels
3016
- current_xlim = self.ax.get_xlim()
3017
- current_ylim = self.ax.get_ylim()
3018
3066
 
3019
3067
  self.panning = False
3020
3068
  self.pan_start = None
3021
3069
  self.canvas.setCursor(Qt.CursorShape.OpenHandCursor)
3022
3070
 
3023
- # Restore previously visible channels
3024
- self.channel_visible = self.pre_pan_channel_state.copy()
3025
- self.is_pan_preview = False
3026
- self.pan_background = None
3027
-
3028
- # Update display with preserved zoom at new position
3029
- self.update_display(preserve_zoom=(current_xlim, current_ylim))
3030
3071
  elif event.button == 1: # Left button release
3031
3072
  if self.selecting and self.selection_rect is not None:
3032
3073
  # Get the rectangle bounds
@@ -3034,9 +3075,20 @@ class ImageViewerWindow(QMainWindow):
3034
3075
  y0 = min(self.selection_start[1], event.ydata)
3035
3076
  width = abs(event.xdata - self.selection_start[0])
3036
3077
  height = abs(event.ydata - self.selection_start[1])
3078
+ shift_pressed = 'shift' in event.modifiers
3079
+
3080
+ if shift_pressed or self.zoom_mode: #Optional targeted zoom
3081
+
3082
+ self.ax.set_xlim([x0, x0 + width])
3083
+ self.ax.set_ylim([y0 + height, y0])
3084
+
3085
+ self.zoom_changed = True # Flag that zoom has changed
3086
+
3087
+ if not hasattr(self, 'zoom_changed'):
3088
+ self.zoom_changed = False
3037
3089
 
3038
3090
  # Get current slice data for active channel
3039
- if self.channel_data[self.active_channel] is not None:
3091
+ elif self.channel_data[self.active_channel] is not None:
3040
3092
  data = self.channel_data[self.active_channel][self.current_slice]
3041
3093
 
3042
3094
  # Convert coordinates to array indices
@@ -3102,32 +3154,85 @@ class ImageViewerWindow(QMainWindow):
3102
3154
  self.selection_rect = None
3103
3155
  self.canvas.draw()
3104
3156
 
3105
- # OPTIMIZED: Handle brush mode cleanup with paint session management
3106
- if self.brush_mode and hasattr(self, 'painting') and self.painting:
3107
- self.painting = False
3108
-
3109
- # Restore hidden channels
3110
- try:
3111
- for i in self.restore_channels:
3112
- self.channel_visible[i] = True
3113
- self.restore_channels = []
3114
- except:
3115
- pass
3157
+ elif self.zoom_mode:
3158
+ # Handle zoom mode press
3159
+ if self.original_xlim is None:
3160
+ self.original_xlim = self.ax.get_xlim()
3161
+ #print(self.original_xlim)
3162
+ self.original_ylim = self.ax.get_ylim()
3163
+ #print(self.original_ylim)
3116
3164
 
3117
- # OPTIMIZED: End paint session and ensure all operations complete
3118
- self.end_paint_session()
3165
+ current_xlim = self.ax.get_xlim()
3166
+ current_ylim = self.ax.get_ylim()
3167
+ xdata = event.xdata
3168
+ ydata = event.ydata
3119
3169
 
3120
- # OPTIMIZED: Stop timer and process any pending paint operations
3121
- if hasattr(self, 'paint_timer'):
3122
- self.paint_timer.stop()
3123
- if hasattr(self, 'pending_paint_update') and self.pending_paint_update:
3124
- self.flush_paint_updates()
3170
+ if event.button == 1: # Left click - zoom in
3171
+ x_range = (current_xlim[1] - current_xlim[0]) / 4
3172
+ y_range = (current_ylim[1] - current_ylim[0]) / 4
3173
+
3174
+ self.ax.set_xlim([xdata - x_range, xdata + x_range])
3175
+ self.ax.set_ylim([ydata - y_range, ydata + y_range])
3176
+
3177
+ self.zoom_changed = True # Flag that zoom has changed
3178
+
3179
+ if not hasattr(self, 'zoom_changed'):
3180
+ self.zoom_changed = False
3181
+
3182
+ elif event.button == 3: # Right click - zoom out
3183
+ x_range = (current_xlim[1] - current_xlim[0])
3184
+ y_range = (current_ylim[1] - current_ylim[0])
3185
+
3186
+ new_xlim = [xdata - x_range, xdata + x_range]
3187
+ new_ylim = [ydata - y_range, ydata + y_range]
3188
+
3189
+ if (new_xlim[0] <= self.original_xlim[0] or
3190
+ new_xlim[1] >= self.original_xlim[1] or
3191
+ new_ylim[0] <= self.original_ylim[0] or
3192
+ new_ylim[1] >= self.original_ylim[1]):
3193
+ self.ax.set_xlim(self.original_xlim)
3194
+ self.ax.set_ylim(self.original_ylim)
3195
+ else:
3196
+ self.ax.set_xlim(new_xlim)
3197
+ self.ax.set_ylim(new_ylim)
3198
+
3199
+ self.zoom_changed = False # Flag that zoom has changed
3200
+
3201
+ if not hasattr(self, 'zoom_changed'):
3202
+ self.zoom_changed = False
3125
3203
 
3126
- # Get current zoom and do final display update
3127
- current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3128
- current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3129
- self.update_display(preserve_zoom=(current_xlim, current_ylim))
3204
+ self.canvas.draw()
3130
3205
 
3206
+ # Handle brush mode cleanup with paint session management
3207
+ if self.brush_mode and hasattr(self, 'painting') and self.painting:
3208
+ self.painting = False
3209
+
3210
+ if self.erase:
3211
+ # Restore hidden channels
3212
+ try:
3213
+ for i in self.restore_channels:
3214
+ self.channel_visible[i] = True
3215
+ self.restore_channels = []
3216
+ except:
3217
+ pass
3218
+
3219
+ self.end_paint_session()
3220
+
3221
+ # OPTIMIZED: Stop timer and process any pending paint operations
3222
+ if hasattr(self, 'paint_timer'):
3223
+ self.paint_timer.stop()
3224
+ if hasattr(self, 'pending_paint_update') and self.pending_paint_update:
3225
+ self.flush_paint_updates()
3226
+
3227
+ self.static_background = None
3228
+
3229
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3230
+
3231
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3232
+
3233
+ self.update_display(preserve_zoom = (current_xlim, current_ylim))
3234
+
3235
+
3131
3236
 
3132
3237
  def highlight_value_in_tables(self, clicked_value):
3133
3238
  """Helper method to find and highlight a value in both tables."""
@@ -3381,7 +3486,7 @@ class ImageViewerWindow(QMainWindow):
3381
3486
  # Create Load submenu
3382
3487
  load_menu = file_menu.addMenu("Load")
3383
3488
  network_load = load_menu.addAction("Load Network3D Object")
3384
- network_load.triggered.connect(self.load_from_network_obj)
3489
+ network_load.triggered.connect(lambda: self.load_from_network_obj(None))
3385
3490
  for i in range(4):
3386
3491
  load_action = load_menu.addAction(f"Load {self.channel_names[i]}")
3387
3492
  load_action.triggered.connect(lambda checked, ch=i: self.load_channel(ch))
@@ -3396,6 +3501,8 @@ class ImageViewerWindow(QMainWindow):
3396
3501
  load_action.triggered.connect(lambda: self.load_misc('Node Centroids'))
3397
3502
  load_action = misc_menu.addAction("Load Edge Centroids")
3398
3503
  load_action.triggered.connect(lambda: self.load_misc('Edge Centroids'))
3504
+ load_action = misc_menu.addAction("Load Node Communities")
3505
+ load_action.triggered.connect(lambda: self.load_misc('Communities'))
3399
3506
  load_action = misc_menu.addAction("Merge Nodes")
3400
3507
  load_action.triggered.connect(lambda: self.load_misc('Merge Nodes'))
3401
3508
  load_action = misc_menu.addAction("Merge Node IDs from Images")
@@ -3413,7 +3520,7 @@ class ImageViewerWindow(QMainWindow):
3413
3520
  partition_action.triggered.connect(self.show_partition_dialog)
3414
3521
  com_identity_action = network_menu.addAction("Identity Makeup of Network Communities (and UMAP)")
3415
3522
  com_identity_action.triggered.connect(self.handle_com_id)
3416
- com_neighbor_action = network_menu.addAction("Convert Network Communities into Neighborhoods?")
3523
+ com_neighbor_action = network_menu.addAction("Convert Network Communities into Neighborhoods? (Also Returns Compositional Heatmaps)")
3417
3524
  com_neighbor_action.triggered.connect(self.handle_com_neighbor)
3418
3525
  com_cell_action = network_menu.addAction("Create Communities Based on Cuboidal Proximity Cells?")
3419
3526
  com_cell_action.triggered.connect(self.handle_com_cell)
@@ -3537,14 +3644,29 @@ class ImageViewerWindow(QMainWindow):
3537
3644
  shuffle_action.triggered.connect(self.show_shuffle_dialog)
3538
3645
  arbitrary_action = image_menu.addAction("Select Objects")
3539
3646
  arbitrary_action.triggered.connect(self.show_arbitrary_dialog)
3540
- show3d_action = image_menu.addAction("Show 3D (Napari)")
3647
+ show3d_action = image_menu.addAction("Show 3D (Requires Napari)")
3541
3648
  show3d_action.triggered.connect(self.show3d_dialog)
3649
+ cellpose_action = image_menu.addAction("Cellpose (Requires Cellpose GUI installed)")
3650
+ cellpose_action.triggered.connect(self.open_cellpose)
3542
3651
 
3543
3652
  # Help
3544
3653
 
3545
3654
  help_button = menubar.addAction("Help")
3546
3655
  help_button.triggered.connect(self.help_me)
3547
3656
 
3657
+ def open_cellpose(self):
3658
+
3659
+ try:
3660
+
3661
+ from . import cellpose_manager
3662
+ self.cellpose_launcher = cellpose_manager.CellposeGUILauncher(parent_widget=self)
3663
+
3664
+ self.cellpose_launcher.launch_cellpose_gui()
3665
+
3666
+ except:
3667
+ pass
3668
+
3669
+
3548
3670
  def help_me(self):
3549
3671
 
3550
3672
  import webbrowser
@@ -3694,94 +3816,103 @@ class ImageViewerWindow(QMainWindow):
3694
3816
 
3695
3817
 
3696
3818
  def format_for_upperright_table(self, data, metric='Metric', value='Value', title=None):
3697
- """
3698
- Format dictionary or list data for display in upper right table.
3699
-
3700
- Args:
3701
- data: Dictionary with keys and single/multiple values, or a list of values
3702
- metric: String for the key/index column header
3703
- value: String or list of strings for value column headers (used for dictionaries only)
3704
- title: Optional custom title for the tab
3705
- """
3706
- def convert_to_numeric(val):
3707
- """Helper function to convert strings to numeric types when possible"""
3708
- if isinstance(val, str):
3709
- try:
3710
- # First try converting to int
3711
- if '.' not in val:
3712
- return int(val)
3713
- # If that fails or if there's a decimal point, try float
3714
- return float(val)
3715
- except ValueError:
3716
- return val
3717
- return val
3718
-
3719
- try:
3720
-
3721
- if isinstance(data, (list, tuple, np.ndarray)):
3722
- # Handle list input - create single column DataFrame
3723
- df = pd.DataFrame({
3724
- metric: [convert_to_numeric(val) for val in data]
3725
- })
3726
-
3727
- # Format floating point numbers
3728
- df[metric] = df[metric].apply(lambda x: f"{x:.3f}" if isinstance(x, (float, np.float64)) else str(x))
3729
-
3730
- else: # Dictionary input
3731
- # Get sample value to determine structure
3732
- sample_value = next(iter(data.values()))
3733
- is_multi_value = isinstance(sample_value, (list, tuple, np.ndarray))
3734
-
3735
- if is_multi_value:
3736
- # Handle multi-value case
3737
- if isinstance(value, str):
3738
- # If single string provided for multi-values, generate numbered headers
3739
- n_cols = len(sample_value)
3740
- value_headers = [f"{value}_{i+1}" for i in range(n_cols)]
3741
- else:
3742
- # Use provided list of headers
3743
- value_headers = value
3744
- if len(value_headers) != len(sample_value):
3745
- raise ValueError("Number of headers must match number of values per key")
3746
-
3747
- # Create lists for each column
3748
- dict_data = {metric: list(data.keys())}
3749
- for i, header in enumerate(value_headers):
3750
- # Convert values to numeric when possible before adding to DataFrame
3751
- dict_data[header] = [convert_to_numeric(data[key][i]) for key in data.keys()]
3752
-
3753
- df = pd.DataFrame(dict_data)
3754
-
3755
- # Format floating point numbers in all value columns
3756
- for header in value_headers:
3757
- df[header] = df[header].apply(lambda x: f"{x:.3f}" if isinstance(x, (float, np.float64)) else str(x))
3758
-
3759
- else:
3760
- # Single-value case
3761
- df = pd.DataFrame({
3762
- metric: data.keys(),
3763
- value: [convert_to_numeric(val) for val in data.values()]
3764
- })
3765
-
3766
- # Format floating point numbers
3767
- df[value] = df[value].apply(lambda x: f"{x:.3f}" if isinstance(x, (float, np.float64)) else str(x))
3768
-
3769
- # Create new table
3770
- table = CustomTableView(self)
3771
- table.setModel(PandasModel(df))
3772
-
3773
- # Add to tabbed widget
3774
- if title is None:
3775
- self.tabbed_data.add_table(f"{metric} Analysis", table)
3776
- else:
3777
- self.tabbed_data.add_table(f"{title}", table)
3778
-
3779
- # Adjust column widths to content
3780
- for column in range(table.model().columnCount(None)):
3781
- table.resizeColumnToContents(column)
3782
-
3783
- except:
3784
- pass
3819
+ """
3820
+ Format dictionary or list data for display in upper right table.
3821
+
3822
+ Args:
3823
+ data: Dictionary with keys and single/multiple values, or a list of values
3824
+ metric: String for the key/index column header
3825
+ value: String or list of strings for value column headers (used for dictionaries only)
3826
+ title: Optional custom title for the tab
3827
+ """
3828
+ def convert_to_numeric(val):
3829
+ """Helper function to convert strings to numeric types when possible"""
3830
+ if isinstance(val, str):
3831
+ try:
3832
+ # First try converting to int
3833
+ if '.' not in val:
3834
+ return int(val)
3835
+ # If that fails or if there's a decimal point, try float
3836
+ return float(val)
3837
+ except ValueError:
3838
+ return val
3839
+ return val
3840
+
3841
+ def format_number(x):
3842
+ """Smart formatting that removes trailing zeros"""
3843
+ if not isinstance(x, (float, np.float64)):
3844
+ return str(x)
3845
+
3846
+ # Use more decimal places, then strip trailing zeros
3847
+ formatted = f"{x:.8f}".rstrip('0').rstrip('.')
3848
+ return formatted if formatted else "0"
3849
+
3850
+ try:
3851
+
3852
+ if isinstance(data, (list, tuple, np.ndarray)):
3853
+ # Handle list input - create single column DataFrame
3854
+ df = pd.DataFrame({
3855
+ metric: [convert_to_numeric(val) for val in data]
3856
+ })
3857
+
3858
+ # Format floating point numbers
3859
+ df[metric] = df[metric].apply(format_number)
3860
+
3861
+ else: # Dictionary input
3862
+ # Get sample value to determine structure
3863
+ sample_value = next(iter(data.values()))
3864
+ is_multi_value = isinstance(sample_value, (list, tuple, np.ndarray))
3865
+
3866
+ if is_multi_value:
3867
+ # Handle multi-value case
3868
+ if isinstance(value, str):
3869
+ # If single string provided for multi-values, generate numbered headers
3870
+ n_cols = len(sample_value)
3871
+ value_headers = [f"{value}_{i+1}" for i in range(n_cols)]
3872
+ else:
3873
+ # Use provided list of headers
3874
+ value_headers = value
3875
+ if len(value_headers) != len(sample_value):
3876
+ raise ValueError("Number of headers must match number of values per key")
3877
+
3878
+ # Create lists for each column
3879
+ dict_data = {metric: list(data.keys())}
3880
+ for i, header in enumerate(value_headers):
3881
+ # Convert values to numeric when possible before adding to DataFrame
3882
+ dict_data[header] = [convert_to_numeric(data[key][i]) for key in data.keys()]
3883
+
3884
+ df = pd.DataFrame(dict_data)
3885
+
3886
+ # Format floating point numbers in all value columns
3887
+ for header in value_headers:
3888
+ df[header] = df[header].apply(format_number)
3889
+
3890
+ else:
3891
+ # Single-value case
3892
+ df = pd.DataFrame({
3893
+ metric: data.keys(),
3894
+ value: [convert_to_numeric(val) for val in data.values()]
3895
+ })
3896
+
3897
+ # Format floating point numbers
3898
+ df[value] = df[value].apply(format_number)
3899
+
3900
+ # Create new table
3901
+ table = CustomTableView(self)
3902
+ table.setModel(PandasModel(df))
3903
+
3904
+ # Add to tabbed widget
3905
+ if title is None:
3906
+ self.tabbed_data.add_table(f"{metric} Analysis", table)
3907
+ else:
3908
+ self.tabbed_data.add_table(f"{title}", table)
3909
+
3910
+ # Adjust column widths to content
3911
+ for column in range(table.model().columnCount(None)):
3912
+ table.resizeColumnToContents(column)
3913
+
3914
+ except:
3915
+ pass
3785
3916
 
3786
3917
  def show_merge_node_id_dialog(self):
3787
3918
 
@@ -4093,6 +4224,14 @@ class ImageViewerWindow(QMainWindow):
4093
4224
  self.format_for_upperright_table(my_network.edge_centroids, 'EdgeID', ['Z', 'Y', 'X'], 'Edge Centroids')
4094
4225
  except Exception as e:
4095
4226
  print(f"Error loading edge centroid table: {e}")
4227
+ elif sort == 'Communities':
4228
+ my_network.load_communities(file_path = filename)
4229
+
4230
+ if hasattr(my_network, 'communities') and my_network.communities is not None:
4231
+ try:
4232
+ self.format_for_upperright_table(my_network.communities, 'NodeID', 'Identity', 'Node Communities')
4233
+ except Exception as e:
4234
+ print(f"Error loading edge centroid table: {e}")
4096
4235
 
4097
4236
 
4098
4237
  except Exception as e:
@@ -4164,14 +4303,19 @@ class ImageViewerWindow(QMainWindow):
4164
4303
 
4165
4304
 
4166
4305
  # Modify load_from_network_obj method
4167
- def load_from_network_obj(self):
4306
+ def load_from_network_obj(self, directory = None):
4168
4307
  try:
4169
- directory = QFileDialog.getExistingDirectory(
4170
- self,
4171
- f"Select Directory for Network3D Object",
4172
- "",
4173
- QFileDialog.Option.ShowDirsOnly
4174
- )
4308
+
4309
+ if directory is None:
4310
+
4311
+ directory = QFileDialog.getExistingDirectory(
4312
+ self,
4313
+ f"Select Directory for Network3D Object",
4314
+ "",
4315
+ QFileDialog.Option.ShowDirsOnly
4316
+ )
4317
+
4318
+ self.last_load = directory
4175
4319
 
4176
4320
  if directory != "":
4177
4321
 
@@ -4393,7 +4537,7 @@ class ImageViewerWindow(QMainWindow):
4393
4537
  # Update button appearances to show active channel
4394
4538
  for i, btn in enumerate(self.channel_buttons):
4395
4539
  if i == index and btn.isEnabled():
4396
- btn.setStyleSheet("background-color: lightblue;")
4540
+ btn.setStyleSheet("font-weight: bold; color: yellow;")
4397
4541
  else:
4398
4542
  btn.setStyleSheet("")
4399
4543
 
@@ -4458,7 +4602,7 @@ class ImageViewerWindow(QMainWindow):
4458
4602
  msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
4459
4603
  return msg.exec() == QMessageBox.StandardButton.Yes
4460
4604
 
4461
- def load_channel(self, channel_index, channel_data=None, data=False, assign_shape = True):
4605
+ def load_channel(self, channel_index, channel_data=None, data=False, assign_shape = True, preserve_zoom = None, end_paint = False, begin_paint = False):
4462
4606
  """Load a channel and enable active channel selection if needed."""
4463
4607
 
4464
4608
  try:
@@ -4509,6 +4653,9 @@ class ImageViewerWindow(QMainWindow):
4509
4653
 
4510
4654
  else:
4511
4655
  self.channel_data[channel_index] = channel_data
4656
+ if channel_data is None:
4657
+ self.channel_buttons[channel_index].setEnabled(False)
4658
+ self.delete_buttons[channel_index].setEnabled(False)
4512
4659
 
4513
4660
  if len(self.channel_data[channel_index].shape) == 2: # handle 2d data
4514
4661
  self.channel_data[channel_index] = np.expand_dims(self.channel_data[channel_index], axis=0)
@@ -4551,15 +4698,15 @@ class ImageViewerWindow(QMainWindow):
4551
4698
  self.channel_data[channel_index] = n3d.upsample_with_padding(self.channel_data[channel_index], original_shape = old_shape)
4552
4699
  break
4553
4700
 
4554
-
4555
- if channel_index == 0:
4556
- my_network.nodes = self.channel_data[channel_index]
4557
- elif channel_index == 1:
4558
- my_network.edges = self.channel_data[channel_index]
4559
- elif channel_index == 2:
4560
- my_network.network_overlay = self.channel_data[channel_index]
4561
- elif channel_index == 3:
4562
- my_network.id_overlay = self.channel_data[channel_index]
4701
+ if not begin_paint:
4702
+ if channel_index == 0:
4703
+ my_network.nodes = self.channel_data[channel_index]
4704
+ elif channel_index == 1:
4705
+ my_network.edges = self.channel_data[channel_index]
4706
+ elif channel_index == 2:
4707
+ my_network.network_overlay = self.channel_data[channel_index]
4708
+ elif channel_index == 3:
4709
+ my_network.id_overlay = self.channel_data[channel_index]
4563
4710
 
4564
4711
  # Enable the channel button
4565
4712
  self.channel_buttons[channel_index].setEnabled(True)
@@ -4621,9 +4768,12 @@ class ImageViewerWindow(QMainWindow):
4621
4768
  self.shape = self.channel_data[channel_index].shape
4622
4769
 
4623
4770
  self.img_height, self.img_width = self.shape[1], self.shape[2]
4771
+ self.original_ylim, self.original_xlim = (self.shape[1] + 0.5, -0.5), (-0.5, self.shape[2] - 0.5)
4772
+ #print(self.original_xlim)
4624
4773
 
4774
+ if not end_paint:
4625
4775
 
4626
- self.update_display(reset_resize = reset_resize)
4776
+ self.update_display(reset_resize = reset_resize, preserve_zoom = preserve_zoom)
4627
4777
 
4628
4778
 
4629
4779
 
@@ -4756,6 +4906,8 @@ class ImageViewerWindow(QMainWindow):
4756
4906
  # Call appropriate save method
4757
4907
  if asbool:
4758
4908
  my_network.dump(parent_dir=parent_dir, name=new_folder_name)
4909
+ self.last_saved = parent_dir
4910
+ self.last_save_name = new_folder_name
4759
4911
  else:
4760
4912
  my_network.dump(name='my_network')
4761
4913
 
@@ -4817,10 +4969,11 @@ class ImageViewerWindow(QMainWindow):
4817
4969
  # Store current zoom settings before toggling
4818
4970
  current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
4819
4971
  current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
4820
-
4972
+
4821
4973
  self.channel_visible[channel_index] = self.channel_buttons[channel_index].isChecked()
4822
4974
  self.update_display(preserve_zoom=(current_xlim, current_ylim))
4823
4975
 
4976
+
4824
4977
 
4825
4978
  def update_slice(self):
4826
4979
  """Queue a slice update when slider moves."""
@@ -4858,24 +5011,60 @@ class ImageViewerWindow(QMainWindow):
4858
5011
  current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
4859
5012
  # Convert slider values (0-100) to data values (0-1)
4860
5013
  min_val, max_val = values
4861
- self.channel_brightness[channel_index]['min'] = min_val / 65535 #Accomodate 32 bit data?
5014
+ self.channel_brightness[channel_index]['min'] = min_val / 65535
4862
5015
  self.channel_brightness[channel_index]['max'] = max_val / 65535
4863
5016
  self.update_display(preserve_zoom = (current_xlim, current_ylim))
4864
5017
 
4865
5018
 
4866
5019
 
4867
5020
 
4868
- def update_display(self, preserve_zoom=None, dims = None, called = False, reset_resize = False, begin_paint = False):
5021
+ def update_display(self, preserve_zoom=None, dims = None, called = False, reset_resize = False):
4869
5022
  """Update the display with currently visible channels and highlight overlay."""
4870
5023
 
4871
5024
  try:
4872
5025
 
4873
- if begin_paint:
4874
- # Store/update the static background with current zoom level
4875
- self.static_background = self.canvas.copy_from_bbox(self.ax.bbox)
5026
+ self.figure.clear()
4876
5027
 
5028
+ if self.pan_background_image is not None:
5029
+ # Restore previously visible channels
5030
+ self.channel_visible = self.pre_pan_channel_state.copy()
5031
+ self.is_pan_preview = False
5032
+ self.pan_background_image = None
4877
5033
 
4878
- self.figure.clear()
5034
+ if self.machine_window is not None:
5035
+ if self.machine_window.segmentation_worker is not None:
5036
+ self.machine_window.segmentation_worker.resume()
5037
+
5038
+ if self.static_background is not None:
5039
+
5040
+ # Restore hidden channels
5041
+ try:
5042
+ for i in self.restore_channels:
5043
+ self.channel_visible[i] = True
5044
+ self.restore_channels = []
5045
+ except:
5046
+ pass
5047
+
5048
+ self.end_paint_session()
5049
+
5050
+ # OPTIMIZED: Stop timer and process any pending paint operations
5051
+ if hasattr(self, 'paint_timer'):
5052
+ self.paint_timer.stop()
5053
+ if hasattr(self, 'pending_paint_update') and self.pending_paint_update:
5054
+ self.flush_paint_updates()
5055
+
5056
+ self.static_background = None
5057
+
5058
+ if self.machine_window is None:
5059
+
5060
+ try:
5061
+
5062
+ self.channel_data[4][self.current_slice, :, :] = n3d.overlay_arrays_simple(self.channel_data[self.temp_chan][self.current_slice, :, :], self.channel_data[4][self.current_slice, :, :])
5063
+ self.load_channel(self.temp_chan, self.channel_data[4], data = True, end_paint = True)
5064
+ self.channel_data[4] = None
5065
+ self.channel_visible[4] = False
5066
+ except:
5067
+ pass
4879
5068
 
4880
5069
  # Get active channels and their dimensions
4881
5070
  active_channels = [i for i in range(4) if self.channel_data[i] is not None]
@@ -4949,9 +5138,6 @@ class ImageViewerWindow(QMainWindow):
4949
5138
  else:
4950
5139
  vmin = img_min + (img_max - img_min) * self.channel_brightness[channel]['min']
4951
5140
  vmax = img_min + (img_max - img_min) * self.channel_brightness[channel]['max']
4952
-
4953
-
4954
-
4955
5141
 
4956
5142
  # Normalize the image safely
4957
5143
  if vmin == vmax:
@@ -5024,9 +5210,39 @@ class ImageViewerWindow(QMainWindow):
5024
5210
  vmax=2, # Important: set vmax to 2 to accommodate both values
5025
5211
  alpha=0.5)
5026
5212
 
5213
+ if self.channel_data[4] is not None:
5027
5214
 
5215
+ highlight_slice = self.channel_data[4][self.current_slice]
5216
+ img_min = self.min_max[4][0]
5217
+ img_max = self.min_max[4][1]
5218
+
5219
+ # Calculate vmin and vmax, ensuring we don't get a zero range
5220
+ if img_min == img_max:
5221
+ vmin = img_min
5222
+ vmax = img_min + 1
5223
+ else:
5224
+ vmin = img_min + (img_max - img_min) * self.channel_brightness[4]['min']
5225
+ vmax = img_min + (img_max - img_min) * self.channel_brightness[4]['max']
5226
+
5227
+ # Normalize the image safely
5228
+ if vmin == vmax:
5229
+ normalized_image = np.zeros_like(highlight_slice)
5230
+ else:
5231
+ normalized_image = np.clip((highlight_slice - vmin) / (vmax - vmin), 0, 1)
5232
+
5233
+ color = base_colors[self.temp_chan]
5234
+ custom_cmap = LinearSegmentedColormap.from_list(
5235
+ f'custom_{4}',
5236
+ [(0,0,0,0), (*color,1)]
5237
+ )
5238
+
5239
+
5240
+ self.ax.imshow(normalized_image,
5241
+ alpha=0.7,
5242
+ cmap=custom_cmap,
5243
+ vmin=0,
5244
+ vmax=1)
5028
5245
 
5029
-
5030
5246
  # Style the axes
5031
5247
  self.ax.set_xlabel('X')
5032
5248
  self.ax.set_ylabel('Y')
@@ -5414,6 +5630,9 @@ class CustomTableView(QTableView):
5414
5630
  save_menu = context_menu.addMenu("Save As")
5415
5631
  save_csv = save_menu.addAction("CSV")
5416
5632
  save_excel = save_menu.addAction("Excel")
5633
+ close_action = context_menu.addAction("Close All")
5634
+
5635
+ close_action.triggered.connect(self.close_all)
5417
5636
 
5418
5637
  # Connect the actions
5419
5638
  save_csv.triggered.connect(lambda: self.save_table_as('csv'))
@@ -5621,7 +5840,9 @@ class CustomTableView(QTableView):
5621
5840
  except Exception as e:
5622
5841
  print(f"Error setting new network: {e}")
5623
5842
 
5843
+ def close_all(self):
5624
5844
 
5845
+ self.parent.tabbed_data.clear_all_tabs()
5625
5846
 
5626
5847
  def handle_find_action(self, row, column, value):
5627
5848
  """Handle the Find action for bottom tables."""
@@ -6920,10 +7141,7 @@ class NetShowDialog(QDialog):
6920
7141
 
6921
7142
  def show_network(self):
6922
7143
  # Get parameters and run analysis
6923
- if my_network.communities is None:
6924
- self.parent().show_partition_dialog()
6925
- if my_network.communities is None:
6926
- return
7144
+
6927
7145
  geo = self.geo_layout.isChecked()
6928
7146
  if geo:
6929
7147
  if my_network.node_centroids is None:
@@ -6934,6 +7152,13 @@ class NetShowDialog(QDialog):
6934
7152
 
6935
7153
  weighted = self.weighted.isChecked()
6936
7154
 
7155
+ if accepted_mode == 1:
7156
+
7157
+ if my_network.communities is None:
7158
+ self.parent().show_partition_dialog()
7159
+ if my_network.communities is None:
7160
+ return
7161
+
6937
7162
  try:
6938
7163
  if accepted_mode == 0:
6939
7164
  my_network.show_network(geometric=geo, directory = directory)
@@ -6973,7 +7198,7 @@ class PartitionDialog(QDialog):
6973
7198
  # stats checkbox (default True)
6974
7199
  self.stats = QPushButton("Stats")
6975
7200
  self.stats.setCheckable(True)
6976
- self.stats.setChecked(True)
7201
+ self.stats.setChecked(False)
6977
7202
  layout.addRow("Community Stats:", self.stats)
6978
7203
 
6979
7204
  self.seed = QLineEdit("")
@@ -7000,7 +7225,7 @@ class PartitionDialog(QDialog):
7000
7225
 
7001
7226
  try:
7002
7227
  stats = my_network.community_partition(weighted = weighted, style = accepted_mode, dostats = dostats, seed = seed)
7003
- print(f"Discovered communities: {my_network.communities}")
7228
+ #print(f"Discovered communities: {my_network.communities}")
7004
7229
 
7005
7230
  self.parent().format_for_upperright_table(my_network.communities, 'NodeID', 'CommunityID', title = 'Community Partition')
7006
7231
 
@@ -7039,6 +7264,14 @@ class ComIdDialog(QDialog):
7039
7264
  self.label.setChecked(False)
7040
7265
  layout.addRow("If using above - label UMAP points?:", self.label)
7041
7266
 
7267
+ self.limit = QLineEdit("")
7268
+ layout.addRow("Min Community Size for UMAP (Smaller communities will be ignored in graph, does not apply if empty)", self.limit)
7269
+
7270
+ # weighted checkbox (default True)
7271
+ self.proportional = QPushButton("Robust")
7272
+ self.proportional.setCheckable(True)
7273
+ self.proportional.setChecked(False)
7274
+ layout.addRow("Return Node Type Distribution Robust UMAP (ie, communities will show how much they overrepresent a node type rather than just their proportional composition):", self.proportional)
7042
7275
 
7043
7276
  # Add Run button
7044
7277
  run_button = QPushButton("Get Community ID Info")
@@ -7062,6 +7295,9 @@ class ComIdDialog(QDialog):
7062
7295
 
7063
7296
  umap = self.umap.isChecked()
7064
7297
  label = self.label.isChecked()
7298
+ proportional = self.proportional.isChecked()
7299
+ limit = int(self.limit.text()) if self.limit.text().strip() else 0
7300
+
7065
7301
 
7066
7302
  if mode == 1:
7067
7303
 
@@ -7071,7 +7307,7 @@ class ComIdDialog(QDialog):
7071
7307
 
7072
7308
  else:
7073
7309
 
7074
- info, names = my_network.community_id_info_per_com(umap = umap, label = label)
7310
+ info, names = my_network.community_id_info_per_com(umap = umap, label = label, limit = limit, proportional = proportional)
7075
7311
 
7076
7312
  self.parent().format_for_upperright_table(info, 'Community', names, 'Average of Community Makeup')
7077
7313
 
@@ -7079,6 +7315,9 @@ class ComIdDialog(QDialog):
7079
7315
 
7080
7316
  except Exception as e:
7081
7317
 
7318
+ import traceback
7319
+ print(traceback.format_exc())
7320
+
7082
7321
  print(f"Error: {e}")
7083
7322
 
7084
7323
 
@@ -7088,12 +7327,13 @@ class ComNeighborDialog(QDialog):
7088
7327
  def __init__(self, parent=None):
7089
7328
 
7090
7329
  super().__init__(parent)
7091
- self.setWindowTitle("Reassign Communities Based on Identity Similarity?")
7330
+ self.setWindowTitle("Reassign Communities Based on Identity Similarity? (Note this alters communities outside of this function)")
7092
7331
  self.setModal(True)
7093
7332
 
7094
7333
  layout = QFormLayout(self)
7095
7334
 
7096
- self.neighborcount = QLineEdit("5")
7335
+ self.neighborcount = QLineEdit("")
7336
+ self.neighborcount.setPlaceholderText("KMeans Only. Empty = auto-predict (between 1 and 20)")
7097
7337
  layout.addRow("Num Neighborhoods:", self.neighborcount)
7098
7338
 
7099
7339
  self.seed = QLineEdit("")
@@ -7102,8 +7342,19 @@ class ComNeighborDialog(QDialog):
7102
7342
  self.limit = QLineEdit("")
7103
7343
  layout.addRow("Min Community Size to be grouped (Smaller communities will be placed in neighborhood 0 - does not apply if empty)", self.limit)
7104
7344
 
7345
+ # weighted checkbox (default True)
7346
+ self.proportional = QPushButton("Robust")
7347
+ self.proportional.setCheckable(True)
7348
+ self.proportional.setChecked(False)
7349
+ layout.addRow("Return Node Type Distribution Robust Heatmaps (ie, will give two more heatmaps that are not beholden to the total number of nodes of each type, representing which structures are overrepresented in a network):", self.proportional)
7350
+
7351
+ self.mode = QComboBox()
7352
+ self.mode.addItems(["KMeans (Better at simplifying data)", "DBSCAN (Better at capturing anomalies)"])
7353
+ self.mode.setCurrentIndex(0)
7354
+ layout.addRow("Mode", self.mode)
7355
+
7105
7356
  # Add Run button
7106
- run_button = QPushButton("Get Communities")
7357
+ run_button = QPushButton("Get Neighborhoods")
7107
7358
  run_button.clicked.connect(self.run)
7108
7359
  layout.addWidget(run_button)
7109
7360
 
@@ -7120,20 +7371,29 @@ class ComNeighborDialog(QDialog):
7120
7371
  if my_network.communities is None:
7121
7372
  return
7122
7373
 
7374
+ mode = self.mode.currentIndex()
7375
+
7123
7376
  seed = float(self.seed.text()) if self.seed.text().strip() else 42
7124
7377
 
7125
7378
  limit = int(self.limit.text()) if self.limit.text().strip() else None
7126
7379
 
7380
+ proportional = self.proportional.isChecked()
7127
7381
 
7128
- neighborcount = int(self.neighborcount.text()) if self.neighborcount.text().strip() else 5
7382
+ neighborcount = int(self.neighborcount.text()) if self.neighborcount.text().strip() else None
7129
7383
 
7130
7384
  if self.parent().prev_coms is None:
7131
7385
 
7132
7386
  self.parent().prev_coms = copy.deepcopy(my_network.communities)
7133
- my_network.assign_neighborhoods(seed, neighborcount, limit = limit)
7387
+ len_dict, matrixes, id_set = my_network.assign_neighborhoods(seed, neighborcount, limit = limit, proportional = proportional, mode = mode)
7134
7388
  else:
7135
- my_network.assign_neighborhoods(seed, neighborcount, limit = limit, prev_coms = self.parent().prev_coms)
7389
+ len_dict, matrixes, id_set = my_network.assign_neighborhoods(seed, neighborcount, limit = limit, prev_coms = self.parent().prev_coms, proportional = proportional, mode = mode)
7390
+
7391
+
7392
+ for i, matrix in enumerate(matrixes):
7393
+ self.parent().format_for_upperright_table(matrix, 'NeighborhoodID', id_set, title = f'Neighborhood Heatmap {i + 1}')
7136
7394
 
7395
+
7396
+ self.parent().format_for_upperright_table(len_dict, 'NeighborhoodID', 'Proportion of Total Nodes', title = 'Neighborhood Counts')
7137
7397
  self.parent().format_for_upperright_table(my_network.communities, 'NodeID', 'NeighborhoodID', title = 'Neighborhood Partition')
7138
7398
 
7139
7399
  print("Neighborhoods have been assigned to communities based on similarity")
@@ -7142,6 +7402,9 @@ class ComNeighborDialog(QDialog):
7142
7402
 
7143
7403
  except Exception as e:
7144
7404
 
7405
+ import traceback
7406
+ print(traceback.format_exc())
7407
+
7145
7408
  print(f"Error assigning neighborhoods: {e}")
7146
7409
 
7147
7410
  class ComCellDialog(QDialog):
@@ -7164,7 +7427,7 @@ class ComCellDialog(QDialog):
7164
7427
  layout.addRow("z scale:", self.z_scale)
7165
7428
 
7166
7429
  # Add Run button
7167
- run_button = QPushButton("Get Neighborhoods (Note this overwrites current communities - save your coms first)")
7430
+ run_button = QPushButton("Get Communities (Note this overwrites current communities - save your coms first)")
7168
7431
  run_button.clicked.connect(self.run)
7169
7432
  layout.addWidget(run_button)
7170
7433
 
@@ -7790,9 +8053,9 @@ class RandNodeDialog(QDialog):
7790
8053
 
7791
8054
  # Calculate shape if not provided
7792
8055
  max_coords = centroid_points.max(axis=0)
7793
- max_shape = tuple(max_coord + 1 for max_coord in max_coords)
8056
+ max_shape = tuple(max_coord for max_coord in max_coords)
7794
8057
  min_coords = centroid_points.min(axis=0)
7795
- min_shape = tuple(min_coord + 1 for min_coord in min_coords)
8058
+ min_shape = tuple(min_coord for min_coord in min_coords)
7796
8059
  bounds = (min_shape, max_shape)
7797
8060
  else:
7798
8061
  mask = n3d.binarize(self.parent().channel_data[mode - 1])
@@ -8618,6 +8881,12 @@ class BinarizeDialog(QDialog):
8618
8881
 
8619
8882
  layout = QFormLayout(self)
8620
8883
 
8884
+ # Add mode selection dropdown
8885
+ self.mode = QComboBox()
8886
+ self.mode.addItems(["Total Binarize", "Predict Foreground"])
8887
+ self.mode.setCurrentIndex(0) # Default to Mode 1
8888
+ layout.addRow("Method:", self.mode)
8889
+
8621
8890
  # Add Run button
8622
8891
  run_button = QPushButton("Run Binarize")
8623
8892
  run_button.clicked.connect(self.run_binarize)
@@ -8632,11 +8901,19 @@ class BinarizeDialog(QDialog):
8632
8901
  if active_data is None:
8633
8902
  raise ValueError("No active image selected")
8634
8903
 
8904
+ mode = self.mode.currentIndex()
8905
+
8635
8906
  try:
8636
- # Call binarize method with parameters
8637
- result = n3d.binarize(
8638
- active_data
8639
- )
8907
+
8908
+ if mode == 0:
8909
+ # Call binarize method with parameters
8910
+ result = n3d.binarize(
8911
+ active_data
8912
+ )
8913
+ else:
8914
+ result = n3d.otsu_binarize(
8915
+ active_data, True
8916
+ )
8640
8917
 
8641
8918
  # Update both the display data and the network object
8642
8919
  self.parent().channel_data[self.parent().active_channel] = result
@@ -9305,12 +9582,16 @@ class MachineWindow(QMainWindow):
9305
9582
  if self.parent().brush_mode:
9306
9583
  self.parent().pan_button.setChecked(False)
9307
9584
  self.parent().zoom_button.setChecked(False)
9585
+ if self.parent().pan_mode:
9586
+ current_xlim = self.parent().ax.get_xlim()
9587
+ current_ylim = self.parent().ax.get_ylim()
9588
+ self.parent().update_display(preserve_zoom=(current_xlim, current_ylim))
9308
9589
  self.parent().pan_mode = False
9309
9590
  self.parent().zoom_mode = False
9310
9591
  self.parent().update_brush_cursor()
9311
9592
  else:
9312
- self.threed = False
9313
- self.can = False
9593
+ self.parent().threed = False
9594
+ self.parent().can = False
9314
9595
  self.parent().zoom_button.click()
9315
9596
 
9316
9597
  def silence_button(self):
@@ -9623,6 +9904,7 @@ class SegmentationWorker(QThread):
9623
9904
  self.machine_window = machine_window
9624
9905
  self.mem_lock = mem_lock
9625
9906
  self._stop = False
9907
+ self._paused = False # Add pause flag
9626
9908
  self.update_interval = 1 # Increased to 500ms
9627
9909
  self.chunks_since_update = 0
9628
9910
  self.chunks_per_update = 5 # Only update every 5 chunks
@@ -9632,6 +9914,23 @@ class SegmentationWorker(QThread):
9632
9914
  def stop(self):
9633
9915
  self._stop = True
9634
9916
 
9917
+ def pause(self):
9918
+ """Pause the segmentation worker"""
9919
+ self._paused = True
9920
+
9921
+ def resume(self):
9922
+ """Resume the segmentation worker"""
9923
+ self._paused = False
9924
+
9925
+ def is_paused(self):
9926
+ """Check if the worker is currently paused"""
9927
+ return self._paused
9928
+
9929
+ def _check_pause(self):
9930
+ """Check if paused and wait until resumed"""
9931
+ while self._paused and not self._stop:
9932
+ self.msleep(50) # Sleep for 50ms while paused
9933
+
9635
9934
  def get_poked(self):
9636
9935
  self.machine_window.poke_segmenter()
9637
9936
 
@@ -9648,6 +9947,11 @@ class SegmentationWorker(QThread):
9648
9947
 
9649
9948
  # Process the slice with chunked generator
9650
9949
  for foreground, background in self.segmenter.segment_slice_chunked(current_z):
9950
+ # Check for pause/stop before processing each chunk
9951
+ self._check_pause()
9952
+ if self._stop:
9953
+ break
9954
+
9651
9955
  if foreground == None and background == None:
9652
9956
  self.get_poked()
9653
9957
 
@@ -9672,6 +9976,8 @@ class SegmentationWorker(QThread):
9672
9976
  else:
9673
9977
  # Original 3D approach
9674
9978
  for foreground_coords, background_coords in self.segmenter.segment_volume_realtime(gpu=self.use_gpu):
9979
+ # Check for pause/stop before processing each chunk
9980
+ self._check_pause()
9675
9981
  if self._stop:
9676
9982
  break
9677
9983
 
@@ -9702,6 +10008,10 @@ class SegmentationWorker(QThread):
9702
10008
  # Modify the array directly
9703
10009
  self.overlay.fill(False)
9704
10010
  for z,y,x in foreground_coords:
10011
+ # Check for pause/stop during batch processing too
10012
+ self._check_pause()
10013
+ if self._stop:
10014
+ break
9705
10015
  self.overlay[z,y,x] = True
9706
10016
 
9707
10017
  self.finished.emit()
@@ -12264,7 +12574,10 @@ class ProxDialog(QDialog):
12264
12574
  # Speed Up Options Group
12265
12575
  speedup_group = QGroupBox("Speed Up Options")
12266
12576
  speedup_layout = QFormLayout(speedup_group)
12267
-
12577
+
12578
+ self.max_neighbors = QLineEdit("")
12579
+ speedup_layout.addRow("(If using centroids): Max number of closest neighbors each node can connect to? Further neighbors within the radius will be ignored if a value is passed here. (Can be good to simplify dense networks)", self.max_neighbors)
12580
+
12268
12581
  self.fastdil = QPushButton("Fast Dilate")
12269
12582
  self.fastdil.setCheckable(True)
12270
12583
  self.fastdil.setChecked(False)
@@ -12316,6 +12629,11 @@ class ProxDialog(QDialog):
12316
12629
  except ValueError:
12317
12630
  search = None
12318
12631
 
12632
+ try:
12633
+ max_neighbors = int(self.max_neighbors.text()) if self.max_neighbors.text() else None
12634
+ except:
12635
+ max_neighbors = None
12636
+
12319
12637
  overlays = self.overlays.isChecked()
12320
12638
  fastdil = self.fastdil.isChecked()
12321
12639
 
@@ -12354,10 +12672,10 @@ class ProxDialog(QDialog):
12354
12672
  return
12355
12673
 
12356
12674
  if populate:
12357
- my_network.nodes = my_network.kd_network(distance = search, targets = targets, make_array = True)
12675
+ my_network.nodes = my_network.kd_network(distance = search, targets = targets, make_array = True, max_neighbors = max_neighbors)
12358
12676
  self.parent().load_channel(0, channel_data = my_network.nodes, data = True)
12359
12677
  else:
12360
- my_network.kd_network(distance = search, targets = targets)
12678
+ my_network.kd_network(distance = search, targets = targets, max_neighbors = max_neighbors)
12361
12679
 
12362
12680
  if directory is not None:
12363
12681
  my_network.dump(directory = directory)