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

@@ -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
 
@@ -2170,11 +2161,6 @@ class ImageViewerWindow(QMainWindow):
2170
2161
  if self.zoom_mode:
2171
2162
  self.pan_button.setChecked(False)
2172
2163
 
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
2164
  self.pen_button.setChecked(False)
2179
2165
  self.pan_mode = False
2180
2166
  self.brush_mode = False
@@ -2184,6 +2170,11 @@ class ImageViewerWindow(QMainWindow):
2184
2170
  if self.machine_window is not None:
2185
2171
  self.machine_window.silence_button()
2186
2172
  self.canvas.setCursor(Qt.CursorShape.CrossCursor)
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
+
2187
2178
  else:
2188
2179
  if self.machine_window is None:
2189
2180
  self.canvas.setCursor(Qt.CursorShape.ArrowCursor)
@@ -2195,10 +2186,6 @@ class ImageViewerWindow(QMainWindow):
2195
2186
  """Toggle pan mode on/off."""
2196
2187
  self.pan_mode = self.pan_button.isChecked()
2197
2188
  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
2189
 
2203
2190
  self.zoom_button.setChecked(False)
2204
2191
  self.pen_button.setChecked(False)
@@ -2210,6 +2197,10 @@ class ImageViewerWindow(QMainWindow):
2210
2197
  if self.machine_window is not None:
2211
2198
  self.machine_window.silence_button()
2212
2199
  self.canvas.setCursor(Qt.CursorShape.OpenHandCursor)
2200
+ if self.brush_mode:
2201
+ current_xlim = self.ax.get_xlim()
2202
+ current_ylim = self.ax.get_ylim()
2203
+ self.update_display(preserve_zoom=(current_xlim, current_ylim))
2213
2204
  else:
2214
2205
  current_xlim = self.ax.get_xlim()
2215
2206
  current_ylim = self.ax.get_ylim()
@@ -2224,6 +2215,20 @@ class ImageViewerWindow(QMainWindow):
2224
2215
  self.brush_mode = self.pen_button.isChecked()
2225
2216
  if self.brush_mode:
2226
2217
 
2218
+ self.pm = painting.PaintManager(parent = self)
2219
+
2220
+ # Start virtual paint session
2221
+ # Get current zoom to preserve it
2222
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2223
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
2224
+
2225
+ if self.pen_button.isChecked():
2226
+ channel = self.active_channel
2227
+ else:
2228
+ channel = 2
2229
+
2230
+ self.pm.initiate_paint_session(channel, current_xlim, current_ylim)
2231
+
2227
2232
  if self.pan_mode:
2228
2233
  current_xlim = self.ax.get_xlim()
2229
2234
  current_ylim = self.ax.get_ylim()
@@ -2443,37 +2448,6 @@ class ImageViewerWindow(QMainWindow):
2443
2448
 
2444
2449
  painter.end()
2445
2450
 
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
2451
  def get_current_mouse_position(self):
2478
2452
  # Get the main application's current mouse position
2479
2453
  cursor_pos = QCursor.pos()
@@ -2486,15 +2460,25 @@ class ImageViewerWindow(QMainWindow):
2486
2460
  0 <= canvas_pos.y() < self.canvas.height()):
2487
2461
  return 0, 0 # Mouse is outside of the matplotlib canvas
2488
2462
 
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]
2463
+ # OPTION 1: Use matplotlib's built-in coordinate conversion
2464
+ # This accounts for figure margins, subplot positioning, etc.
2465
+ try:
2466
+ # Get the figure and axes bounds
2467
+ bbox = self.ax.bbox
2468
+
2469
+ # Convert widget coordinates to figure coordinates
2470
+ fig_x = canvas_pos.x()
2471
+ fig_y = self.canvas.height() - canvas_pos.y() # Flip Y coordinate
2472
+
2473
+ # Check if within axes bounds
2474
+ if (bbox.x0 <= fig_x <= bbox.x1 and bbox.y0 <= fig_y <= bbox.y1):
2475
+ # Transform to data coordinates
2476
+ data_coords = self.ax.transData.inverted().transform((fig_x, fig_y))
2477
+ return data_coords[0], data_coords[1]
2478
+ else:
2479
+ return 0, 0
2480
+ except:
2481
+ pass
2498
2482
 
2499
2483
  def on_mouse_press(self, event):
2500
2484
  """Handle mouse press events."""
@@ -2533,61 +2517,50 @@ class ImageViewerWindow(QMainWindow):
2533
2517
 
2534
2518
 
2535
2519
  elif self.brush_mode:
2520
+ """Handle brush mode with virtual painting."""
2536
2521
  if event.inaxes != self.ax:
2537
2522
  return
2538
2523
 
2539
2524
  if event.button == 1 or event.button == 3:
2525
+ if self.machine_window is not None:
2526
+ if self.machine_window.segmentation_worker is not None:
2527
+ self.machine_window.segmentation_worker.pause()
2528
+
2540
2529
  x, y = int(event.xdata), int(event.ydata)
2541
- # Get current zoom to preserve it
2542
-
2530
+
2531
+ # Get current zoom to preserve it
2543
2532
  current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
2544
2533
  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))
2534
+
2535
+ if event.button == 1 and getattr(self, 'can', False):
2536
+ self.update_display(preserve_zoom=(current_xlim, current_ylim), skip_paint_reinit = True)
2549
2537
  self.handle_can(x, y)
2550
2538
  return
2551
-
2539
+
2540
+ # Determine erase mode and foreground/background
2552
2541
  if event.button == 3:
2553
2542
  self.erase = True
2554
- self.update_display(preserve_zoom = (current_xlim, current_ylim))
2555
2543
  else:
2556
2544
  self.erase = False
2557
-
2558
- self.painting = True
2559
- self.last_paint_pos = (x, y)
2560
-
2545
+
2546
+ # Determine foreground/background for machine window mode
2547
+ foreground = getattr(self, 'foreground', True)
2548
+
2549
+ self.last_virtual_pos = (x, y)
2550
+
2561
2551
  if self.pen_button.isChecked():
2562
2552
  channel = self.active_channel
2563
2553
  else:
2564
2554
  channel = 2
2565
2555
 
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))
2556
+ self.pm.start_virtual_paint_session(channel, current_xlim, current_ylim)
2557
+
2558
+ # Add first virtual paint stroke
2559
+ brush_size = getattr(self, 'brush_size', 5)
2560
+ self.pm.add_virtual_paint_stroke(x, y, brush_size, self.erase, foreground)
2561
+
2562
+ # Update display with virtual paint
2563
+ self.pm.update_virtual_paint_display()
2591
2564
 
2592
2565
  elif not self.zoom_mode and event.button == 3: # Right click (for context menu)
2593
2566
  self.create_context_menu(event)
@@ -2597,92 +2570,6 @@ class ImageViewerWindow(QMainWindow):
2597
2570
  self.selection_start = (event.xdata, event.ydata)
2598
2571
  self.selecting = False # Will be set to True if the mouse moves while button is held
2599
2572
 
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
2573
 
2687
2574
  def handle_can(self, x, y):
2688
2575
 
@@ -2823,129 +2710,24 @@ class ImageViewerWindow(QMainWindow):
2823
2710
  if event.inaxes != self.ax:
2824
2711
  return
2825
2712
 
2826
- # OPTIMIZED: Queue paint operation instead of immediate execution
2827
- self.queue_paint_operation(event)
2713
+ # Throttle updates like selection rectangle
2714
+ current_time = time.time()
2715
+ if current_time - getattr(self, 'last_paint_update_time', 0) < 0.016: # ~60fps
2716
+ return
2717
+ self.last_paint_update_time = current_time
2828
2718
 
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
- }
2719
+ x, y = int(event.xdata), int(event.ydata)
2863
2720
 
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
2721
+ # Determine foreground/background for machine window mode
2722
+ foreground = getattr(self, 'foreground', True)
2869
2723
 
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
2724
+ # Add virtual paint stroke with interpolation
2725
+ brush_size = getattr(self, 'brush_size', 5)
2726
+ self.pm.add_virtual_paint_stroke(x, y, brush_size, self.erase, foreground)
2876
2727
 
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
2728
+ # Update display with virtual paint (super fast)
2729
+ self.pm.update_virtual_paint_display()
2880
2730
 
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
2731
 
2950
2732
  def create_pan_background(self):
2951
2733
  """Create a static background image from currently visible channels with proper rendering"""
@@ -3322,32 +3104,23 @@ class ImageViewerWindow(QMainWindow):
3322
3104
 
3323
3105
  # Handle brush mode cleanup with paint session management
3324
3106
  if self.brush_mode and hasattr(self, 'painting') and self.painting:
3107
+ # Finish current operation
3108
+ self.pm.finish_current_virtual_operation()
3109
+
3110
+ # Reset last position for next stroke
3111
+ self.last_virtual_pos = None
3112
+
3113
+ # End this stroke but keep session active for continuous painting
3325
3114
  self.painting = False
3326
3115
 
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
3116
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
3117
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3347
3118
 
3348
- current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
3119
+ self.update_display(preserve_zoom=(current_xlim, current_ylim), continue_paint = True)
3349
3120
 
3350
- self.update_display(preserve_zoom = (current_xlim, current_ylim))
3121
+ if self.machine_window is not None:
3122
+ if self.machine_window.segmentation_worker is not None:
3123
+ self.machine_window.segmentation_worker.resume()
3351
3124
 
3352
3125
 
3353
3126
 
@@ -3675,6 +3448,8 @@ class ImageViewerWindow(QMainWindow):
3675
3448
  community_code_action.triggered.connect(lambda: self.show_code_dialog(sort = 'Community'))
3676
3449
  id_code_action = overlay_menu.addAction("Code Identities")
3677
3450
  id_code_action.triggered.connect(lambda: self.show_code_dialog(sort = 'Identity'))
3451
+ umap_action = overlay_menu.addAction("Centroid UMAP")
3452
+ umap_action.triggered.connect(self.handle_umap)
3678
3453
 
3679
3454
  rand_menu = analysis_menu.addMenu("Randomize")
3680
3455
  random_action = rand_menu.addAction("Generate Equivalent Random Network")
@@ -3779,6 +3554,43 @@ class ImageViewerWindow(QMainWindow):
3779
3554
  help_button = menubar.addAction("Help")
3780
3555
  help_button.triggered.connect(self.help_me)
3781
3556
 
3557
+ cam_button = QPushButton("📷")
3558
+ cam_button.setFixedSize(40, 40)
3559
+ cam_button.setStyleSheet("font-size: 24px;") # Makes emoji larger
3560
+ cam_button.clicked.connect(self.snap)
3561
+ menubar.setCornerWidget(cam_button, Qt.Corner.TopRightCorner)
3562
+
3563
+ def snap(self):
3564
+
3565
+ try:
3566
+
3567
+ for thing in self.channel_data:
3568
+ if thing is not None:
3569
+ data = True
3570
+ if not data:
3571
+ return
3572
+
3573
+ snap = self.create_composite_for_pan()
3574
+
3575
+ filename, _ = QFileDialog.getSaveFileName(
3576
+ self,
3577
+ f"Save Image As",
3578
+ "", # Default directory
3579
+ "TIFF Files (*.tif *.tiff);;All Files (*)" # File type filter
3580
+ )
3581
+
3582
+ if filename: # Only proceed if user didn't cancel
3583
+ # If user didn't type an extension, add .tif
3584
+ if not filename.endswith(('.tif', '.tiff')):
3585
+ filename += '.tif'
3586
+
3587
+ import tifffile
3588
+ tifffile.imwrite(filename, snap)
3589
+
3590
+ except:
3591
+ pass
3592
+
3593
+
3782
3594
  def open_cellpose(self):
3783
3595
 
3784
3596
  try:
@@ -4555,6 +4367,7 @@ class ImageViewerWindow(QMainWindow):
4555
4367
  )
4556
4368
 
4557
4369
  self.last_load = directory
4370
+
4558
4371
 
4559
4372
  if directory != "":
4560
4373
 
@@ -4817,6 +4630,7 @@ class ImageViewerWindow(QMainWindow):
4817
4630
  - 'mean': averages across color channels
4818
4631
  - 'max': takes maximum value across color channels
4819
4632
  - 'min': takes minimum value across color channels
4633
+ - 'weight': takes weighted channel averages
4820
4634
 
4821
4635
  Returns:
4822
4636
  --------
@@ -4831,7 +4645,7 @@ class ImageViewerWindow(QMainWindow):
4831
4645
  if array.ndim != 4:
4832
4646
  raise ValueError(f"Expected 4D array, got {array.ndim}D array")
4833
4647
 
4834
- if method not in ['first', 'mean', 'max', 'min']:
4648
+ if method not in ['first', 'mean', 'max', 'min', 'weight']:
4835
4649
  raise ValueError(f"Unknown method: {method}")
4836
4650
 
4837
4651
  if method == 'first':
@@ -4840,6 +4654,9 @@ class ImageViewerWindow(QMainWindow):
4840
4654
  return np.mean(array, axis=-1)
4841
4655
  elif method == 'max':
4842
4656
  return np.max(array, axis=-1)
4657
+ elif method == 'weight':
4658
+ # Apply the luminosity formula
4659
+ return (0.2989 * array[:,:,:,0] + 0.5870 * array[:,:,:,1] + 0.1140 * array[:,:,:,2])
4843
4660
  else: # min
4844
4661
  return np.min(array, axis=-1)
4845
4662
 
@@ -4863,7 +4680,7 @@ class ImageViewerWindow(QMainWindow):
4863
4680
  msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
4864
4681
  return msg.exec() == QMessageBox.StandardButton.Yes
4865
4682
 
4866
- def load_channel(self, channel_index, channel_data=None, data=False, assign_shape = True, preserve_zoom = None, end_paint = False, begin_paint = False):
4683
+ 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
4684
  """Load a channel and enable active channel selection if needed."""
4868
4685
 
4869
4686
  try:
@@ -4940,12 +4757,12 @@ class ImageViewerWindow(QMainWindow):
4940
4757
  except:
4941
4758
  pass
4942
4759
 
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
4760
+ if not color:
4761
+ try:
4762
+ if len(self.channel_data[channel_index].shape) == 4 and (channel_index == 0 or channel_index == 1):
4763
+ self.channel_data[channel_index] = self.reduce_rgb_dimension(self.channel_data[channel_index], 'weight')
4764
+ except:
4765
+ pass
4949
4766
 
4950
4767
  reset_resize = False
4951
4768
 
@@ -5295,25 +5112,29 @@ class ImageViewerWindow(QMainWindow):
5295
5112
 
5296
5113
 
5297
5114
 
5298
- def update_display(self, preserve_zoom=None, dims = None, called = False, reset_resize = False):
5115
+ def update_display(self, preserve_zoom=None, dims = None, called = False, reset_resize = False, continue_paint = False, skip_paint_reinit = False):
5299
5116
  """Update the display with currently visible channels and highlight overlay."""
5300
-
5301
5117
  try:
5302
-
5303
5118
  self.figure.clear()
5304
-
5305
5119
  if self.pan_background_image is not None:
5306
5120
  # Restore previously visible channels
5307
5121
  self.channel_visible = self.pre_pan_channel_state.copy()
5308
5122
  self.is_pan_preview = False
5309
5123
  self.pan_background_image = None
5310
-
5311
5124
  if self.machine_window is not None:
5312
5125
  if self.machine_window.segmentation_worker is not None:
5313
5126
  self.machine_window.segmentation_worker.resume()
5314
-
5315
5127
  if self.static_background is not None:
5316
-
5128
+ # NEW: Convert virtual strokes to real data before cleanup
5129
+ if (hasattr(self, 'virtual_draw_operations') and self.virtual_draw_operations) or \
5130
+ (hasattr(self, 'virtual_erase_operations') and self.virtual_erase_operations) or \
5131
+ (hasattr(self, 'current_operation') and self.current_operation):
5132
+ # Finish current operation first
5133
+ if hasattr(self, 'current_operation') and self.current_operation:
5134
+ self.pm.finish_current_virtual_operation()
5135
+ # Now convert to real data
5136
+ self.pm.convert_virtual_strokes_to_data()
5137
+
5317
5138
  # Restore hidden channels
5318
5139
  try:
5319
5140
  for i in self.restore_channels:
@@ -5321,27 +5142,17 @@ class ImageViewerWindow(QMainWindow):
5321
5142
  self.restore_channels = []
5322
5143
  except:
5323
5144
  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
5334
-
5335
- if self.machine_window is None:
5145
+ if not continue_paint:
5146
+ self.static_background = None
5336
5147
 
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
5148
+ if self.machine_window is None:
5149
+ try:
5150
+ 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, :, :])
5151
+ self.load_channel(self.temp_chan, self.channel_data[4], data = True, end_paint = True)
5152
+ self.channel_data[4] = None
5153
+ self.channel_visible[4] = False
5154
+ except:
5155
+ pass
5345
5156
 
5346
5157
  # Get active channels and their dimensions
5347
5158
  active_channels = [i for i in range(4) if self.channel_data[i] is not None]
@@ -5572,49 +5383,22 @@ class ImageViewerWindow(QMainWindow):
5572
5383
 
5573
5384
  self.canvas.draw()
5574
5385
 
5386
+ if self.brush_mode and not skip_paint_reinit:
5387
+ # Get current zoom to preserve it
5388
+ current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
5389
+ current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
5390
+
5391
+ if self.pen_button.isChecked():
5392
+ channel = self.active_channel
5393
+ else:
5394
+ channel = 2
5395
+
5396
+ self.pm.initiate_paint_session(channel, current_xlim, current_ylim)
5397
+
5575
5398
  except:
5576
5399
  import traceback
5577
5400
  print(traceback.format_exc())
5578
5401
 
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
5402
  def get_channel_image(self, channel):
5619
5403
  """Find the matplotlib image object for a specific channel."""
5620
5404
  if not hasattr(self.ax, 'images'):
@@ -5744,6 +5528,13 @@ class ImageViewerWindow(QMainWindow):
5744
5528
  dialog = CodeDialog(self, sort = sort)
5745
5529
  dialog.exec()
5746
5530
 
5531
+ def handle_umap(self):
5532
+
5533
+ if my_network.node_centroids is None:
5534
+ self.show_centroid_dialog()
5535
+
5536
+ my_network.centroid_umap()
5537
+
5747
5538
  def closeEvent(self, event):
5748
5539
  """Override closeEvent to close all windows when main window closes"""
5749
5540
 
@@ -7264,24 +7055,29 @@ class ColorOverlayDialog(QDialog):
7264
7055
 
7265
7056
  def coloroverlay(self):
7266
7057
 
7267
- down_factor = float(self.down_factor.text()) if self.down_factor.text().strip() else None
7058
+ try:
7268
7059
 
7269
- if self.parent().active_channel == 0:
7270
- mode = 0
7271
- self.sort = 'Node'
7272
- else:
7273
- mode = 1
7274
- self.sort = 'Edge'
7060
+ down_factor = float(self.down_factor.text()) if self.down_factor.text().strip() else None
7061
+
7062
+ if self.parent().active_channel == 0:
7063
+ mode = 0
7064
+ self.sort = 'Node'
7065
+ else:
7066
+ mode = 1
7067
+ self.sort = 'Edge'
7275
7068
 
7276
7069
 
7277
- result, legend = my_network.node_to_color(down_factor = down_factor, mode = mode)
7070
+ result, legend = my_network.node_to_color(down_factor = down_factor, mode = mode)
7278
7071
 
7279
- self.parent().format_for_upperright_table(legend, f'{self.sort} Id', f'Encoding Val: {self.sort}', 'Legend')
7072
+ self.parent().format_for_upperright_table(legend, f'{self.sort} Id', f'Encoding Val: {self.sort}', 'Legend')
7280
7073
 
7281
7074
 
7282
- self.parent().load_channel(3, channel_data = result, data = True)
7075
+ self.parent().load_channel(3, channel_data = result, data = True)
7283
7076
 
7284
- self.accept()
7077
+ self.accept()
7078
+
7079
+ except:
7080
+ pass
7285
7081
 
7286
7082
 
7287
7083
  class ShuffleDialog(QDialog):
@@ -7624,7 +7420,7 @@ class ComNeighborDialog(QDialog):
7624
7420
  # weighted checkbox (default True)
7625
7421
  self.proportional = QPushButton("Robust")
7626
7422
  self.proportional.setCheckable(True)
7627
- self.proportional.setChecked(False)
7423
+ self.proportional.setChecked(True)
7628
7424
  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
7425
 
7630
7426
  self.mode = QComboBox()
@@ -9503,6 +9299,12 @@ class ThresholdDialog(QDialog):
9503
9299
 
9504
9300
  def start_ml(self, GPU = False):
9505
9301
 
9302
+ try:
9303
+ 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.")
9304
+ self.parent().load_channel(0, color = True)
9305
+ except:
9306
+ pass
9307
+
9506
9308
 
9507
9309
  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
9310
  if self.confirm_machine_dialog():
@@ -9619,206 +9421,208 @@ class MachineWindow(QMainWindow):
9619
9421
  def __init__(self, parent=None, GPU = False):
9620
9422
  super().__init__(parent)
9621
9423
 
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:
9424
+ try:
9425
+
9426
+ if self.parent().active_channel == 0:
9427
+ if self.parent().channel_data[0] is not None:
9428
+ try:
9429
+ active_data = self.parent().channel_data[0]
9430
+ act_channel = 0
9431
+ except:
9432
+ active_data = self.parent().channel_data[1]
9433
+ act_channel = 1
9434
+ else:
9628
9435
  active_data = self.parent().channel_data[1]
9629
9436
  act_channel = 1
9630
- else:
9631
- active_data = self.parent().channel_data[1]
9632
- act_channel = 1
9633
-
9634
- try:
9635
- array1 = np.zeros_like(active_data).astype(np.uint8)
9636
- except:
9637
- print("No data in nodes channel")
9638
- return
9639
9437
 
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)
9438
+ try:
9439
+ if len(active_data.shape) == 3:
9440
+ array1 = np.zeros_like(active_data).astype(np.uint8)
9441
+ elif len(active_data.shape) == 4:
9442
+ array1 = np.zeros_like(active_data)[:,:,:,0].astype(np.uint8)
9443
+ except:
9444
+ print("No data in nodes channel")
9445
+ return
9646
9446
 
9447
+ self.setWindowTitle("Threshold")
9448
+
9449
+ # Create central widget and layout
9450
+ central_widget = QWidget()
9451
+ self.setCentralWidget(central_widget)
9452
+ layout = QVBoxLayout(central_widget)
9647
9453
 
9648
- # Create form layout for inputs
9649
- form_layout = QFormLayout()
9650
9454
 
9651
- layout.addLayout(form_layout)
9455
+ # Create form layout for inputs
9456
+ form_layout = QFormLayout()
9652
9457
 
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
9458
+ layout.addLayout(form_layout)
9658
9459
 
9659
- self.parent().pen_button.setEnabled(False)
9460
+ if self.parent().pen_button.isChecked(): #Disable the pen mode if the user is in it because the segmenter pen forks it
9461
+ self.parent().pen_button.click()
9462
+ self.parent().threed = False
9463
+ self.parent().can = False
9464
+ self.parent().last_change = None
9660
9465
 
9661
- array3 = np.zeros_like(active_data).astype(np.uint8)
9662
- self.parent().highlight_overlay = array3 #Clear this out for the segmenter to use
9466
+ self.parent().pen_button.setEnabled(False)
9663
9467
 
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)
9468
+ array3 = np.zeros_like(array1).astype(np.uint8)
9469
+ self.parent().highlight_overlay = array3 #Clear this out for the segmenter to use
9671
9470
 
9672
- self.parent().base_colors[act_channel] = self.parent().color_dictionary['WHITE']
9673
- self.parent().base_colors[2] = self.parent().color_dictionary['LIGHT_GREEN']
9471
+ self.parent().load_channel(2, array1, True)
9472
+ # Enable the channel button
9473
+ # Not exactly sure why we need all this but the channel buttons weren't loading like they normally do when load_channel() is called:
9474
+ if not self.parent().channel_buttons[2].isEnabled():
9475
+ self.parent().channel_buttons[2].setEnabled(True)
9476
+ self.parent().channel_buttons[2].click()
9477
+ self.parent().delete_buttons[2].setEnabled(True)
9674
9478
 
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)
9479
+ if len(active_data.shape) == 3:
9480
+ self.parent().base_colors[act_channel] = self.parent().color_dictionary['WHITE']
9481
+ self.parent().base_colors[2] = self.parent().color_dictionary['LIGHT_GREEN']
9680
9482
 
9681
- # Create main layout container
9682
- main_widget = QWidget()
9683
- main_layout = QVBoxLayout(main_widget)
9483
+ self.parent().update_display()
9484
+
9485
+ # Set a reasonable default size for the window
9486
+ self.setMinimumWidth(600) # Increased to accommodate grouped buttons
9487
+ self.setMinimumHeight(500)
9488
+
9489
+ # Create main layout container
9490
+ main_widget = QWidget()
9491
+ main_layout = QVBoxLayout(main_widget)
9492
+
9493
+ # Group 1: Drawing tools (Brush + Foreground/Background)
9494
+ drawing_group = QGroupBox("Drawing Tools")
9495
+ drawing_layout = QHBoxLayout()
9496
+
9497
+ # Brush button
9498
+ self.brush_button = QPushButton("🖌️")
9499
+ self.brush_button.setCheckable(True)
9500
+ self.brush_button.setFixedSize(40, 40)
9501
+ self.brush_button.clicked.connect(self.toggle_brush_mode)
9502
+ self.brush_button.click()
9503
+
9504
+ # Foreground/Background buttons in their own horizontal layout
9505
+ fb_layout = QHBoxLayout()
9506
+ self.fore_button = QPushButton("Foreground")
9507
+ self.fore_button.setCheckable(True)
9508
+ self.fore_button.setChecked(True)
9509
+ self.fore_button.clicked.connect(self.toggle_foreground)
9684
9510
 
9685
- # Group 1: Drawing tools (Brush + Foreground/Background)
9686
- drawing_group = QGroupBox("Drawing Tools")
9687
- drawing_layout = QHBoxLayout()
9511
+ self.back_button = QPushButton("Background")
9512
+ self.back_button.setCheckable(True)
9513
+ self.back_button.setChecked(False)
9514
+ self.back_button.clicked.connect(self.toggle_background)
9688
9515
 
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()
9516
+ fb_layout.addWidget(self.fore_button)
9517
+ fb_layout.addWidget(self.back_button)
9695
9518
 
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)
9519
+ drawing_layout.addWidget(self.brush_button)
9520
+ drawing_layout.addLayout(fb_layout)
9521
+ drawing_group.setLayout(drawing_layout)
9702
9522
 
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)
9523
+ # Group 2: Processing Options (GPU)
9524
+ processing_group = QGroupBox("Processing Options")
9525
+ processing_layout = QHBoxLayout()
9707
9526
 
9708
- fb_layout.addWidget(self.fore_button)
9709
- fb_layout.addWidget(self.back_button)
9527
+ self.use_gpu = GPU
9528
+ self.two = QPushButton("Train By 2D Slice Patterns")
9529
+ self.two.setCheckable(True)
9530
+ self.two.setChecked(False)
9531
+ self.two.clicked.connect(self.toggle_two)
9532
+ self.use_two = False
9533
+ self.three = QPushButton("Train by 3D Patterns")
9534
+ self.three.setCheckable(True)
9535
+ self.three.setChecked(True)
9536
+ self.three.clicked.connect(self.toggle_three)
9537
+ self.GPU = QPushButton("GPU")
9538
+ self.GPU.setCheckable(True)
9539
+ self.GPU.setChecked(False)
9540
+ self.GPU.clicked.connect(self.toggle_GPU)
9541
+ processing_layout.addWidget(self.GPU)
9542
+ processing_layout.addWidget(self.two)
9543
+ processing_layout.addWidget(self.three)
9544
+ processing_group.setLayout(processing_layout)
9545
+
9546
+ # Group 3: Training Options
9547
+ training_group = QGroupBox("Training")
9548
+ training_layout = QHBoxLayout()
9549
+ train_quick = QPushButton("Train Quick Model (When Good SNR)")
9550
+ train_quick.clicked.connect(lambda: self.train_model(speed=True))
9551
+ train_detailed = QPushButton("Train Detailed Model (For Morphology)")
9552
+ train_detailed.clicked.connect(lambda: self.train_model(speed=False))
9553
+ save = QPushButton("Save Model")
9554
+ save.clicked.connect(self.save_model)
9555
+ load = QPushButton("Load Model")
9556
+ load.clicked.connect(self.load_model)
9557
+ training_layout.addWidget(train_quick)
9558
+ training_layout.addWidget(train_detailed)
9559
+ training_layout.addWidget(save)
9560
+ training_layout.addWidget(load)
9561
+ training_group.setLayout(training_layout)
9562
+
9563
+ # Group 4: Segmentation Options
9564
+ segmentation_group = QGroupBox("Segmentation")
9565
+ segmentation_layout = QHBoxLayout()
9566
+ seg_button = QPushButton("Preview Segment")
9567
+ self.seg_button = seg_button
9568
+ seg_button.clicked.connect(self.start_segmentation)
9569
+ self.pause_button = QPushButton("▶/⏸️")
9570
+ self.pause_button.setFixedSize(40, 40)
9571
+ self.pause_button.clicked.connect(self.toggle_segment)
9572
+ self.lock_button = QPushButton("🔒 Memory lock - (Prioritize RAM)")
9573
+ self.lock_button.setCheckable(True)
9574
+ self.lock_button.setChecked(True)
9575
+ self.lock_button.clicked.connect(self.toggle_lock)
9576
+ self.mem_lock = True
9577
+ full_button = QPushButton("Segment All")
9578
+ full_button.clicked.connect(self.segment)
9579
+ segmentation_layout.addWidget(seg_button)
9580
+ 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.
9581
+ #segmentation_layout.addWidget(self.lock_button) # Also turned this off
9582
+ segmentation_layout.addWidget(full_button)
9583
+ segmentation_group.setLayout(segmentation_layout)
9584
+
9585
+ # Add all groups to main layout
9586
+ main_layout.addWidget(drawing_group)
9587
+ if not GPU:
9588
+ main_layout.addWidget(processing_group)
9589
+ main_layout.addWidget(training_group)
9590
+ main_layout.addWidget(segmentation_group)
9591
+
9592
+ # Set the main widget as the central widget
9593
+ self.setCentralWidget(main_widget)
9594
+
9595
+ self.trained = False
9596
+ self.previewing = False
9710
9597
 
9711
- drawing_layout.addWidget(self.brush_button)
9712
- drawing_layout.addLayout(fb_layout)
9713
- drawing_group.setLayout(drawing_layout)
9598
+ if not GPU:
9599
+ self.segmenter = segmenter.InteractiveSegmenter(active_data, use_gpu=False)
9600
+ else:
9601
+ self.segmenter = seg_GPU.InteractiveSegmenter(active_data)
9714
9602
 
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)
9603
+ self.segmentation_worker = None
9737
9604
 
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)
9605
+ self.fore_button.click()
9606
+ self.fore_button.click()
9785
9607
 
9786
- self.trained = False
9787
- self.previewing = False
9608
+ except:
9609
+ return
9788
9610
 
9789
- if not GPU:
9790
- self.segmenter = segmenter.InteractiveSegmenter(active_data, use_gpu=False)
9791
- else:
9792
- self.segmenter = seg_GPU.InteractiveSegmenter(active_data)
9611
+ def toggle_segment(self):
9793
9612
 
9794
- self.segmentation_worker = None
9613
+ if self.segmentation_worker is not None:
9614
+ if not self.segmentation_worker._paused:
9615
+ self.segmentation_worker.pause()
9616
+ print("Segmentation Worker Paused")
9617
+ elif self.segmentation_worker._paused:
9618
+ self.segmentation_worker.resume()
9619
+ print("Segmentation Worker Resuming")
9795
9620
 
9796
- self.fore_button.click()
9797
- self.fore_button.click()
9798
9621
 
9799
9622
  def toggle_lock(self):
9800
9623
 
9801
9624
  self.mem_lock = self.lock_button.isChecked()
9802
9625
 
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
9626
 
9823
9627
  def save_model(self):
9824
9628
 
@@ -9840,6 +9644,9 @@ class MachineWindow(QMainWindow):
9840
9644
 
9841
9645
  except Exception as e:
9842
9646
  print(f"Error saving model: {e}")
9647
+ import traceback
9648
+ traceback.print_exc()
9649
+
9843
9650
 
9844
9651
  def load_model(self):
9845
9652
 
@@ -9935,7 +9742,23 @@ class MachineWindow(QMainWindow):
9935
9742
  def toggle_brush_mode(self):
9936
9743
  """Toggle brush mode on/off"""
9937
9744
  self.parent().brush_mode = self.brush_button.isChecked()
9745
+
9938
9746
  if self.parent().brush_mode:
9747
+
9748
+ self.parent().pm = painting.PaintManager(parent = self.parent())
9749
+
9750
+ # Start virtual paint session
9751
+ # Get current zoom to preserve it
9752
+ current_xlim = self.parent().ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
9753
+ current_ylim = self.parent().ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
9754
+
9755
+ if self.parent().pen_button.isChecked():
9756
+ channel = self.parent().active_channel
9757
+ else:
9758
+ channel = 2
9759
+
9760
+ self.parent().pm.initiate_paint_session(channel, current_xlim, current_ylim)
9761
+
9939
9762
  self.parent().pan_button.setChecked(False)
9940
9763
  self.parent().zoom_button.setChecked(False)
9941
9764
  if self.parent().pan_mode:
@@ -9962,12 +9785,13 @@ class MachineWindow(QMainWindow):
9962
9785
  self.kill_segmentation()
9963
9786
  # Wait a bit for cleanup
9964
9787
  time.sleep(0.1)
9965
- if not self.use_two:
9966
- self.previewing = False
9788
+
9789
+ self.previewing = True
9967
9790
  try:
9968
9791
  try:
9969
9792
  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
9793
  self.trained = True
9794
+ self.start_segmentation()
9971
9795
  except Exception as e:
9972
9796
  print("Error training. Perhaps you forgot both foreground and background markers? I need both!")
9973
9797
  import traceback
@@ -9983,6 +9807,8 @@ class MachineWindow(QMainWindow):
9983
9807
 
9984
9808
  def start_segmentation(self):
9985
9809
 
9810
+ self.parent().static_background = None
9811
+
9986
9812
  self.kill_segmentation()
9987
9813
  time.sleep(0.1)
9988
9814
 
@@ -9991,12 +9817,10 @@ class MachineWindow(QMainWindow):
9991
9817
  else:
9992
9818
  print("Beginning new segmentation...")
9993
9819
 
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]
9820
+ if self.parent().channel_data[2] is not None:
9821
+ active_data = self.parent().channel_data[2]
9822
+ else:
9823
+ active_data = self.parent().channel_data[0]
10000
9824
 
10001
9825
  array3 = np.zeros_like(active_data).astype(np.uint8)
10002
9826
  self.parent().highlight_overlay = array3 #Clear this out for the segmenter to use
@@ -10005,8 +9829,7 @@ class MachineWindow(QMainWindow):
10005
9829
  return
10006
9830
  else:
10007
9831
  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)
9832
+ self.segmentation_worker.chunk_processed.connect(lambda: self.update_display(skip_paint_reinit = True)) # Just update display
10010
9833
  current_xlim = self.parent().ax.get_xlim()
10011
9834
  current_ylim = self.parent().ax.get_ylim()
10012
9835
  try:
@@ -10059,7 +9882,7 @@ class MachineWindow(QMainWindow):
10059
9882
 
10060
9883
  return changed
10061
9884
 
10062
- def update_display(self):
9885
+ def update_display(self, skip_paint_reinit = False):
10063
9886
  if not hasattr(self, '_last_update'):
10064
9887
  self._last_update = 0
10065
9888
 
@@ -10069,8 +9892,6 @@ class MachineWindow(QMainWindow):
10069
9892
 
10070
9893
  self._last_z = current_z
10071
9894
 
10072
- if self.previewing:
10073
- changed = self.check_for_z_change()
10074
9895
 
10075
9896
  current_time = time.time()
10076
9897
  if current_time - self._last_update >= 1: # Match worker's interval
@@ -10087,71 +9908,40 @@ class MachineWindow(QMainWindow):
10087
9908
 
10088
9909
  if not self.parent().painting:
10089
9910
  # Only update if view limits are valid
10090
- self.parent().update_display(preserve_zoom=(current_xlim, current_ylim))
9911
+ self.parent().update_display(preserve_zoom=(current_xlim, current_ylim), skip_paint_reinit = skip_paint_reinit)
9912
+
9913
+ if self.parent().brush_mode:
9914
+ current_xlim = self.parent().ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
9915
+ current_ylim = self.parent().ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
10091
9916
 
9917
+ if self.parent().pen_button.isChecked():
9918
+ channel = self.parent().active_channel
9919
+ else:
9920
+ channel = 2
9921
+
9922
+ self.parent().pm.initiate_paint_session(channel, current_xlim, current_ylim)
10092
9923
 
10093
9924
  self._last_update = current_time
10094
9925
  except Exception as e:
10095
9926
  print(f"Display update error: {e}")
10096
9927
 
10097
9928
  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()
9929
+ try:
9930
+ # Clear any processing flags in the segmenter
9931
+ if hasattr(self.segmenter, '_currently_processing'):
9932
+ self.segmenter._currently_processing = None
10110
9933
 
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 = {}
9934
+ # Force regenerating the worker
9935
+ if self.segmentation_worker is not None:
9936
+ self.kill_segmentation()
10135
9937
 
10136
- current_time = time.time()
9938
+ time.sleep(0.2)
9939
+ self.start_segmentation()
10137
9940
 
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
-
9941
+ except Exception as e:
9942
+ print(f"Error in poke_segmenter: {e}")
9943
+ import traceback
9944
+ traceback.print_exc()
10155
9945
 
10156
9946
 
10157
9947
  def kill_segmentation(self):
@@ -10185,11 +9975,10 @@ class MachineWindow(QMainWindow):
10185
9975
 
10186
9976
  self.previewing = False
10187
9977
 
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]
9978
+ if self.parent().channel_data[2] is not None:
9979
+ active_data = self.parent().channel_data[2]
9980
+ else:
9981
+ active_data = self.parent().channel_data[0]
10193
9982
 
10194
9983
  array3 = np.zeros_like(active_data).astype(np.uint8)
10195
9984
  self.parent().highlight_overlay = array3 #Clear this out for the segmenter to use
@@ -10200,6 +9989,8 @@ class MachineWindow(QMainWindow):
10200
9989
  self.parent().highlight_overlay = self.segmenter.segment_volume(array = self.parent().highlight_overlay)
10201
9990
  except Exception as e:
10202
9991
  print(f"Error segmenting (Perhaps retrain the model...): {e}")
9992
+ import traceback
9993
+ traceback.print_exc()
10203
9994
  return
10204
9995
 
10205
9996
  # Clean up when done
@@ -10236,6 +10027,12 @@ class MachineWindow(QMainWindow):
10236
10027
  # Kill the segmentation thread and wait for it to finish
10237
10028
  self.kill_segmentation()
10238
10029
  time.sleep(0.2) # Give additional time for cleanup
10030
+
10031
+ try:
10032
+ self.parent().channel_data[0] = self.parent().reduce_rgb_dimension(self.parent().channel_data[0], 'weight')
10033
+ self.update_display()
10034
+ except:
10035
+ pass
10239
10036
 
10240
10037
  self.parent().machine_window = None
10241
10038
  else:
@@ -10296,59 +10093,26 @@ class SegmentationWorker(QThread):
10296
10093
 
10297
10094
  # Remember the starting z position
10298
10095
  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
10096
 
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
10097
+ # Original 3D approach
10098
+ for foreground_coords, background_coords in self.segmenter.segment_volume_realtime(gpu=self.use_gpu):
10099
+ # Check for pause/stop before processing each chunk
10100
+ self._check_pause()
10101
+ if self._stop:
10102
+ break
10331
10103
 
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
10104
+ for z,y,x in foreground_coords:
10105
+ self.overlay[z,y,x] = 1
10106
+ for z,y,x in background_coords:
10107
+ self.overlay[z,y,x] = 2
10108
+
10109
+ self.chunks_since_update += 1
10110
+ current_time = time.time()
10111
+ if (self.chunks_since_update >= self.chunks_per_update and
10112
+ current_time - self.last_update >= self.update_interval):
10113
+ self.chunk_processed.emit()
10114
+ self.chunks_since_update = 0
10115
+ self.last_update = current_time
10352
10116
 
10353
10117
  self.finished.emit()
10354
10118