nettracer3d 0.8.4__py3-none-any.whl → 0.8.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.
@@ -35,6 +35,7 @@ import queue
35
35
  from threading import Lock
36
36
  from scipy import ndimage
37
37
  import os
38
+ from . import painting
38
39
 
39
40
 
40
41
 
@@ -210,7 +211,7 @@ class ImageViewerWindow(QMainWindow):
210
211
  buttons_widget = QWidget()
211
212
  buttons_layout = QHBoxLayout(buttons_widget)
212
213
 
213
- # Create zoom button
214
+ # "Create" zoom button
214
215
  self.zoom_button = QPushButton("🔍")
215
216
  self.zoom_button.setCheckable(True)
216
217
  self.zoom_button.setFixedSize(40, 40)
@@ -454,18 +455,8 @@ class ImageViewerWindow(QMainWindow):
454
455
  self.excel_manager.data_received.connect(self.handle_excel_data)
455
456
  self.prev_coms = None
456
457
 
457
- self.paint_timer = QTimer()
458
- self.paint_timer.timeout.connect(self.flush_paint_updates)
459
- self.paint_timer.setSingleShot(True)
460
- self.pending_paint_update = False
461
458
  self.static_background = None
462
459
 
463
- # Threading for paint operations
464
- self.paint_queue = queue.Queue()
465
- self.paint_lock = Lock()
466
- self.paint_worker = threading.Thread(target=self.paint_worker_loop, daemon=True)
467
- self.paint_worker.start()
468
-
469
460
  # Background caching for blitting
470
461
  self.paint_session_active = False
471
462
 
@@ -473,6 +464,8 @@ class ImageViewerWindow(QMainWindow):
473
464
  self.paint_batch = []
474
465
  self.last_paint_pos = None
475
466
 
467
+ self.resume = False
468
+
476
469
  def start_left_scroll(self):
477
470
  """Start scrolling left when left arrow is pressed."""
478
471
  # Single increment first
@@ -2170,11 +2163,6 @@ class ImageViewerWindow(QMainWindow):
2170
2163
  if self.zoom_mode:
2171
2164
  self.pan_button.setChecked(False)
2172
2165
 
2173
- if self.pan_mode or self.brush_mode:
2174
- current_xlim = self.ax.get_xlim()
2175
- current_ylim = self.ax.get_ylim()
2176
- self.update_display(preserve_zoom=(current_xlim, current_ylim))
2177
-
2178
2166
  self.pen_button.setChecked(False)
2179
2167
  self.pan_mode = False
2180
2168
  self.brush_mode = False
@@ -2184,6 +2172,11 @@ class ImageViewerWindow(QMainWindow):
2184
2172
  if self.machine_window is not None:
2185
2173
  self.machine_window.silence_button()
2186
2174
  self.canvas.setCursor(Qt.CursorShape.CrossCursor)
2175
+ if self.pan_mode or self.brush_mode:
2176
+ current_xlim = self.ax.get_xlim()
2177
+ current_ylim = self.ax.get_ylim()
2178
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
2179
+
2187
2180
  else:
2188
2181
  if self.machine_window is None:
2189
2182
  self.canvas.setCursor(Qt.CursorShape.ArrowCursor)
@@ -2195,10 +2188,6 @@ class ImageViewerWindow(QMainWindow):
2195
2188
  """Toggle pan mode on/off."""
2196
2189
  self.pan_mode = self.pan_button.isChecked()
2197
2190
  if self.pan_mode:
2198
- if self.brush_mode:
2199
- current_xlim = self.ax.get_xlim()
2200
- current_ylim = self.ax.get_ylim()
2201
- self.update_display(preserve_zoom=(current_xlim, current_ylim))
2202
2191
 
2203
2192
  self.zoom_button.setChecked(False)
2204
2193
  self.pen_button.setChecked(False)
@@ -2210,6 +2199,10 @@ class ImageViewerWindow(QMainWindow):
2210
2199
  if self.machine_window is not None:
2211
2200
  self.machine_window.silence_button()
2212
2201
  self.canvas.setCursor(Qt.CursorShape.OpenHandCursor)
2202
+ if self.brush_mode:
2203
+ current_xlim = self.ax.get_xlim()
2204
+ current_ylim = self.ax.get_ylim()
2205
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
2213
2206
  else:
2214
2207
  current_xlim = self.ax.get_xlim()
2215
2208
  current_ylim = self.ax.get_ylim()
@@ -2224,6 +2217,20 @@ class ImageViewerWindow(QMainWindow):
2224
2217
  self.brush_mode = self.pen_button.isChecked()
2225
2218
  if self.brush_mode:
2226
2219
 
2220
+ self.pm = painting.PaintManager(parent = self)
2221
+
2222
+ # Start virtual paint session
2223
+ # Get current zoom to preserve it
2224
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2225
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2226
+
2227
+ if self.pen_button.isChecked():
2228
+ channel = self.active_channel
2229
+ else:
2230
+ channel = 2
2231
+
2232
+ self.pm.initiate_paint_session(channel, current_xlim, current_ylim)
2233
+
2227
2234
  if self.pan_mode:
2228
2235
  current_xlim = self.ax.get_xlim()
2229
2236
  current_ylim = self.ax.get_ylim()
@@ -2443,37 +2450,6 @@ class ImageViewerWindow(QMainWindow):
2443
2450
 
2444
2451
  painter.end()
2445
2452
 
2446
- def get_line_points(self, x0, y0, x1, y1):
2447
- """Get all points in a line between (x0,y0) and (x1,y1) using Bresenham's algorithm."""
2448
- points = []
2449
- dx = abs(x1 - x0)
2450
- dy = abs(y1 - y0)
2451
- x, y = x0, y0
2452
- sx = 1 if x0 < x1 else -1
2453
- sy = 1 if y0 < y1 else -1
2454
-
2455
- if dx > dy:
2456
- err = dx / 2.0
2457
- while x != x1:
2458
- points.append((x, y))
2459
- err -= dy
2460
- if err < 0:
2461
- y += sy
2462
- err += dx
2463
- x += sx
2464
- else:
2465
- err = dy / 2.0
2466
- while y != y1:
2467
- points.append((x, y))
2468
- err -= dx
2469
- if err < 0:
2470
- x += sx
2471
- err += dy
2472
- y += sy
2473
-
2474
- points.append((x, y))
2475
- return points
2476
-
2477
2453
  def get_current_mouse_position(self):
2478
2454
  # Get the main application's current mouse position
2479
2455
  cursor_pos = QCursor.pos()
@@ -2486,15 +2462,25 @@ class ImageViewerWindow(QMainWindow):
2486
2462
  0 <= canvas_pos.y() < self.canvas.height()):
2487
2463
  return 0, 0 # Mouse is outside of the matplotlib canvas
2488
2464
 
2489
- # Convert from canvas widget coordinates to matplotlib data coordinates
2490
- x = canvas_pos.x()
2491
- y = canvas_pos.y()
2492
-
2493
- # Transform display coordinates to data coordinates
2494
- inv = self.ax.transData.inverted()
2495
- data_coords = inv.transform((x, y))
2496
-
2497
- return data_coords[0], data_coords[1]
2465
+ # OPTION 1: Use matplotlib's built-in coordinate conversion
2466
+ # This accounts for figure margins, subplot positioning, etc.
2467
+ try:
2468
+ # Get the figure and axes bounds
2469
+ bbox = self.ax.bbox
2470
+
2471
+ # Convert widget coordinates to figure coordinates
2472
+ fig_x = canvas_pos.x()
2473
+ fig_y = self.canvas.height() - canvas_pos.y() # Flip Y coordinate
2474
+
2475
+ # Check if within axes bounds
2476
+ if (bbox.x0 <= fig_x <= bbox.x1 and bbox.y0 <= fig_y <= bbox.y1):
2477
+ # Transform to data coordinates
2478
+ data_coords = self.ax.transData.inverted().transform((fig_x, fig_y))
2479
+ return data_coords[0], data_coords[1]
2480
+ else:
2481
+ return 0, 0
2482
+ except:
2483
+ pass
2498
2484
 
2499
2485
  def on_mouse_press(self, event):
2500
2486
  """Handle mouse press events."""
@@ -2514,6 +2500,8 @@ class ImageViewerWindow(QMainWindow):
2514
2500
 
2515
2501
  if self.machine_window is not None:
2516
2502
  if self.machine_window.segmentation_worker is not None:
2503
+ if not self.machine_window.segmentation_worker._paused:
2504
+ self.resume = True
2517
2505
  self.machine_window.segmentation_worker.pause()
2518
2506
 
2519
2507
  # Store current channel visibility state
@@ -2533,61 +2521,68 @@ class ImageViewerWindow(QMainWindow):
2533
2521
 
2534
2522
 
2535
2523
  elif self.brush_mode:
2524
+ """Handle brush mode with virtual painting."""
2536
2525
  if event.inaxes != self.ax:
2537
2526
  return
2527
+
2528
+ """
2529
+ try:
2530
+ if self.machine_window is not None and not self.machine_window.segmentation_worker._paused:
2531
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2532
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2533
+
2534
+ if self.pen_button.isChecked():
2535
+ channel = self.active_channel
2536
+ else:
2537
+ channel = 2
2538
+
2539
+ self.pm.initiate_paint_session(channel, current_xlim, current_ylim)
2540
+ except:
2541
+ pass
2542
+ """
2538
2543
 
2539
2544
  if event.button == 1 or event.button == 3:
2545
+ if self.machine_window is not None:
2546
+ if self.machine_window.segmentation_worker is not None:
2547
+ if not self.machine_window.segmentation_worker._paused:
2548
+ self.resume = True
2549
+ self.machine_window.segmentation_worker.pause()
2550
+
2540
2551
  x, y = int(event.xdata), int(event.ydata)
2541
- # Get current zoom to preserve it
2542
-
2552
+
2553
+ # Get current zoom to preserve it
2543
2554
  current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2544
2555
  current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2545
-
2546
-
2547
- if event.button == 1 and self.can:
2548
- self.update_display(preserve_zoom = (current_xlim, current_ylim))
2556
+
2557
+ if event.button == 1 and getattr(self, 'can', False):
2558
+ self.update_display(preserve_zoom=(current_xlim, current_ylim), skip_paint_reinit = True)
2549
2559
  self.handle_can(x, y)
2550
2560
  return
2551
-
2561
+
2562
+ # Determine erase mode and foreground/background
2552
2563
  if event.button == 3:
2553
2564
  self.erase = True
2554
- self.update_display(preserve_zoom = (current_xlim, current_ylim))
2555
2565
  else:
2556
2566
  self.erase = False
2557
-
2558
- self.painting = True
2559
- self.last_paint_pos = (x, y)
2560
-
2567
+
2568
+ # Determine foreground/background for machine window mode
2569
+ foreground = getattr(self, 'foreground', True)
2570
+
2571
+ self.last_virtual_pos = (x, y)
2572
+
2561
2573
  if self.pen_button.isChecked():
2562
2574
  channel = self.active_channel
2563
2575
  else:
2564
2576
  channel = 2
2565
2577
 
2566
- # Paint at initial position
2567
- self.paint_at_position(x, y, self.erase, channel)
2568
-
2569
- self.canvas.draw()
2570
-
2571
- self.restore_channels = []
2572
- if not self.channel_visible[channel]:
2573
- self.channel_visible[channel] = True
2574
-
2575
- # No need to hide other channels or track restore_channels
2576
- self.restore_channels = []
2577
-
2578
- if self.static_background is None:
2579
- if self.machine_window is not None:
2580
- self.update_display(preserve_zoom = (current_xlim, current_ylim))
2581
- elif not self.erase:
2582
- self.temp_chan = channel
2583
- self.channel_data[4] = self.channel_data[channel]
2584
- self.min_max[4] = copy.deepcopy(self.min_max[channel])
2585
- self.channel_brightness[4] = copy.deepcopy(self.channel_brightness[channel])
2586
- self.load_channel(channel, np.zeros_like(self.channel_data[channel]), data = True, preserve_zoom = (current_xlim, current_ylim), begin_paint = True)
2587
- self.channel_visible[4] = True
2588
- self.static_background = self.canvas.copy_from_bbox(self.ax.bbox)
2589
-
2590
- self.update_display_slice_optimized(channel, preserve_zoom=(current_xlim, current_ylim))
2578
+ self.pm.start_virtual_paint_session(channel, current_xlim, current_ylim)
2579
+
2580
+ # Add first virtual paint stroke
2581
+ brush_size = getattr(self, 'brush_size', 5)
2582
+ self.pm.add_virtual_paint_stroke(x, y, brush_size, self.erase, foreground)
2583
+
2584
+ # Update display with virtual paint
2585
+ self.pm.update_virtual_paint_display()
2591
2586
 
2592
2587
  elif not self.zoom_mode and event.button == 3: # Right click (for context menu)
2593
2588
  self.create_context_menu(event)
@@ -2597,92 +2592,6 @@ class ImageViewerWindow(QMainWindow):
2597
2592
  self.selection_start = (event.xdata, event.ydata)
2598
2593
  self.selecting = False # Will be set to True if the mouse moves while button is held
2599
2594
 
2600
- def paint_at_position(self, center_x, center_y, erase = False, channel = 2):
2601
- """Paint pixels within brush radius at given position"""
2602
- if self.channel_data[channel] is None:
2603
- return
2604
-
2605
- if erase:
2606
- val = 0
2607
- elif self.machine_window is None:
2608
- try:
2609
- val = max(255, self.min_max[4][1])
2610
- except:
2611
- val = 255
2612
- elif self.foreground:
2613
- val = 1
2614
- else:
2615
- val = 2
2616
- height, width = self.channel_data[channel][self.current_slice].shape
2617
- radius = self.brush_size // 2
2618
-
2619
- # Calculate brush area
2620
- for y in range(max(0, center_y - radius), min(height, center_y + radius + 1)):
2621
- for x in range(max(0, center_x - radius), min(width, center_x + radius + 1)):
2622
- # Check if point is within circular brush area
2623
- if (x - center_x) * 2 + (y - center_y) * 2 <= radius ** 2:
2624
- if self.threed and self.threedthresh > 1:
2625
- amount = (self.threedthresh - 1) / 2
2626
- low = max(0, self.current_slice - amount)
2627
- high = min(self.channel_data[channel].shape[0] - 1, self.current_slice + amount)
2628
- for i in range(int(low), int(high + 1)):
2629
- self.channel_data[channel][i][y, x] = val
2630
- else:
2631
- self.channel_data[channel][self.current_slice][y, x] = val
2632
-
2633
- def paint_at_position_vectorized(self, center_x, center_y, erase=False, channel=2,
2634
- slice_idx=None, brush_size=None, threed=False,
2635
- threedthresh=1, foreground=True, machine_window=None):
2636
- """Vectorized paint operation for better performance."""
2637
- if self.channel_data[channel] is None:
2638
- return
2639
-
2640
- # Use provided parameters or fall back to instance variables
2641
- slice_idx = slice_idx if slice_idx is not None else self.current_slice
2642
- brush_size = brush_size if brush_size is not None else getattr(self, 'brush_size', 5)
2643
-
2644
- # Determine paint value
2645
- if erase:
2646
- val = 0
2647
- elif machine_window is None:
2648
- try:
2649
- val = max(255, self.min_max[4][1])
2650
- except:
2651
- val = 255
2652
- elif foreground:
2653
- val = 1
2654
- else:
2655
- val = 2
2656
-
2657
- height, width = self.channel_data[channel][slice_idx].shape
2658
- radius = brush_size // 2
2659
-
2660
- # Calculate affected region bounds
2661
- y_min = max(0, center_y - radius)
2662
- y_max = min(height, center_y + radius + 1)
2663
- x_min = max(0, center_x - radius)
2664
- x_max = min(width, center_x + radius + 1)
2665
-
2666
- if y_min >= y_max or x_min >= x_max:
2667
- return # No valid region to paint
2668
-
2669
- # Create coordinate grids for the affected region
2670
- y_coords, x_coords = np.mgrid[y_min:y_max, x_min:x_max]
2671
-
2672
- # Calculate distances squared (avoid sqrt for performance)
2673
- distances_sq = (x_coords - center_x) ** 2 + (y_coords - center_y) ** 2
2674
- mask = distances_sq <= radius ** 2
2675
-
2676
- # Apply paint to affected slices
2677
- if threed and threedthresh > 1:
2678
- amount = (threedthresh - 1) / 2
2679
- low = max(0, int(slice_idx - amount))
2680
- high = min(self.channel_data[channel].shape[0] - 1, int(slice_idx + amount))
2681
-
2682
- for i in range(low, high + 1):
2683
- self.channel_data[channel][i][y_min:y_max, x_min:x_max][mask] = val
2684
- else:
2685
- self.channel_data[channel][slice_idx][y_min:y_max, x_min:x_max][mask] = val
2686
2595
 
2687
2596
  def handle_can(self, x, y):
2688
2597
 
@@ -2823,129 +2732,24 @@ class ImageViewerWindow(QMainWindow):
2823
2732
  if event.inaxes != self.ax:
2824
2733
  return
2825
2734
 
2826
- # OPTIMIZED: Queue paint operation instead of immediate execution
2827
- self.queue_paint_operation(event)
2735
+ # Throttle updates like selection rectangle
2736
+ current_time = time.time()
2737
+ if current_time - getattr(self, 'last_paint_update_time', 0) < 0.016: # ~60fps
2738
+ return
2739
+ self.last_paint_update_time = current_time
2828
2740
 
2829
- # OPTIMIZED: Schedule display update at controlled frequency
2830
- if not self.pending_paint_update:
2831
- self.pending_paint_update = True
2832
- self.paint_timer.start(16) # ~60fps max update rate
2833
-
2834
- def queue_paint_operation(self, event):
2835
- """Queue a paint operation for background processing."""
2836
- x, y = int(event.xdata), int(event.ydata)
2837
-
2838
- if self.pen_button.isChecked():
2839
- channel = self.active_channel
2840
- else:
2841
- channel = 2
2842
-
2843
- if self.channel_data[channel] is not None:
2844
- # Prepare paint session if needed
2845
- if not self.paint_session_active:
2846
- self.prepare_paint_session(channel)
2847
-
2848
- # Create paint operation
2849
- paint_op = {
2850
- 'type': 'stroke',
2851
- 'x': x,
2852
- 'y': y,
2853
- 'last_pos': getattr(self, 'last_paint_pos', None),
2854
- 'brush_size': self.brush_size,
2855
- 'erase': self.erase,
2856
- 'channel': channel,
2857
- 'slice': self.current_slice,
2858
- 'threed': getattr(self, 'threed', False),
2859
- 'threedthresh': getattr(self, 'threedthresh', 1),
2860
- 'foreground': getattr(self, 'foreground', True),
2861
- 'machine_window': getattr(self, 'machine_window', None)
2862
- }
2741
+ x, y = int(event.xdata), int(event.ydata)
2863
2742
 
2864
- # Queue the operation
2865
- try:
2866
- self.paint_queue.put_nowait(paint_op)
2867
- except queue.Full:
2868
- pass # Skip if queue is full to avoid blocking
2743
+ # Determine foreground/background for machine window mode
2744
+ foreground = getattr(self, 'foreground', True)
2869
2745
 
2870
- self.last_paint_pos = (x, y)
2871
-
2872
- def prepare_paint_session(self, channel):
2873
- """Prepare optimized background for blitting during paint session."""
2874
- if self.paint_session_active:
2875
- return
2746
+ # Add virtual paint stroke with interpolation
2747
+ brush_size = getattr(self, 'brush_size', 5)
2748
+ self.pm.add_virtual_paint_stroke(x, y, brush_size, self.erase, foreground)
2876
2749
 
2877
- # IMPORTANT: Don't capture background here - let the main display update handle it
2878
- # We'll capture the background after the proper channel visibility setup
2879
- self.paint_session_active = True
2750
+ # Update display with virtual paint (super fast)
2751
+ self.pm.update_virtual_paint_display()
2880
2752
 
2881
- def end_paint_session(self):
2882
- """Clean up after paint session."""
2883
- self.paint_session_active = False
2884
- self.last_paint_pos = None
2885
-
2886
- def paint_worker_loop(self):
2887
- """Background thread for processing paint operations."""
2888
- while True:
2889
- try:
2890
- paint_op = self.paint_queue.get(timeout=1.0)
2891
- if paint_op is None: # Shutdown signal
2892
- break
2893
-
2894
- with self.paint_lock:
2895
- self.execute_paint_operation(paint_op)
2896
-
2897
- except queue.Empty:
2898
- continue
2899
-
2900
- def shutdown(self):
2901
- """Clean shutdown of worker thread."""
2902
- self.paint_queue.put(None) # Signal worker to stop
2903
- if hasattr(self, 'paint_worker'):
2904
- self.paint_worker.join(timeout=1.0)
2905
-
2906
- def execute_paint_operation(self, paint_op):
2907
- """Execute a single paint operation on the data arrays."""
2908
- if paint_op['type'] == 'stroke':
2909
- channel = paint_op['channel']
2910
- x, y = paint_op['x'], paint_op['y']
2911
- last_pos = paint_op['last_pos']
2912
-
2913
- if last_pos is not None:
2914
- # Paint line from last position to current
2915
- points = self.get_line_points(last_pos[0], last_pos[1], x, y)
2916
- for px, py in points:
2917
- height, width = self.channel_data[channel][paint_op['slice']].shape
2918
- if 0 <= px < width and 0 <= py < height:
2919
- self.paint_at_position_vectorized(
2920
- px, py, paint_op['erase'], paint_op['channel'],
2921
- paint_op['slice'], paint_op['brush_size'],
2922
- paint_op['threed'], paint_op['threedthresh'],
2923
- paint_op['foreground'], paint_op['machine_window']
2924
- )
2925
- else:
2926
- # Single point paint
2927
- height, width = self.channel_data[channel][paint_op['slice']].shape
2928
- if 0 <= x < width and 0 <= y < height:
2929
- self.paint_at_position_vectorized(
2930
- x, y, paint_op['erase'], paint_op['channel'],
2931
- paint_op['slice'], paint_op['brush_size'],
2932
- paint_op['threed'], paint_op['threedthresh'],
2933
- paint_op['foreground'], paint_op['machine_window']
2934
- )
2935
-
2936
- def flush_paint_updates(self):
2937
- """Update the display with batched paint changes."""
2938
- self.pending_paint_update = False
2939
-
2940
- # Determine which channel to update
2941
- channel = self.active_channel if hasattr(self, 'pen_button') and self.pen_button.isChecked() else 2
2942
-
2943
- # Get current zoom to preserve it
2944
- current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2945
- current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2946
-
2947
- # Update display
2948
- self.update_display_slice_optimized(channel, preserve_zoom=(current_xlim, current_ylim))
2949
2753
 
2950
2754
  def create_pan_background(self):
2951
2755
  """Create a static background image from currently visible channels with proper rendering"""
@@ -3322,33 +3126,23 @@ class ImageViewerWindow(QMainWindow):
3322
3126
 
3323
3127
  # Handle brush mode cleanup with paint session management
3324
3128
  if self.brush_mode and hasattr(self, 'painting') and self.painting:
3129
+ # Finish current operation
3130
+ self.pm.finish_current_virtual_operation()
3131
+
3132
+ # Reset last position for next stroke
3133
+ self.last_virtual_pos = None
3134
+
3135
+ # End this stroke but keep session active for continuous painting
3325
3136
  self.painting = False
3326
3137
 
3327
- if self.erase:
3328
- # Restore hidden channels
3329
- try:
3330
- for i in self.restore_channels:
3331
- self.channel_visible[i] = True
3332
- self.restore_channels = []
3333
- except:
3334
- pass
3335
-
3336
- self.end_paint_session()
3337
-
3338
- # OPTIMIZED: Stop timer and process any pending paint operations
3339
- if hasattr(self, 'paint_timer'):
3340
- self.paint_timer.stop()
3341
- if hasattr(self, 'pending_paint_update') and self.pending_paint_update:
3342
- self.flush_paint_updates()
3343
-
3344
- self.static_background = None
3345
-
3346
- current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3347
-
3348
- current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3138
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3139
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3349
3140
 
3350
- self.update_display(preserve_zoom = (current_xlim, current_ylim))
3141
+ self.update_display(preserve_zoom=(current_xlim, current_ylim), continue_paint = True)
3351
3142
 
3143
+ if self.resume:
3144
+ self.machine_window.segmentation_worker.resume()
3145
+ self.resume = False
3352
3146
 
3353
3147
 
3354
3148
  def highlight_value_in_tables(self, clicked_value):
@@ -3675,6 +3469,8 @@ class ImageViewerWindow(QMainWindow):
3675
3469
  community_code_action.triggered.connect(lambda: self.show_code_dialog(sort = 'Community'))
3676
3470
  id_code_action = overlay_menu.addAction("Code Identities")
3677
3471
  id_code_action.triggered.connect(lambda: self.show_code_dialog(sort = 'Identity'))
3472
+ umap_action = overlay_menu.addAction("Centroid UMAP")
3473
+ umap_action.triggered.connect(self.handle_umap)
3678
3474
 
3679
3475
  rand_menu = analysis_menu.addMenu("Randomize")
3680
3476
  random_action = rand_menu.addAction("Generate Equivalent Random Network")
@@ -3779,6 +3575,43 @@ class ImageViewerWindow(QMainWindow):
3779
3575
  help_button = menubar.addAction("Help")
3780
3576
  help_button.triggered.connect(self.help_me)
3781
3577
 
3578
+ cam_button = QPushButton("📷")
3579
+ cam_button.setFixedSize(40, 40)
3580
+ cam_button.setStyleSheet("font-size: 24px;") # Makes emoji larger
3581
+ cam_button.clicked.connect(self.snap)
3582
+ menubar.setCornerWidget(cam_button, Qt.Corner.TopRightCorner)
3583
+
3584
+ def snap(self):
3585
+
3586
+ try:
3587
+
3588
+ for thing in self.channel_data:
3589
+ if thing is not None:
3590
+ data = True
3591
+ if not data:
3592
+ return
3593
+
3594
+ snap = self.create_composite_for_pan()
3595
+
3596
+ filename, _ = QFileDialog.getSaveFileName(
3597
+ self,
3598
+ f"Save Image As",
3599
+ "", # Default directory
3600
+ "TIFF Files (*.tif *.tiff);;All Files (*)" # File type filter
3601
+ )
3602
+
3603
+ if filename: # Only proceed if user didn't cancel
3604
+ # If user didn't type an extension, add .tif
3605
+ if not filename.endswith(('.tif', '.tiff')):
3606
+ filename += '.tif'
3607
+
3608
+ import tifffile
3609
+ tifffile.imwrite(filename, snap)
3610
+
3611
+ except:
3612
+ pass
3613
+
3614
+
3782
3615
  def open_cellpose(self):
3783
3616
 
3784
3617
  try:
@@ -4555,6 +4388,7 @@ class ImageViewerWindow(QMainWindow):
4555
4388
  )
4556
4389
 
4557
4390
  self.last_load = directory
4391
+
4558
4392
 
4559
4393
  if directory != "":
4560
4394
 
@@ -4817,6 +4651,7 @@ class ImageViewerWindow(QMainWindow):
4817
4651
  - 'mean': averages across color channels
4818
4652
  - 'max': takes maximum value across color channels
4819
4653
  - 'min': takes minimum value across color channels
4654
+ - 'weight': takes weighted channel averages
4820
4655
 
4821
4656
  Returns:
4822
4657
  --------
@@ -4831,7 +4666,7 @@ class ImageViewerWindow(QMainWindow):
4831
4666
  if array.ndim != 4:
4832
4667
  raise ValueError(f"Expected 4D array, got {array.ndim}D array")
4833
4668
 
4834
- if method not in ['first', 'mean', 'max', 'min']:
4669
+ if method not in ['first', 'mean', 'max', 'min', 'weight']:
4835
4670
  raise ValueError(f"Unknown method: {method}")
4836
4671
 
4837
4672
  if method == 'first':
@@ -4840,6 +4675,9 @@ class ImageViewerWindow(QMainWindow):
4840
4675
  return np.mean(array, axis=-1)
4841
4676
  elif method == 'max':
4842
4677
  return np.max(array, axis=-1)
4678
+ elif method == 'weight':
4679
+ # Apply the luminosity formula
4680
+ return (0.2989 * array[:,:,:,0] + 0.5870 * array[:,:,:,1] + 0.1140 * array[:,:,:,2])
4843
4681
  else: # min
4844
4682
  return np.min(array, axis=-1)
4845
4683
 
@@ -4863,7 +4701,7 @@ class ImageViewerWindow(QMainWindow):
4863
4701
  msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
4864
4702
  return msg.exec() == QMessageBox.StandardButton.Yes
4865
4703
 
4866
- def load_channel(self, channel_index, channel_data=None, data=False, assign_shape = True, preserve_zoom = None, end_paint = False, begin_paint = False):
4704
+ def load_channel(self, channel_index, channel_data=None, data=False, assign_shape = True, preserve_zoom = None, end_paint = False, begin_paint = False, color = False):
4867
4705
  """Load a channel and enable active channel selection if needed."""
4868
4706
 
4869
4707
  try:
@@ -4940,12 +4778,12 @@ class ImageViewerWindow(QMainWindow):
4940
4778
  except:
4941
4779
  pass
4942
4780
 
4943
-
4944
- try:
4945
- if len(self.channel_data[channel_index].shape) == 4 and (channel_index == 0 or channel_index == 1):
4946
- self.channel_data[channel_index] = self.reduce_rgb_dimension(self.channel_data[channel_index])
4947
- except:
4948
- pass
4781
+ if not color:
4782
+ try:
4783
+ if len(self.channel_data[channel_index].shape) == 4 and (channel_index == 0 or channel_index == 1):
4784
+ self.channel_data[channel_index] = self.reduce_rgb_dimension(self.channel_data[channel_index], 'weight')
4785
+ except:
4786
+ pass
4949
4787
 
4950
4788
  reset_resize = False
4951
4789
 
@@ -5295,25 +5133,29 @@ class ImageViewerWindow(QMainWindow):
5295
5133
 
5296
5134
 
5297
5135
 
5298
- def update_display(self, preserve_zoom=None, dims = None, called = False, reset_resize = False):
5136
+ def update_display(self, preserve_zoom=None, dims = None, called = False, reset_resize = False, continue_paint = False, skip_paint_reinit = False):
5299
5137
  """Update the display with currently visible channels and highlight overlay."""
5300
-
5301
5138
  try:
5302
-
5303
5139
  self.figure.clear()
5304
-
5305
5140
  if self.pan_background_image is not None:
5306
5141
  # Restore previously visible channels
5307
5142
  self.channel_visible = self.pre_pan_channel_state.copy()
5308
5143
  self.is_pan_preview = False
5309
5144
  self.pan_background_image = None
5310
-
5311
- if self.machine_window is not None:
5312
- if self.machine_window.segmentation_worker is not None:
5313
- self.machine_window.segmentation_worker.resume()
5314
-
5145
+ if self.resume:
5146
+ self.machine_window.segmentation_worker.resume()
5147
+ self.resume = False
5315
5148
  if self.static_background is not None:
5316
-
5149
+ # NEW: Convert virtual strokes to real data before cleanup
5150
+ if (hasattr(self, 'virtual_draw_operations') and self.virtual_draw_operations) or \
5151
+ (hasattr(self, 'virtual_erase_operations') and self.virtual_erase_operations) or \
5152
+ (hasattr(self, 'current_operation') and self.current_operation):
5153
+ # Finish current operation first
5154
+ if hasattr(self, 'current_operation') and self.current_operation:
5155
+ self.pm.finish_current_virtual_operation()
5156
+ # Now convert to real data
5157
+ self.pm.convert_virtual_strokes_to_data()
5158
+
5317
5159
  # Restore hidden channels
5318
5160
  try:
5319
5161
  for i in self.restore_channels:
@@ -5321,27 +5163,17 @@ class ImageViewerWindow(QMainWindow):
5321
5163
  self.restore_channels = []
5322
5164
  except:
5323
5165
  pass
5324
-
5325
- self.end_paint_session()
5326
-
5327
- # OPTIMIZED: Stop timer and process any pending paint operations
5328
- if hasattr(self, 'paint_timer'):
5329
- self.paint_timer.stop()
5330
- if hasattr(self, 'pending_paint_update') and self.pending_paint_update:
5331
- self.flush_paint_updates()
5332
-
5333
- self.static_background = None
5166
+ if not continue_paint:
5167
+ self.static_background = None
5334
5168
 
5335
- if self.machine_window is None:
5336
-
5337
- try:
5338
-
5339
- 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, :, :])
5340
- self.load_channel(self.temp_chan, self.channel_data[4], data = True, end_paint = True)
5341
- self.channel_data[4] = None
5342
- self.channel_visible[4] = False
5343
- except:
5344
- pass
5169
+ if self.machine_window is None:
5170
+ try:
5171
+ 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, :, :])
5172
+ self.load_channel(self.temp_chan, self.channel_data[4], data = True, end_paint = True)
5173
+ self.channel_data[4] = None
5174
+ self.channel_visible[4] = False
5175
+ except:
5176
+ pass
5345
5177
 
5346
5178
  # Get active channels and their dimensions
5347
5179
  active_channels = [i for i in range(4) if self.channel_data[i] is not None]
@@ -5572,49 +5404,22 @@ class ImageViewerWindow(QMainWindow):
5572
5404
 
5573
5405
  self.canvas.draw()
5574
5406
 
5407
+ if self.brush_mode and not skip_paint_reinit:
5408
+ # Get current zoom to preserve it
5409
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
5410
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
5411
+
5412
+ if self.pen_button.isChecked():
5413
+ channel = self.active_channel
5414
+ else:
5415
+ channel = 2
5416
+
5417
+ self.pm.initiate_paint_session(channel, current_xlim, current_ylim)
5418
+
5575
5419
  except:
5576
5420
  import traceback
5577
5421
  print(traceback.format_exc())
5578
5422
 
5579
- def update_display_slice_optimized(self, channel, preserve_zoom=None):
5580
- """Ultra minimal update that only changes the paint channel's data - OPTIMIZED VERSION"""
5581
- if not self.channel_visible[channel]:
5582
- return
5583
-
5584
- if preserve_zoom:
5585
- current_xlim, current_ylim = preserve_zoom
5586
- if current_xlim is not None and current_ylim is not None:
5587
- self.ax.set_xlim(current_xlim)
5588
- self.ax.set_ylim(current_ylim)
5589
-
5590
- # Find the existing image for channel (paint channel)
5591
- channel_image = None
5592
- for img in self.ax.images:
5593
- if img.cmap.name == f'custom_{channel}':
5594
- channel_image = img
5595
- break
5596
-
5597
- if channel_image is not None:
5598
- # Update the data of the existing image with thread safety
5599
- with self.paint_lock:
5600
- channel_image.set_array(self.channel_data[channel][self.current_slice])
5601
-
5602
- # Restore the static background (all other channels) at current zoom level
5603
- # This is the key - use static_background from update_display, not paint_background
5604
- if hasattr(self, 'static_background') and self.static_background is not None:
5605
- self.canvas.restore_region(self.static_background)
5606
- # Draw just our paint channel
5607
- self.ax.draw_artist(channel_image)
5608
- # Blit everything
5609
- self.canvas.blit(self.ax.bbox)
5610
- self.canvas.flush_events()
5611
- else:
5612
- # Fallback to full draw if no static background
5613
- self.canvas.draw()
5614
- else:
5615
- # Fallback if channel image not found
5616
- self.canvas.draw()
5617
-
5618
5423
  def get_channel_image(self, channel):
5619
5424
  """Find the matplotlib image object for a specific channel."""
5620
5425
  if not hasattr(self.ax, 'images'):
@@ -5744,6 +5549,13 @@ class ImageViewerWindow(QMainWindow):
5744
5549
  dialog = CodeDialog(self, sort = sort)
5745
5550
  dialog.exec()
5746
5551
 
5552
+ def handle_umap(self):
5553
+
5554
+ if my_network.node_centroids is None:
5555
+ self.show_centroid_dialog()
5556
+
5557
+ my_network.centroid_umap()
5558
+
5747
5559
  def closeEvent(self, event):
5748
5560
  """Override closeEvent to close all windows when main window closes"""
5749
5561
 
@@ -7264,24 +7076,29 @@ class ColorOverlayDialog(QDialog):
7264
7076
 
7265
7077
  def coloroverlay(self):
7266
7078
 
7267
- down_factor = float(self.down_factor.text()) if self.down_factor.text().strip() else None
7079
+ try:
7268
7080
 
7269
- if self.parent().active_channel == 0:
7270
- mode = 0
7271
- self.sort = 'Node'
7272
- else:
7273
- mode = 1
7274
- self.sort = 'Edge'
7081
+ down_factor = float(self.down_factor.text()) if self.down_factor.text().strip() else None
7275
7082
 
7083
+ if self.parent().active_channel == 0:
7084
+ mode = 0
7085
+ self.sort = 'Node'
7086
+ else:
7087
+ mode = 1
7088
+ self.sort = 'Edge'
7276
7089
 
7277
- result, legend = my_network.node_to_color(down_factor = down_factor, mode = mode)
7278
7090
 
7279
- self.parent().format_for_upperright_table(legend, f'{self.sort} Id', f'Encoding Val: {self.sort}', 'Legend')
7091
+ result, legend = my_network.node_to_color(down_factor = down_factor, mode = mode)
7280
7092
 
7093
+ self.parent().format_for_upperright_table(legend, f'{self.sort} Id', f'Encoding Val: {self.sort}', 'Legend')
7281
7094
 
7282
- self.parent().load_channel(3, channel_data = result, data = True)
7283
7095
 
7284
- self.accept()
7096
+ self.parent().load_channel(3, channel_data = result, data = True)
7097
+
7098
+ self.accept()
7099
+
7100
+ except:
7101
+ pass
7285
7102
 
7286
7103
 
7287
7104
  class ShuffleDialog(QDialog):
@@ -7624,7 +7441,7 @@ class ComNeighborDialog(QDialog):
7624
7441
  # weighted checkbox (default True)
7625
7442
  self.proportional = QPushButton("Robust")
7626
7443
  self.proportional.setCheckable(True)
7627
- self.proportional.setChecked(False)
7444
+ self.proportional.setChecked(True)
7628
7445
  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)
7629
7446
 
7630
7447
  self.mode = QComboBox()
@@ -9503,6 +9320,12 @@ class ThresholdDialog(QDialog):
9503
9320
 
9504
9321
  def start_ml(self, GPU = False):
9505
9322
 
9323
+ try:
9324
+ print("Please select image to load into nodes channel for segmentation or press X if you already have the one you want. Note that this load may permit a color image in the nodes channel for segmentation purposes only, which is otherwise not allowed.")
9325
+ self.parent().load_channel(0, color = True)
9326
+ except:
9327
+ pass
9328
+
9506
9329
 
9507
9330
  if self.parent().channel_data[2] is not None or self.parent().channel_data[3] is not None or self.parent().highlight_overlay is not None:
9508
9331
  if self.confirm_machine_dialog():
@@ -9619,206 +9442,210 @@ class MachineWindow(QMainWindow):
9619
9442
  def __init__(self, parent=None, GPU = False):
9620
9443
  super().__init__(parent)
9621
9444
 
9622
- if self.parent().active_channel == 0:
9623
- if self.parent().channel_data[0] is not None:
9624
- try:
9625
- active_data = self.parent().channel_data[0]
9626
- act_channel = 0
9627
- except:
9445
+ try:
9446
+
9447
+ if self.parent().active_channel == 0:
9448
+ if self.parent().channel_data[0] is not None:
9449
+ try:
9450
+ active_data = self.parent().channel_data[0]
9451
+ act_channel = 0
9452
+ except:
9453
+ active_data = self.parent().channel_data[1]
9454
+ act_channel = 1
9455
+ else:
9628
9456
  active_data = self.parent().channel_data[1]
9629
9457
  act_channel = 1
9630
- else:
9631
- active_data = self.parent().channel_data[1]
9632
- act_channel = 1
9633
9458
 
9634
- try:
9635
- array1 = np.zeros_like(active_data).astype(np.uint8)
9636
- except:
9637
- print("No data in nodes channel")
9638
- return
9459
+ try:
9460
+ if len(active_data.shape) == 3:
9461
+ array1 = np.zeros_like(active_data).astype(np.uint8)
9462
+ elif len(active_data.shape) == 4:
9463
+ array1 = np.zeros_like(active_data)[:,:,:,0].astype(np.uint8)
9464
+ except:
9465
+ print("No data in nodes channel")
9466
+ return
9639
9467
 
9640
- self.setWindowTitle("Threshold")
9641
-
9642
- # Create central widget and layout
9643
- central_widget = QWidget()
9644
- self.setCentralWidget(central_widget)
9645
- layout = QVBoxLayout(central_widget)
9468
+ self.setWindowTitle("Threshold")
9469
+
9470
+ # Create central widget and layout
9471
+ central_widget = QWidget()
9472
+ self.setCentralWidget(central_widget)
9473
+ layout = QVBoxLayout(central_widget)
9646
9474
 
9647
9475
 
9648
- # Create form layout for inputs
9649
- form_layout = QFormLayout()
9476
+ # Create form layout for inputs
9477
+ form_layout = QFormLayout()
9650
9478
 
9651
- layout.addLayout(form_layout)
9479
+ layout.addLayout(form_layout)
9652
9480
 
9653
- if self.parent().pen_button.isChecked(): #Disable the pen mode if the user is in it because the segmenter pen forks it
9654
- self.parent().pen_button.click()
9655
- self.parent().threed = False
9656
- self.parent().can = False
9657
- self.parent().last_change = None
9481
+ if self.parent().pen_button.isChecked(): #Disable the pen mode if the user is in it because the segmenter pen forks it
9482
+ self.parent().pen_button.click()
9483
+ self.parent().threed = False
9484
+ self.parent().can = False
9485
+ self.parent().last_change = None
9658
9486
 
9659
- self.parent().pen_button.setEnabled(False)
9487
+ self.parent().pen_button.setEnabled(False)
9660
9488
 
9661
- array3 = np.zeros_like(active_data).astype(np.uint8)
9662
- self.parent().highlight_overlay = array3 #Clear this out for the segmenter to use
9489
+ array3 = np.zeros_like(array1).astype(np.uint8)
9490
+ self.parent().highlight_overlay = array3 #Clear this out for the segmenter to use
9663
9491
 
9664
- self.parent().load_channel(2, array1, True)
9665
- # Enable the channel button
9666
- # Not exactly sure why we need all this but the channel buttons weren't loading like they normally do when load_channel() is called:
9667
- if not self.parent().channel_buttons[2].isEnabled():
9668
- self.parent().channel_buttons[2].setEnabled(True)
9669
- self.parent().channel_buttons[2].click()
9670
- self.parent().delete_buttons[2].setEnabled(True)
9492
+ self.parent().load_channel(2, array1, True)
9493
+ # Enable the channel button
9494
+ # Not exactly sure why we need all this but the channel buttons weren't loading like they normally do when load_channel() is called:
9495
+ if not self.parent().channel_buttons[2].isEnabled():
9496
+ self.parent().channel_buttons[2].setEnabled(True)
9497
+ self.parent().channel_buttons[2].click()
9498
+ self.parent().delete_buttons[2].setEnabled(True)
9671
9499
 
9672
- self.parent().base_colors[act_channel] = self.parent().color_dictionary['WHITE']
9673
- self.parent().base_colors[2] = self.parent().color_dictionary['LIGHT_GREEN']
9500
+ if len(active_data.shape) == 3:
9501
+ self.parent().base_colors[act_channel] = self.parent().color_dictionary['WHITE']
9502
+ self.parent().base_colors[2] = self.parent().color_dictionary['LIGHT_GREEN']
9674
9503
 
9675
- self.parent().update_display()
9676
-
9677
- # Set a reasonable default size for the window
9678
- self.setMinimumWidth(600) # Increased to accommodate grouped buttons
9679
- self.setMinimumHeight(500)
9504
+ self.parent().update_display()
9505
+
9506
+ # Set a reasonable default size for the window
9507
+ self.setMinimumWidth(600) # Increased to accommodate grouped buttons
9508
+ self.setMinimumHeight(500)
9509
+
9510
+ # Create main layout container
9511
+ main_widget = QWidget()
9512
+ main_layout = QVBoxLayout(main_widget)
9513
+
9514
+ # Group 1: Drawing tools (Brush + Foreground/Background)
9515
+ drawing_group = QGroupBox("Drawing Tools")
9516
+ drawing_layout = QHBoxLayout()
9517
+
9518
+ # Brush button
9519
+ self.brush_button = QPushButton("🖌️")
9520
+ self.brush_button.setCheckable(True)
9521
+ self.brush_button.setFixedSize(40, 40)
9522
+ self.brush_button.clicked.connect(self.toggle_brush_mode)
9523
+ self.brush_button.click()
9524
+
9525
+ # Foreground/Background buttons in their own horizontal layout
9526
+ fb_layout = QHBoxLayout()
9527
+ self.fore_button = QPushButton("Foreground")
9528
+ self.fore_button.setCheckable(True)
9529
+ self.fore_button.setChecked(True)
9530
+ self.fore_button.clicked.connect(self.toggle_foreground)
9680
9531
 
9681
- # Create main layout container
9682
- main_widget = QWidget()
9683
- main_layout = QVBoxLayout(main_widget)
9532
+ self.back_button = QPushButton("Background")
9533
+ self.back_button.setCheckable(True)
9534
+ self.back_button.setChecked(False)
9535
+ self.back_button.clicked.connect(self.toggle_background)
9684
9536
 
9685
- # Group 1: Drawing tools (Brush + Foreground/Background)
9686
- drawing_group = QGroupBox("Drawing Tools")
9687
- drawing_layout = QHBoxLayout()
9537
+ fb_layout.addWidget(self.fore_button)
9538
+ fb_layout.addWidget(self.back_button)
9688
9539
 
9689
- # Brush button
9690
- self.brush_button = QPushButton("🖌️")
9691
- self.brush_button.setCheckable(True)
9692
- self.brush_button.setFixedSize(40, 40)
9693
- self.brush_button.clicked.connect(self.toggle_brush_mode)
9694
- self.brush_button.click()
9540
+ drawing_layout.addWidget(self.brush_button)
9541
+ drawing_layout.addLayout(fb_layout)
9542
+ drawing_group.setLayout(drawing_layout)
9695
9543
 
9696
- # Foreground/Background buttons in their own horizontal layout
9697
- fb_layout = QHBoxLayout()
9698
- self.fore_button = QPushButton("Foreground")
9699
- self.fore_button.setCheckable(True)
9700
- self.fore_button.setChecked(True)
9701
- self.fore_button.clicked.connect(self.toggle_foreground)
9544
+ # Group 2: Processing Options (GPU)
9545
+ processing_group = QGroupBox("Processing Options")
9546
+ processing_layout = QHBoxLayout()
9702
9547
 
9703
- self.back_button = QPushButton("Background")
9704
- self.back_button.setCheckable(True)
9705
- self.back_button.setChecked(False)
9706
- self.back_button.clicked.connect(self.toggle_background)
9548
+ self.use_gpu = GPU
9549
+ self.two = QPushButton("Train By 2D Slice Patterns")
9550
+ self.two.setCheckable(True)
9551
+ self.two.setChecked(False)
9552
+ self.two.clicked.connect(self.toggle_two)
9553
+ self.use_two = False
9554
+ self.three = QPushButton("Train by 3D Patterns")
9555
+ self.three.setCheckable(True)
9556
+ self.three.setChecked(True)
9557
+ self.three.clicked.connect(self.toggle_three)
9558
+ self.GPU = QPushButton("GPU")
9559
+ self.GPU.setCheckable(True)
9560
+ self.GPU.setChecked(False)
9561
+ self.GPU.clicked.connect(self.toggle_GPU)
9562
+ processing_layout.addWidget(self.GPU)
9563
+ processing_layout.addWidget(self.two)
9564
+ processing_layout.addWidget(self.three)
9565
+ processing_group.setLayout(processing_layout)
9566
+
9567
+ # Group 3: Training Options
9568
+ training_group = QGroupBox("Training")
9569
+ training_layout = QHBoxLayout()
9570
+ train_quick = QPushButton("Train Quick Model (When Good SNR)")
9571
+ train_quick.clicked.connect(lambda: self.train_model(speed=True))
9572
+ train_detailed = QPushButton("Train Detailed Model (For Morphology)")
9573
+ train_detailed.clicked.connect(lambda: self.train_model(speed=False))
9574
+ save = QPushButton("Save Model")
9575
+ save.clicked.connect(self.save_model)
9576
+ load = QPushButton("Load Model")
9577
+ load.clicked.connect(self.load_model)
9578
+ training_layout.addWidget(train_quick)
9579
+ training_layout.addWidget(train_detailed)
9580
+ training_layout.addWidget(save)
9581
+ training_layout.addWidget(load)
9582
+ training_group.setLayout(training_layout)
9583
+
9584
+ # Group 4: Segmentation Options
9585
+ segmentation_group = QGroupBox("Segmentation")
9586
+ segmentation_layout = QHBoxLayout()
9587
+ seg_button = QPushButton("Preview Segment")
9588
+ self.seg_button = seg_button
9589
+ seg_button.clicked.connect(self.start_segmentation)
9590
+ self.pause_button = QPushButton("▶/⏸️")
9591
+ self.pause_button.setFixedSize(40, 40)
9592
+ self.pause_button.clicked.connect(self.toggle_segment)
9593
+ self.lock_button = QPushButton("🔒 Memory lock - (Prioritize RAM)")
9594
+ self.lock_button.setCheckable(True)
9595
+ self.lock_button.setChecked(True)
9596
+ self.lock_button.clicked.connect(self.toggle_lock)
9597
+ self.mem_lock = True
9598
+ full_button = QPushButton("Segment All")
9599
+ full_button.clicked.connect(self.segment)
9600
+ segmentation_layout.addWidget(seg_button)
9601
+ segmentation_layout.addWidget(self.pause_button) # <--- for some reason the segmenter preview is still running even when killed, may be regenerating itself somewhere. May or may not actually try to resolve this because this feature isnt that necessary.
9602
+ #segmentation_layout.addWidget(self.lock_button) # Also turned this off
9603
+ segmentation_layout.addWidget(full_button)
9604
+ segmentation_group.setLayout(segmentation_layout)
9605
+
9606
+ # Add all groups to main layout
9607
+ main_layout.addWidget(drawing_group)
9608
+ if not GPU:
9609
+ main_layout.addWidget(processing_group)
9610
+ main_layout.addWidget(training_group)
9611
+ main_layout.addWidget(segmentation_group)
9612
+
9613
+ # Set the main widget as the central widget
9614
+ self.setCentralWidget(main_widget)
9615
+
9616
+ self.trained = False
9617
+ self.previewing = False
9707
9618
 
9708
- fb_layout.addWidget(self.fore_button)
9709
- fb_layout.addWidget(self.back_button)
9619
+ if not GPU:
9620
+ self.segmenter = segmenter.InteractiveSegmenter(active_data, use_gpu=False)
9621
+ else:
9622
+ self.segmenter = seg_GPU.InteractiveSegmenter(active_data)
9710
9623
 
9711
- drawing_layout.addWidget(self.brush_button)
9712
- drawing_layout.addLayout(fb_layout)
9713
- drawing_group.setLayout(drawing_layout)
9624
+ self.segmentation_worker = None
9714
9625
 
9715
- # Group 2: Processing Options (GPU)
9716
- processing_group = QGroupBox("Processing Options")
9717
- processing_layout = QHBoxLayout()
9718
-
9719
- self.use_gpu = GPU
9720
- self.two = QPushButton("Train By 2D Slice Patterns")
9721
- self.two.setCheckable(True)
9722
- self.two.setChecked(False)
9723
- self.two.clicked.connect(self.toggle_two)
9724
- self.use_two = False
9725
- self.three = QPushButton("Train by 3D Patterns")
9726
- self.three.setCheckable(True)
9727
- self.three.setChecked(True)
9728
- self.three.clicked.connect(self.toggle_three)
9729
- self.GPU = QPushButton("GPU")
9730
- self.GPU.setCheckable(True)
9731
- self.GPU.setChecked(False)
9732
- self.GPU.clicked.connect(self.toggle_GPU)
9733
- processing_layout.addWidget(self.GPU)
9734
- processing_layout.addWidget(self.two)
9735
- processing_layout.addWidget(self.three)
9736
- processing_group.setLayout(processing_layout)
9626
+ self.fore_button.click()
9627
+ self.fore_button.click()
9737
9628
 
9738
- # Group 3: Training Options
9739
- training_group = QGroupBox("Training")
9740
- training_layout = QHBoxLayout()
9741
- train_quick = QPushButton("Train Quick Model")
9742
- train_quick.clicked.connect(lambda: self.train_model(speed=True))
9743
- train_detailed = QPushButton("Train More Detailed Model")
9744
- train_detailed.clicked.connect(lambda: self.train_model(speed=False))
9745
- save = QPushButton("Save Model")
9746
- save.clicked.connect(self.save_model)
9747
- load = QPushButton("Load Model")
9748
- load.clicked.connect(self.load_model)
9749
- training_layout.addWidget(train_quick)
9750
- training_layout.addWidget(train_detailed)
9751
- training_layout.addWidget(save)
9752
- training_layout.addWidget(load)
9753
- training_group.setLayout(training_layout)
9754
-
9755
- # Group 4: Segmentation Options
9756
- segmentation_group = QGroupBox("Segmentation")
9757
- segmentation_layout = QHBoxLayout()
9758
- seg_button = QPushButton("Preview Segment")
9759
- self.seg_button = seg_button
9760
- seg_button.clicked.connect(self.start_segmentation)
9761
- self.pause_button = QPushButton("▶/⏸️")
9762
- self.pause_button.clicked.connect(self.pause)
9763
- self.lock_button = QPushButton("🔒 Memory lock - (Prioritize RAM)")
9764
- self.lock_button.setCheckable(True)
9765
- self.lock_button.setChecked(True)
9766
- self.lock_button.clicked.connect(self.toggle_lock)
9767
- self.mem_lock = True
9768
- full_button = QPushButton("Segment All")
9769
- full_button.clicked.connect(self.segment)
9770
- segmentation_layout.addWidget(seg_button)
9771
- #segmentation_layout.addWidget(self.pause_button) # <--- for some reason the segmenter preview is still running even when killed, may be regenerating itself somewhere. May or may not actually try to resolve this because this feature isnt that necessary.
9772
- #segmentation_layout.addWidget(self.lock_button) # Also turned this off
9773
- segmentation_layout.addWidget(full_button)
9774
- segmentation_group.setLayout(segmentation_layout)
9775
-
9776
- # Add all groups to main layout
9777
- main_layout.addWidget(drawing_group)
9778
- if not GPU:
9779
- main_layout.addWidget(processing_group)
9780
- main_layout.addWidget(training_group)
9781
- main_layout.addWidget(segmentation_group)
9782
-
9783
- # Set the main widget as the central widget
9784
- self.setCentralWidget(main_widget)
9629
+ self.num_chunks = 0
9785
9630
 
9786
- self.trained = False
9787
- self.previewing = False
9631
+ except:
9632
+ return
9788
9633
 
9789
- if not GPU:
9790
- self.segmenter = segmenter.InteractiveSegmenter(active_data, use_gpu=False)
9791
- else:
9792
- self.segmenter = seg_GPU.InteractiveSegmenter(active_data)
9634
+ def toggle_segment(self):
9793
9635
 
9794
- self.segmentation_worker = None
9636
+ if self.segmentation_worker is not None:
9637
+ if not self.segmentation_worker._paused:
9638
+ self.segmentation_worker.pause()
9639
+ print("Segmentation Worker Paused")
9640
+ elif self.segmentation_worker._paused:
9641
+ self.segmentation_worker.resume()
9642
+ print("Segmentation Worker Resuming")
9795
9643
 
9796
- self.fore_button.click()
9797
- self.fore_button.click()
9798
9644
 
9799
9645
  def toggle_lock(self):
9800
9646
 
9801
9647
  self.mem_lock = self.lock_button.isChecked()
9802
9648
 
9803
- def pause(self):
9804
-
9805
- if self.segmentation_worker is not None:
9806
- try:
9807
- print("Pausing segmenter")
9808
- self.previewing = False
9809
- self.segmentation_finished
9810
- del self.segmentation_worker
9811
- self.segmentation_worker = None
9812
- except:
9813
- pass
9814
-
9815
- else:
9816
- try:
9817
- print("Restarting segmenter")
9818
- self.previewing = True
9819
- self.start_segmentation
9820
- except:
9821
- pass
9822
9649
 
9823
9650
  def save_model(self):
9824
9651
 
@@ -9840,6 +9667,9 @@ class MachineWindow(QMainWindow):
9840
9667
 
9841
9668
  except Exception as e:
9842
9669
  print(f"Error saving model: {e}")
9670
+ import traceback
9671
+ traceback.print_exc()
9672
+
9843
9673
 
9844
9674
  def load_model(self):
9845
9675
 
@@ -9935,7 +9765,23 @@ class MachineWindow(QMainWindow):
9935
9765
  def toggle_brush_mode(self):
9936
9766
  """Toggle brush mode on/off"""
9937
9767
  self.parent().brush_mode = self.brush_button.isChecked()
9768
+
9938
9769
  if self.parent().brush_mode:
9770
+
9771
+ self.parent().pm = painting.PaintManager(parent = self.parent())
9772
+
9773
+ # Start virtual paint session
9774
+ # Get current zoom to preserve it
9775
+ current_xlim = self.parent().ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
9776
+ current_ylim = self.parent().ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
9777
+
9778
+ if self.parent().pen_button.isChecked():
9779
+ channel = self.parent().active_channel
9780
+ else:
9781
+ channel = 2
9782
+
9783
+ self.parent().pm.initiate_paint_session(channel, current_xlim, current_ylim)
9784
+
9939
9785
  self.parent().pan_button.setChecked(False)
9940
9786
  self.parent().zoom_button.setChecked(False)
9941
9787
  if self.parent().pan_mode:
@@ -9962,12 +9808,13 @@ class MachineWindow(QMainWindow):
9962
9808
  self.kill_segmentation()
9963
9809
  # Wait a bit for cleanup
9964
9810
  time.sleep(0.1)
9965
- if not self.use_two:
9966
- self.previewing = False
9811
+
9812
+ self.previewing = True
9967
9813
  try:
9968
9814
  try:
9969
9815
  self.segmenter.train_batch(self.parent().channel_data[2], speed = speed, use_gpu = self.use_gpu, use_two = self.use_two, mem_lock = self.mem_lock)
9970
9816
  self.trained = True
9817
+ self.start_segmentation()
9971
9818
  except Exception as e:
9972
9819
  print("Error training. Perhaps you forgot both foreground and background markers? I need both!")
9973
9820
  import traceback
@@ -9983,6 +9830,8 @@ class MachineWindow(QMainWindow):
9983
9830
 
9984
9831
  def start_segmentation(self):
9985
9832
 
9833
+ self.parent().static_background = None
9834
+
9986
9835
  self.kill_segmentation()
9987
9836
  time.sleep(0.1)
9988
9837
 
@@ -9991,12 +9840,10 @@ class MachineWindow(QMainWindow):
9991
9840
  else:
9992
9841
  print("Beginning new segmentation...")
9993
9842
 
9994
-
9995
- if self.parent().active_channel == 0:
9996
- if self.parent().channel_data[0] is not None:
9997
- active_data = self.parent().channel_data[0]
9998
- else:
9999
- active_data = self.parent().channel_data[1]
9843
+ if self.parent().channel_data[2] is not None:
9844
+ active_data = self.parent().channel_data[2]
9845
+ else:
9846
+ active_data = self.parent().channel_data[0]
10000
9847
 
10001
9848
  array3 = np.zeros_like(active_data).astype(np.uint8)
10002
9849
  self.parent().highlight_overlay = array3 #Clear this out for the segmenter to use
@@ -10005,8 +9852,7 @@ class MachineWindow(QMainWindow):
10005
9852
  return
10006
9853
  else:
10007
9854
  self.segmentation_worker = SegmentationWorker(self.parent().highlight_overlay, self.segmenter, self.use_gpu, self.use_two, self.previewing, self, self.mem_lock)
10008
- self.segmentation_worker.chunk_processed.connect(self.update_display) # Just update display
10009
- self.segmentation_worker.finished.connect(self.segmentation_finished)
9855
+ self.segmentation_worker.chunk_processed.connect(lambda: self.update_display(skip_paint_reinit = True)) # Just update display
10010
9856
  current_xlim = self.parent().ax.get_xlim()
10011
9857
  current_ylim = self.parent().ax.get_ylim()
10012
9858
  try:
@@ -10059,7 +9905,7 @@ class MachineWindow(QMainWindow):
10059
9905
 
10060
9906
  return changed
10061
9907
 
10062
- def update_display(self):
9908
+ def update_display(self, skip_paint_reinit = False):
10063
9909
  if not hasattr(self, '_last_update'):
10064
9910
  self._last_update = 0
10065
9911
 
@@ -10069,8 +9915,7 @@ class MachineWindow(QMainWindow):
10069
9915
 
10070
9916
  self._last_z = current_z
10071
9917
 
10072
- if self.previewing:
10073
- changed = self.check_for_z_change()
9918
+ self.num_chunks += 1
10074
9919
 
10075
9920
  current_time = time.time()
10076
9921
  if current_time - self._last_update >= 1: # Match worker's interval
@@ -10087,71 +9932,40 @@ class MachineWindow(QMainWindow):
10087
9932
 
10088
9933
  if not self.parent().painting:
10089
9934
  # Only update if view limits are valid
10090
- self.parent().update_display(preserve_zoom=(current_xlim, current_ylim))
9935
+ self.parent().update_display(preserve_zoom=(current_xlim, current_ylim), skip_paint_reinit = skip_paint_reinit)
9936
+
9937
+ if self.parent().brush_mode:
9938
+ current_xlim = self.parent().ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
9939
+ current_ylim = self.parent().ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
9940
+
9941
+ if self.parent().pen_button.isChecked():
9942
+ channel = self.parent().active_channel
9943
+ else:
9944
+ channel = 2
10091
9945
 
9946
+ self.parent().pm.initiate_paint_session(channel, current_xlim, current_ylim)
10092
9947
 
10093
9948
  self._last_update = current_time
10094
9949
  except Exception as e:
10095
9950
  print(f"Display update error: {e}")
10096
9951
 
10097
9952
  def poke_segmenter(self):
10098
- if self.use_two and self.previewing:
10099
- try:
10100
- # Clear any processing flags in the segmenter
10101
- if hasattr(self.segmenter, '_currently_processing'):
10102
- self.segmenter._currently_processing = None
10103
-
10104
- # Force regenerating the worker
10105
- if self.segmentation_worker is not None:
10106
- self.kill_segmentation()
10107
-
10108
- time.sleep(0.2)
10109
- self.start_segmentation()
9953
+ try:
9954
+ # Clear any processing flags in the segmenter
9955
+ if hasattr(self.segmenter, '_currently_processing'):
9956
+ self.segmenter._currently_processing = None
10110
9957
 
10111
- except Exception as e:
10112
- print(f"Error in poke_segmenter: {e}")
10113
- import traceback
10114
- traceback.print_exc()
10115
-
10116
- def segmentation_finished(self):
10117
-
10118
- current_xlim = self.parent().ax.get_xlim()
10119
- current_ylim = self.parent().ax.get_ylim()
10120
- self.parent().update_display(preserve_zoom=(current_xlim, current_ylim))
10121
-
10122
- # Store the current z position before killing the worker
10123
- current_z = self.parent().current_slice
10124
-
10125
- # Clean up the worker
10126
- self.kill_segmentation()
10127
- self.segmentation_worker = None
10128
- time.sleep(0.1)
10129
-
10130
- # Auto-restart for 2D preview mode only if certain conditions are met
10131
- if self.previewing and self.use_two:
10132
- # Track when this slice was last processed
10133
- if not hasattr(self, '_processed_slices'):
10134
- self._processed_slices = {}
9958
+ # Force regenerating the worker
9959
+ if self.segmentation_worker is not None:
9960
+ self.kill_segmentation()
10135
9961
 
10136
- current_time = time.time()
9962
+ time.sleep(0.2)
9963
+ self.start_segmentation()
10137
9964
 
10138
- # Check if we've recently tried to process this slice (to prevent loops)
10139
- recently_processed = False
10140
- if current_z in self._processed_slices:
10141
- time_since_last_attempt = current_time - self._processed_slices[current_z]
10142
- recently_processed = time_since_last_attempt < 5.0 # 5 second cooldown
10143
-
10144
- if not recently_processed:
10145
- self._processed_slices[current_z] = current_time
10146
-
10147
- # Reset any processing flags in the segmenter
10148
- if hasattr(self.segmenter, '_currently_processing'):
10149
- self.segmenter._currently_processing = None
10150
-
10151
- if 0 in self.parent().highlight_overlay[current_z, :, :]:
10152
- # Create a new worker after a brief delay
10153
- QTimer.singleShot(500, self.start_segmentation)
10154
-
9965
+ except Exception as e:
9966
+ print(f"Error in poke_segmenter: {e}")
9967
+ import traceback
9968
+ traceback.print_exc()
10155
9969
 
10156
9970
 
10157
9971
  def kill_segmentation(self):
@@ -10185,11 +9999,10 @@ class MachineWindow(QMainWindow):
10185
9999
 
10186
10000
  self.previewing = False
10187
10001
 
10188
- if self.parent().active_channel == 0:
10189
- if self.parent().channel_data[0] is not None:
10190
- active_data = self.parent().channel_data[0]
10191
- else:
10192
- active_data = self.parent().channel_data[1]
10002
+ if self.parent().channel_data[2] is not None:
10003
+ active_data = self.parent().channel_data[2]
10004
+ else:
10005
+ active_data = self.parent().channel_data[0]
10193
10006
 
10194
10007
  array3 = np.zeros_like(active_data).astype(np.uint8)
10195
10008
  self.parent().highlight_overlay = array3 #Clear this out for the segmenter to use
@@ -10200,6 +10013,8 @@ class MachineWindow(QMainWindow):
10200
10013
  self.parent().highlight_overlay = self.segmenter.segment_volume(array = self.parent().highlight_overlay)
10201
10014
  except Exception as e:
10202
10015
  print(f"Error segmenting (Perhaps retrain the model...): {e}")
10016
+ import traceback
10017
+ traceback.print_exc()
10203
10018
  return
10204
10019
 
10205
10020
  # Clean up when done
@@ -10236,6 +10051,12 @@ class MachineWindow(QMainWindow):
10236
10051
  # Kill the segmentation thread and wait for it to finish
10237
10052
  self.kill_segmentation()
10238
10053
  time.sleep(0.2) # Give additional time for cleanup
10054
+
10055
+ try:
10056
+ self.parent().channel_data[0] = self.parent().reduce_rgb_dimension(self.parent().channel_data[0], 'weight')
10057
+ self.update_display()
10058
+ except:
10059
+ pass
10239
10060
 
10240
10061
  self.parent().machine_window = None
10241
10062
  else:
@@ -10296,59 +10117,28 @@ class SegmentationWorker(QThread):
10296
10117
 
10297
10118
  # Remember the starting z position
10298
10119
  self.starting_z = self.segmenter.current_z
10299
-
10300
- if self.previewing and self.use_two:
10301
- # Process current z-slice in chunks
10302
- current_z = self.segmenter.current_z
10303
-
10304
- # Process the slice with chunked generator
10305
- for foreground, background in self.segmenter.segment_slice_chunked(current_z):
10306
- # Check for pause/stop before processing each chunk
10307
- self._check_pause()
10308
- if self._stop:
10309
- break
10310
-
10311
- if foreground == None and background == None:
10312
- self.get_poked()
10313
10120
 
10314
- if self._stop:
10315
- break
10316
-
10317
- # Update the overlay
10318
- for z,y,x in foreground:
10319
- self.overlay[z,y,x] = 1
10320
- for z,y,x in background:
10321
- self.overlay[z,y,x] = 2
10322
-
10323
- # Signal update after each chunk
10324
- self.chunks_since_update += 1
10325
- current_time = time.time()
10326
- if (self.chunks_since_update >= self.chunks_per_update and
10327
- current_time - self.last_update >= self.update_interval):
10328
- self.chunk_processed.emit()
10329
- self.chunks_since_update = 0
10330
- self.last_update = current_time
10121
+ # Original 3D approach
10122
+ for foreground_coords, background_coords in self.segmenter.segment_volume_realtime(gpu=self.use_gpu):
10123
+ # Check for pause/stop before processing each chunk
10124
+ self._check_pause()
10125
+ if self._stop:
10126
+ break
10331
10127
 
10332
- else:
10333
- # Original 3D approach
10334
- for foreground_coords, background_coords in self.segmenter.segment_volume_realtime(gpu=self.use_gpu):
10335
- # Check for pause/stop before processing each chunk
10336
- self._check_pause()
10337
- if self._stop:
10338
- break
10339
-
10340
- for z,y,x in foreground_coords:
10341
- self.overlay[z,y,x] = 1
10342
- for z,y,x in background_coords:
10343
- self.overlay[z,y,x] = 2
10344
-
10345
- self.chunks_since_update += 1
10346
- current_time = time.time()
10347
- if (self.chunks_since_update >= self.chunks_per_update and
10348
- current_time - self.last_update >= self.update_interval):
10349
- self.chunk_processed.emit()
10350
- self.chunks_since_update = 0
10351
- self.last_update = current_time
10128
+ for z,y,x in foreground_coords:
10129
+ self.overlay[z,y,x] = 1
10130
+ for z,y,x in background_coords:
10131
+ self.overlay[z,y,x] = 2
10132
+
10133
+ self.chunks_since_update += 1
10134
+ current_time = time.time()
10135
+ if (self.chunks_since_update >= self.chunks_per_update and
10136
+ current_time - self.last_update >= self.update_interval):
10137
+ if self.machine_window.parent().shape[1] * self.machine_window.parent().shape[2] > 3000 * 3000: #arbitrary throttle for large arrays.
10138
+ self.msleep(3000)
10139
+ self.chunk_processed.emit()
10140
+ self.chunks_since_update = 0
10141
+ self.last_update = current_time
10352
10142
 
10353
10143
  self.finished.emit()
10354
10144