microlive 1.0.12__py3-none-any.whl → 1.0.19__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.
microlive/gui/app.py CHANGED
@@ -101,6 +101,7 @@ from matplotlib.backends.backend_qt5agg import (
101
101
  FigureCanvasQTAgg as FigureCanvas,
102
102
  NavigationToolbar2QT as NavigationToolbar,)
103
103
  from mpl_toolkits.axes_grid1.inset_locator import inset_axes
104
+ from mpl_toolkits.mplot3d import Axes3D # For 3D intensity profile visualization
104
105
  from functools import partial
105
106
  from scipy.optimize import curve_fit
106
107
  from scipy.ndimage import gaussian_filter, label, center_of_mass, distance_transform_edt
@@ -2841,12 +2842,21 @@ class GUI(QMainWindow):
2841
2842
  if hasattr(self, 'time_slider_display'):
2842
2843
  self.time_slider_display.setEnabled(False)
2843
2844
  self.time_slider_display.setValue(0)
2845
+ self.time_slider_display.setMaximum(0)
2846
+ if hasattr(self, 'frame_label_display'):
2847
+ self.frame_label_display.setText("0/0")
2844
2848
  if hasattr(self, 'play_button_display'):
2845
2849
  self.play_button_display.setEnabled(False)
2846
2850
  if hasattr(self, 'time_slider_tracking'):
2847
2851
  self.time_slider_tracking.setValue(0)
2852
+ self.time_slider_tracking.setMaximum(0)
2853
+ if hasattr(self, 'frame_label_tracking'):
2854
+ self.frame_label_tracking.setText("0/0")
2848
2855
  if hasattr(self, 'time_slider_tracking_vis'):
2849
2856
  self.time_slider_tracking_vis.setValue(0)
2857
+ self.time_slider_tracking_vis.setMaximum(0)
2858
+ if hasattr(self, 'frame_label_tracking_vis'):
2859
+ self.frame_label_tracking_vis.setText("0/0")
2850
2860
 
2851
2861
  # Stop any playing timers
2852
2862
  self.stop_all_playback()
@@ -2937,12 +2947,21 @@ class GUI(QMainWindow):
2937
2947
  if hasattr(self, 'time_slider_display'):
2938
2948
  self.time_slider_display.setEnabled(False)
2939
2949
  self.time_slider_display.setValue(0)
2950
+ self.time_slider_display.setMaximum(0)
2951
+ if hasattr(self, 'frame_label_display'):
2952
+ self.frame_label_display.setText("0/0")
2940
2953
  if hasattr(self, 'play_button_display'):
2941
2954
  self.play_button_display.setEnabled(False)
2942
2955
  if hasattr(self, 'time_slider_tracking'):
2943
2956
  self.time_slider_tracking.setValue(0)
2957
+ self.time_slider_tracking.setMaximum(0)
2958
+ if hasattr(self, 'frame_label_tracking'):
2959
+ self.frame_label_tracking.setText("0/0")
2944
2960
  if hasattr(self, 'time_slider_tracking_vis'):
2945
2961
  self.time_slider_tracking_vis.setValue(0)
2962
+ self.time_slider_tracking_vis.setMaximum(0)
2963
+ if hasattr(self, 'frame_label_tracking_vis'):
2964
+ self.frame_label_tracking_vis.setText("0/0")
2946
2965
 
2947
2966
  # Stop any playing timers
2948
2967
  self.stop_all_playback()
@@ -4164,6 +4183,19 @@ class GUI(QMainWindow):
4164
4183
  if hasattr(self, 'label_nuc_mask_status'):
4165
4184
  self.label_nuc_mask_status.setText("No nucleus mask loaded")
4166
4185
  self.label_nuc_mask_status.setStyleSheet("color: gray;")
4186
+ # Reset Improve Segmentation checkboxes
4187
+ if hasattr(self, 'chk_remove_border_cells'):
4188
+ self.chk_remove_border_cells.blockSignals(True)
4189
+ self.chk_remove_border_cells.setChecked(False)
4190
+ self.chk_remove_border_cells.blockSignals(False)
4191
+ if hasattr(self, 'chk_remove_unpaired_cells'):
4192
+ self.chk_remove_unpaired_cells.blockSignals(True)
4193
+ self.chk_remove_unpaired_cells.setChecked(False)
4194
+ self.chk_remove_unpaired_cells.blockSignals(False)
4195
+ if hasattr(self, 'chk_keep_center_cell'):
4196
+ self.chk_keep_center_cell.blockSignals(True)
4197
+ self.chk_keep_center_cell.setChecked(False)
4198
+ self.chk_keep_center_cell.blockSignals(False)
4167
4199
  self.plot_cellpose_results()
4168
4200
 
4169
4201
  def clear_imported_masks(self):
@@ -4581,6 +4613,142 @@ class GUI(QMainWindow):
4581
4613
  new_masks[masks == old_id] = new_id
4582
4614
  return new_masks
4583
4615
 
4616
+ def get_closest_cell_to_center(self, mask):
4617
+ """Find the cell ID whose centroid is closest to the image center.
4618
+
4619
+ Args:
4620
+ mask: 2D array [Y, X] with cell labels (0=background, 1,2,3...=cells)
4621
+
4622
+ Returns:
4623
+ int: Cell ID closest to center, or None if no cells
4624
+ """
4625
+ if mask is None or np.max(mask) == 0:
4626
+ return None
4627
+
4628
+ # Get image center
4629
+ center_y, center_x = mask.shape[0] / 2, mask.shape[1] / 2
4630
+
4631
+ # Get unique cell IDs (exclude background)
4632
+ cell_ids = np.unique(mask)
4633
+ cell_ids = cell_ids[cell_ids > 0]
4634
+
4635
+ if len(cell_ids) == 0:
4636
+ return None
4637
+
4638
+ min_distance = float('inf')
4639
+ closest_cell_id = None
4640
+
4641
+ for cell_id in cell_ids:
4642
+ # Find centroid of this cell
4643
+ coords = np.argwhere(mask == cell_id)
4644
+ centroid_y = np.mean(coords[:, 0])
4645
+ centroid_x = np.mean(coords[:, 1])
4646
+
4647
+ # Calculate distance to image center
4648
+ distance = np.sqrt((centroid_y - center_y)**2 + (centroid_x - center_x)**2)
4649
+
4650
+ if distance < min_distance:
4651
+ min_distance = distance
4652
+ closest_cell_id = cell_id
4653
+
4654
+ return closest_cell_id
4655
+
4656
+ def on_keep_center_cell_changed(self, state):
4657
+ """Handle checkbox state change for keeping only the center cell.
4658
+
4659
+ For TYX masks: Uses the FIRST frame to identify the center cell,
4660
+ then keeps that cell ID across ALL frames for consistent tracking.
4661
+ """
4662
+ from PyQt5.QtCore import Qt
4663
+
4664
+ if state == Qt.Checked:
4665
+ # Check if we have any masks
4666
+ has_cyto = False
4667
+ has_nuc = False
4668
+
4669
+ if getattr(self, 'use_tyx_masks', False):
4670
+ has_cyto = self.cellpose_masks_cyto_tyx is not None and np.max(self.cellpose_masks_cyto_tyx) > 0
4671
+ has_nuc = self.cellpose_masks_nuc_tyx is not None and np.max(self.cellpose_masks_nuc_tyx) > 0
4672
+ else:
4673
+ has_cyto = self.cellpose_masks_cyto is not None and np.max(self.cellpose_masks_cyto) > 0
4674
+ has_nuc = self.cellpose_masks_nuc is not None and np.max(self.cellpose_masks_nuc) > 0
4675
+
4676
+ if not has_cyto and not has_nuc:
4677
+ QMessageBox.warning(self, "No Masks",
4678
+ "No segmentation masks available to filter.")
4679
+ self.chk_keep_center_cell.blockSignals(True)
4680
+ self.chk_keep_center_cell.setChecked(False)
4681
+ self.chk_keep_center_cell.blockSignals(False)
4682
+ return
4683
+
4684
+ # Determine which mask to use for finding center cell
4685
+ # Prefer cytosol mask, fall back to nucleus
4686
+ if getattr(self, 'use_tyx_masks', False):
4687
+ # TYX mode: use first frame to find center cell
4688
+ if has_cyto:
4689
+ reference_mask = self.cellpose_masks_cyto_tyx[0]
4690
+ else:
4691
+ reference_mask = self.cellpose_masks_nuc_tyx[0]
4692
+ else:
4693
+ # Standard YX mode
4694
+ if has_cyto:
4695
+ reference_mask = self.cellpose_masks_cyto
4696
+ else:
4697
+ reference_mask = self.cellpose_masks_nuc
4698
+
4699
+ # Find the cell closest to center
4700
+ center_cell_id = self.get_closest_cell_to_center(reference_mask)
4701
+
4702
+ if center_cell_id is None:
4703
+ QMessageBox.warning(self, "No Cells",
4704
+ "Could not find any cells in the mask.")
4705
+ self.chk_keep_center_cell.blockSignals(True)
4706
+ self.chk_keep_center_cell.setChecked(False)
4707
+ self.chk_keep_center_cell.blockSignals(False)
4708
+ return
4709
+
4710
+ # Remove all cells except the center cell
4711
+ if getattr(self, 'use_tyx_masks', False):
4712
+ # TYX mode: remove from all frames
4713
+ if self.cellpose_masks_cyto_tyx is not None:
4714
+ all_cyto_ids = set(np.unique(self.cellpose_masks_cyto_tyx))
4715
+ all_cyto_ids.discard(0)
4716
+ ids_to_remove = all_cyto_ids - {center_cell_id}
4717
+ if ids_to_remove:
4718
+ self.cellpose_masks_cyto_tyx = self._remove_labels_from_tyx(
4719
+ self.cellpose_masks_cyto_tyx, ids_to_remove)
4720
+ # Update current frame YX mask
4721
+ self.cellpose_masks_cyto = self.cellpose_masks_cyto_tyx[self.segmentation_current_frame]
4722
+
4723
+ if self.cellpose_masks_nuc_tyx is not None:
4724
+ all_nuc_ids = set(np.unique(self.cellpose_masks_nuc_tyx))
4725
+ all_nuc_ids.discard(0)
4726
+ ids_to_remove = all_nuc_ids - {center_cell_id}
4727
+ if ids_to_remove:
4728
+ self.cellpose_masks_nuc_tyx = self._remove_labels_from_tyx(
4729
+ self.cellpose_masks_nuc_tyx, ids_to_remove)
4730
+ # Update current frame YX mask
4731
+ self.cellpose_masks_nuc = self.cellpose_masks_nuc_tyx[self.segmentation_current_frame]
4732
+ else:
4733
+ # Standard YX mode
4734
+ if self.cellpose_masks_cyto is not None:
4735
+ all_cyto_ids = set(np.unique(self.cellpose_masks_cyto))
4736
+ all_cyto_ids.discard(0)
4737
+ ids_to_remove = all_cyto_ids - {center_cell_id}
4738
+ if ids_to_remove:
4739
+ self.cellpose_masks_cyto = self.remove_labels_and_reindex(
4740
+ self.cellpose_masks_cyto, ids_to_remove)
4741
+
4742
+ if self.cellpose_masks_nuc is not None:
4743
+ all_nuc_ids = set(np.unique(self.cellpose_masks_nuc))
4744
+ all_nuc_ids.discard(0)
4745
+ ids_to_remove = all_nuc_ids - {center_cell_id}
4746
+ if ids_to_remove:
4747
+ self.cellpose_masks_nuc = self.remove_labels_and_reindex(
4748
+ self.cellpose_masks_nuc, ids_to_remove)
4749
+
4750
+ self.plot_cellpose_results()
4751
+
4584
4752
  def plot_cellpose_results(self):
4585
4753
  """Plot Cellpose segmentation results on the shared segmentation canvas."""
4586
4754
  if self.image_stack is None:
@@ -4702,21 +4870,43 @@ class GUI(QMainWindow):
4702
4870
  self.segmentation_channel_buttons.append(btn)
4703
4871
 
4704
4872
  def update_segmentation_channel(self, channel_index):
4873
+ # Don't clear mask when in Edit mode (we're editing, not creating new)
4874
+ current_subtab = getattr(self, 'segmentation_method_tabs', None)
4875
+ if current_subtab is not None and current_subtab.currentIndex() == 4:
4876
+ # Just update channel for viewing, don't clear mask
4877
+ self.segmentation_current_channel = channel_index
4878
+ self.plot_edit_mode()
4879
+ return
4880
+
4705
4881
  # Clear old mask when changing channel
4706
4882
  self.segmentation_mask = None
4707
4883
  self.segmentation_current_channel = channel_index
4708
4884
 
4709
4885
  # Refresh display based on active sub-tab
4710
4886
  if hasattr(self, 'segmentation_method_tabs'):
4711
- current_subtab = self.segmentation_method_tabs.currentIndex()
4712
- if current_subtab == 1 or current_subtab == 3: # Cellpose or Import sub-tab
4887
+ current_index = self.segmentation_method_tabs.currentIndex()
4888
+ if current_index == 1 or current_index == 3: # Cellpose or Import sub-tab
4713
4889
  self.plot_cellpose_results()
4890
+ elif current_index == 4: # Edit sub-tab
4891
+ self.plot_edit_mode()
4714
4892
  else:
4715
4893
  self.plot_segmentation()
4716
4894
  else:
4717
4895
  self.plot_segmentation()
4718
4896
 
4719
4897
  def update_segmentation_frame(self, value):
4898
+ # Don't clear mask when in Edit mode (we're editing, not creating new)
4899
+ current_subtab = getattr(self, 'segmentation_method_tabs', None)
4900
+ if current_subtab is not None and current_subtab.currentIndex() == 4:
4901
+ # Just update frame for viewing, don't clear mask
4902
+ self.segmentation_current_frame = value
4903
+ # Update frame label
4904
+ total_frames = getattr(self, 'total_frames', 1)
4905
+ if hasattr(self, 'frame_label_segmentation'):
4906
+ self.frame_label_segmentation.setText(f"{value}/{total_frames - 1}")
4907
+ self.plot_edit_mode()
4908
+ return
4909
+
4720
4910
  # Clear old manual/watershed mask when changing frame
4721
4911
  self.segmentation_mask = None
4722
4912
  self.segmentation_current_frame = value
@@ -4735,9 +4925,11 @@ class GUI(QMainWindow):
4735
4925
 
4736
4926
  # Refresh display based on active sub-tab
4737
4927
  if hasattr(self, 'segmentation_method_tabs'):
4738
- current_subtab = self.segmentation_method_tabs.currentIndex()
4739
- if current_subtab == 1 or current_subtab == 3: # Cellpose or Import sub-tab
4928
+ current_index = self.segmentation_method_tabs.currentIndex()
4929
+ if current_index == 1 or current_index == 3: # Cellpose or Import sub-tab
4740
4930
  self.plot_cellpose_results()
4931
+ elif current_index == 4: # Edit sub-tab
4932
+ self.plot_edit_mode()
4741
4933
  else:
4742
4934
  self.plot_segmentation()
4743
4935
  else:
@@ -5683,7 +5875,7 @@ class GUI(QMainWindow):
5683
5875
  cellpose_cyto_* and cellpose_nuc_* inputs (model, channel, diameter)
5684
5876
  btn_run_cyto, btn_run_nuc (QPushButton)
5685
5877
  num_masks_slider, min_frames_slider, cell_expansion_slider, cell_shrink_slider
5686
- chk_remove_border_cells, chk_remove_unpaired_cells (QCheckBox)
5878
+ chk_remove_border_cells, chk_remove_unpaired_cells, chk_keep_center_cell (QCheckBox)
5687
5879
  btn_clear_cellpose (QPushButton)
5688
5880
  cellpose_masks_cyto, cellpose_masks_nuc (Optional[np.ndarray])
5689
5881
  cellpose_masks_cyto_tyx, cellpose_masks_nuc_tyx (Optional[np.ndarray])
@@ -5703,7 +5895,7 @@ class GUI(QMainWindow):
5703
5895
  run_cellpose_cyto, run_cellpose_nuc, clear_cellpose_masks
5704
5896
  _on_num_masks_slider_changed, _on_min_frames_slider_changed
5705
5897
  _on_expansion_slider_changed, _on_shrink_slider_changed
5706
- on_remove_border_cells_changed, on_remove_unpaired_cells_changed
5898
+ on_remove_border_cells_changed, on_remove_unpaired_cells_changed, on_keep_center_cell_changed
5707
5899
  """
5708
5900
 
5709
5901
  self.segmentation_current_frame = 0
@@ -5719,7 +5911,7 @@ class GUI(QMainWindow):
5719
5911
  main_layout = QHBoxLayout(self.segmentation_tab)
5720
5912
  # LEFT PANEL: Segmentation Figure & Controls
5721
5913
  left_layout = QVBoxLayout()
5722
- main_layout.addLayout(left_layout, stretch=3)
5914
+ main_layout.addLayout(left_layout, stretch=5)
5723
5915
 
5724
5916
  # Create canvas + Z-slider layout (horizontal: canvas on left, Z-slider on right)
5725
5917
  canvas_z_layout = QHBoxLayout()
@@ -5802,12 +5994,15 @@ class GUI(QMainWindow):
5802
5994
 
5803
5995
  # RIGHT PANEL: Segmentation Methods & Source Toggle
5804
5996
  right_layout = QVBoxLayout()
5805
- main_layout.addLayout(right_layout, stretch=1)
5997
+ main_layout.addLayout(right_layout, stretch=2)
5806
5998
 
5807
5999
  # =====================================================================
5808
6000
  # SEGMENTATION METHOD SUB-TABS
5809
6001
  # =====================================================================
5810
6002
  self.segmentation_method_tabs = QTabWidget()
6003
+ # Enable scrollable tabs so all tabs are accessible when panel is narrow
6004
+ self.segmentation_method_tabs.setUsesScrollButtons(True)
6005
+ self.segmentation_method_tabs.setElideMode(Qt.ElideNone)
5811
6006
 
5812
6007
  # --- Manual Segmentation Tab ---
5813
6008
  manual_tab = QWidget()
@@ -6145,6 +6340,16 @@ class GUI(QMainWindow):
6145
6340
  self.chk_remove_unpaired_cells.stateChanged.connect(self.on_remove_unpaired_cells_changed)
6146
6341
  improve_layout.addRow(self.chk_remove_unpaired_cells)
6147
6342
 
6343
+ self.chk_keep_center_cell = QCheckBox("Keep only center cell")
6344
+ self.chk_keep_center_cell.setChecked(False)
6345
+ self.chk_keep_center_cell.setToolTip(
6346
+ "Keep only the cell whose centroid is closest to the image center.\n"
6347
+ "Useful for single-cell analysis when multiple cells are detected.\n"
6348
+ "Note: This filter is applied after 'Remove border' and 'Remove unpaired' filters."
6349
+ )
6350
+ self.chk_keep_center_cell.stateChanged.connect(self.on_keep_center_cell_changed)
6351
+ improve_layout.addRow(self.chk_keep_center_cell)
6352
+
6148
6353
  improve_group.setLayout(improve_layout)
6149
6354
  cellpose_layout.addWidget(improve_group)
6150
6355
 
@@ -6215,9 +6420,154 @@ class GUI(QMainWindow):
6215
6420
  # Add Manual tab (Index 2) before Import
6216
6421
  self.segmentation_method_tabs.addTab(manual_tab, "Manual")
6217
6422
 
6218
- # Add Import tab last (Index 3)
6423
+ # Add Import tab (Index 3)
6219
6424
  self.segmentation_method_tabs.addTab(import_tab, "Import")
6220
6425
 
6426
+ # =====================================================================
6427
+ # EDIT TAB (Index 4) - Edit existing masks from any method
6428
+ # =====================================================================
6429
+ edit_tab = QWidget()
6430
+ edit_tab_layout = QVBoxLayout(edit_tab)
6431
+ edit_tab_layout.setContentsMargins(10, 10, 10, 10)
6432
+ edit_tab_layout.setSpacing(8)
6433
+
6434
+ # Create scrollable area for edit controls
6435
+ edit_scroll = QScrollArea()
6436
+ edit_scroll.setWidgetResizable(True)
6437
+ edit_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
6438
+ edit_scroll_widget = QWidget()
6439
+ edit_scroll_layout = QVBoxLayout(edit_scroll_widget)
6440
+ edit_scroll_layout.setSpacing(8)
6441
+
6442
+ # Instructions label
6443
+ edit_instructions = QLabel(
6444
+ "Edit existing masks from any segmentation method.\n"
6445
+ "Select a mask and tool, then draw on the image.\n"
6446
+ "Cell labels are preserved (erasing sets pixels to background)."
6447
+ )
6448
+ edit_instructions.setWordWrap(True)
6449
+ edit_instructions.setStyleSheet("color: gray; font-size: 11px;")
6450
+ edit_scroll_layout.addWidget(edit_instructions)
6451
+
6452
+ # --- Mask to Edit Group ---
6453
+ mask_select_group = QGroupBox("Mask to Edit")
6454
+ mask_select_layout = QVBoxLayout()
6455
+
6456
+ self.edit_mask_selector = QComboBox()
6457
+ self.edit_mask_selector.addItem("-- Select Mask --")
6458
+ self.edit_mask_selector.currentIndexChanged.connect(self.on_edit_mask_selector_changed)
6459
+ mask_select_layout.addWidget(self.edit_mask_selector)
6460
+
6461
+ self.edit_mask_info_label = QLabel("No mask selected")
6462
+ self.edit_mask_info_label.setStyleSheet("color: gray; font-size: 10px;")
6463
+ mask_select_layout.addWidget(self.edit_mask_info_label)
6464
+
6465
+ mask_select_group.setLayout(mask_select_layout)
6466
+ edit_scroll_layout.addWidget(mask_select_group)
6467
+
6468
+ # --- Instructions Panel (shown when mask is selected) ---
6469
+ self.edit_instructions_group = QGroupBox("How to Edit")
6470
+ instructions_layout = QVBoxLayout()
6471
+
6472
+ self.edit_instructions_label = QLabel(
6473
+ "🖱️ Click and drag on the mask to erase regions.\n"
6474
+ " Erased areas become background (0).\n\n"
6475
+ "💾 Click 'Apply & Save' when finished."
6476
+ )
6477
+ self.edit_instructions_label.setWordWrap(True)
6478
+ self.edit_instructions_label.setStyleSheet("color: #88ccff; font-size: 11px;")
6479
+ instructions_layout.addWidget(self.edit_instructions_label)
6480
+
6481
+ self.edit_instructions_group.setLayout(instructions_layout)
6482
+ self.edit_instructions_group.setVisible(False) # Hidden until mask selected
6483
+ edit_scroll_layout.addWidget(self.edit_instructions_group)
6484
+
6485
+ # --- TYX Info Panel (shown for time-varying masks) ---
6486
+ self.edit_tyx_info_group = QGroupBox("Time-Varying Mask")
6487
+ tyx_info_layout = QVBoxLayout()
6488
+
6489
+ self.edit_tyx_info_label = QLabel(
6490
+ "ℹ️ Editing combined view (max projection).\n"
6491
+ "Edits will apply to ALL frames when saved."
6492
+ )
6493
+ self.edit_tyx_info_label.setWordWrap(True)
6494
+ self.edit_tyx_info_label.setStyleSheet("color: #6699ff; font-size: 10px;")
6495
+ tyx_info_layout.addWidget(self.edit_tyx_info_label)
6496
+
6497
+ self.edit_tyx_info_group.setLayout(tyx_info_layout)
6498
+ self.edit_tyx_info_group.setVisible(False) # Hidden until TYX mask selected
6499
+ edit_scroll_layout.addWidget(self.edit_tyx_info_group)
6500
+
6501
+ # --- Action Buttons Group ---
6502
+ action_group = QGroupBox("Actions")
6503
+ action_layout = QVBoxLayout()
6504
+
6505
+ action_btn_layout = QHBoxLayout()
6506
+ self.btn_undo_edit = QPushButton("Undo")
6507
+ self.btn_undo_edit.setToolTip("Restore mask to state before last edit (max 10 steps)")
6508
+ self.btn_undo_edit.clicked.connect(self.undo_edit)
6509
+ self.btn_undo_edit.setEnabled(False)
6510
+ action_btn_layout.addWidget(self.btn_undo_edit)
6511
+
6512
+ self.btn_reset_edits = QPushButton("Reset")
6513
+ self.btn_reset_edits.setToolTip("Discard all edits and restore the mask to its original state")
6514
+ self.btn_reset_edits.clicked.connect(self.reset_edits)
6515
+ self.btn_reset_edits.setEnabled(False)
6516
+ action_btn_layout.addWidget(self.btn_reset_edits)
6517
+ action_layout.addLayout(action_btn_layout)
6518
+
6519
+ self.btn_apply_edits = QPushButton("Apply && Save")
6520
+ self.btn_apply_edits.setToolTip("Apply edits to the active mask and use for downstream analysis")
6521
+ self.btn_apply_edits.setStyleSheet("background-color: #336633; font-weight: bold;")
6522
+ self.btn_apply_edits.clicked.connect(self.apply_and_save_edits)
6523
+ self.btn_apply_edits.setEnabled(False)
6524
+ action_layout.addWidget(self.btn_apply_edits)
6525
+
6526
+ action_group.setLayout(action_layout)
6527
+ edit_scroll_layout.addWidget(action_group)
6528
+
6529
+ # --- Status Panel ---
6530
+ status_group = QGroupBox("Edit Status")
6531
+ status_layout = QVBoxLayout()
6532
+
6533
+ self.edit_status_label = QLabel("⚠ Select a mask to begin editing")
6534
+ self.edit_status_label.setWordWrap(True)
6535
+ self.edit_status_label.setStyleSheet("color: #ffcc00;")
6536
+ status_layout.addWidget(self.edit_status_label)
6537
+
6538
+ status_group.setLayout(status_layout)
6539
+ edit_scroll_layout.addWidget(status_group)
6540
+
6541
+ edit_scroll_layout.addStretch()
6542
+ edit_scroll.setWidget(edit_scroll_widget)
6543
+ edit_tab_layout.addWidget(edit_scroll)
6544
+
6545
+ # Add Edit tab (Index 4)
6546
+ self.segmentation_method_tabs.addTab(edit_tab, "Edit")
6547
+
6548
+ # =====================================================================
6549
+ # EDIT MODE STATE VARIABLES
6550
+ # =====================================================================
6551
+ # Edit mode state
6552
+ self.edit_mode_active = False
6553
+ self.edit_current_mask_key = None # 'watershed', 'cellpose_cyto', 'cellpose_nuc'
6554
+ self.edit_working_mask = None # 2D working copy (Y, X) - always 2D for editing
6555
+ self.edit_original_mask = None # Original mask before edits (for reset)
6556
+ self.edit_original_2d = None # Max projection of original (for computing diffs)
6557
+ self.edit_source_is_tyx = False # True if source mask is TYX (3D)
6558
+
6559
+ # Undo system
6560
+ self.edit_undo_stack = []
6561
+ self.EDIT_MAX_UNDO_DEPTH = 10
6562
+
6563
+ # Eraser brush state (only tool)
6564
+ self.edit_brush_size = 10
6565
+ self.edit_brush_shape = 'circle'
6566
+
6567
+ # Mouse state for drawing
6568
+ self.edit_last_mouse_pos = None
6569
+ self.edit_is_drawing = False
6570
+
6221
6571
  # Initialize Cellpose/imported mask state variables
6222
6572
  # NOTE: Uses segmentation_current_frame and segmentation_current_channel (shared)
6223
6573
  self.cellpose_masks_cyto = None
@@ -6256,21 +6606,625 @@ class GUI(QMainWindow):
6256
6606
  self.plot_segmentation()
6257
6607
 
6258
6608
  def _on_segmentation_subtab_changed(self, index):
6259
- """Handle switching between segmentation method sub-tabs (Watershed, Cellpose, Manual, Import).
6609
+ """Handle switching between segmentation method sub-tabs.
6260
6610
 
6261
6611
  Tab indices:
6262
6612
  0 = Watershed
6263
6613
  1 = Cellpose
6264
6614
  2 = Manual
6265
6615
  3 = Import (uses Cellpose-style display)
6616
+ 4 = Edit (edit existing masks)
6266
6617
  """
6267
- if index == 1 or index == 3: # Cellpose or Import sub-tab
6268
- # Show Cellpose/imported masks on the shared segmentation canvas
6618
+ if index == 4: # Edit sub-tab
6619
+ self.enter_edit_mode()
6620
+ elif index == 1 or index == 3: # Cellpose or Import sub-tab
6621
+ self.exit_edit_mode()
6269
6622
  self.plot_cellpose_results()
6270
6623
  else:
6271
- # For Watershed and Manual, use the standard segmentation display
6624
+ self.exit_edit_mode()
6272
6625
  self.plot_segmentation()
6273
6626
 
6627
+ # =========================================================================
6628
+ # EDIT TAB SIGNAL HANDLERS
6629
+ # =========================================================================
6630
+
6631
+ def enter_edit_mode(self):
6632
+ """Initialize edit mode when Edit tab is selected."""
6633
+ self.edit_mode_active = True
6634
+
6635
+ # Connect mouse events for editing
6636
+ self._connect_edit_mouse_events()
6637
+
6638
+ # Populate mask selector with available masks
6639
+ self._refresh_edit_mask_selector()
6640
+
6641
+ # If a mask is already selected, load it
6642
+ if self.edit_mask_selector.currentIndex() > 0:
6643
+ self.on_edit_mask_selector_changed(self.edit_mask_selector.currentIndex())
6644
+ else:
6645
+ # Just show the current segmentation view
6646
+ self.plot_edit_mode()
6647
+
6648
+ def exit_edit_mode(self):
6649
+ """Clean up edit mode when leaving Edit tab."""
6650
+ if not self.edit_mode_active:
6651
+ return
6652
+
6653
+ # Check for unsaved edits
6654
+ if self.edit_working_mask is not None and self.edit_undo_stack:
6655
+ reply = QMessageBox.question(
6656
+ self, "Unsaved Edits",
6657
+ "You have unsaved edits. Discard them?",
6658
+ QMessageBox.Yes | QMessageBox.No,
6659
+ QMessageBox.No
6660
+ )
6661
+ if reply == QMessageBox.No:
6662
+ # Switch back to Edit tab
6663
+ self.segmentation_method_tabs.blockSignals(True)
6664
+ self.segmentation_method_tabs.setCurrentIndex(4)
6665
+ self.segmentation_method_tabs.blockSignals(False)
6666
+ return
6667
+
6668
+ # Clear edit state
6669
+ self.edit_mode_active = False
6670
+ self.edit_working_mask = None
6671
+ self.edit_original_mask = None
6672
+ self.edit_undo_stack = []
6673
+
6674
+ # Disconnect mouse events
6675
+ self._disconnect_edit_mouse_events()
6676
+
6677
+ def _refresh_edit_mask_selector(self):
6678
+ """Populate the mask selector dropdown with available masks."""
6679
+ self.edit_mask_selector.blockSignals(True)
6680
+ self.edit_mask_selector.clear()
6681
+ self.edit_mask_selector.addItem("-- Select Mask --")
6682
+
6683
+ # Check for Watershed/Manual mask
6684
+ if self.segmentation_mask is not None:
6685
+ n_cells = len(np.unique(self.segmentation_mask)) - 1 # Exclude 0
6686
+ shape = self.segmentation_mask.shape
6687
+ self.edit_mask_selector.addItem(
6688
+ f"Watershed Mask ({n_cells} cells, {shape[1]}×{shape[0]})",
6689
+ "watershed"
6690
+ )
6691
+
6692
+ # Check for Cellpose cytosol mask
6693
+ if self.cellpose_masks_cyto is not None:
6694
+ mask = self.cellpose_masks_cyto
6695
+ n_cells = len(np.unique(mask)) - 1
6696
+ if mask.ndim == 3:
6697
+ shape_str = f"TYX: {mask.shape[0]}×{mask.shape[2]}×{mask.shape[1]}"
6698
+ else:
6699
+ shape_str = f"{mask.shape[1]}×{mask.shape[0]}"
6700
+ self.edit_mask_selector.addItem(
6701
+ f"Cellpose Cytosol ({n_cells} cells, {shape_str})",
6702
+ "cellpose_cyto"
6703
+ )
6704
+
6705
+ # Check for Cellpose nucleus mask
6706
+ if self.cellpose_masks_nuc is not None:
6707
+ mask = self.cellpose_masks_nuc
6708
+ n_cells = len(np.unique(mask)) - 1
6709
+ if mask.ndim == 3:
6710
+ shape_str = f"TYX: {mask.shape[0]}×{mask.shape[2]}×{mask.shape[1]}"
6711
+ else:
6712
+ shape_str = f"{mask.shape[1]}×{mask.shape[0]}"
6713
+ self.edit_mask_selector.addItem(
6714
+ f"Cellpose Nucleus ({n_cells} cells, {shape_str})",
6715
+ "cellpose_nuc"
6716
+ )
6717
+
6718
+ self.edit_mask_selector.blockSignals(False)
6719
+
6720
+ # Update status based on availability
6721
+ if self.edit_mask_selector.count() <= 1:
6722
+ self.edit_status_label.setText("⚠ No masks available - run segmentation first")
6723
+ self.edit_status_label.setStyleSheet("color: #ff6666;")
6724
+ else:
6725
+ self.edit_status_label.setText("⚠ Select a mask to begin editing")
6726
+ self.edit_status_label.setStyleSheet("color: #ffcc00;")
6727
+
6728
+ def on_edit_mask_selector_changed(self, index):
6729
+ """Handle switching between masks to edit."""
6730
+ if index <= 0:
6731
+ # No mask selected
6732
+ self.edit_working_mask = None
6733
+ self.edit_original_mask = None
6734
+ self.edit_current_mask_key = None
6735
+ self.edit_mask_info_label.setText("No mask selected")
6736
+ self.edit_tyx_info_group.setVisible(False)
6737
+ self.edit_instructions_group.setVisible(False) # Hide instructions
6738
+ self._update_edit_buttons_state()
6739
+ self.plot_edit_mode()
6740
+ return
6741
+
6742
+ # Get the mask key from the item data
6743
+ mask_key = self.edit_mask_selector.itemData(index)
6744
+
6745
+ # Load the selected mask
6746
+ if mask_key == "watershed":
6747
+ source_mask = self.segmentation_mask
6748
+ elif mask_key == "cellpose_cyto":
6749
+ source_mask = self.cellpose_masks_cyto
6750
+ elif mask_key == "cellpose_nuc":
6751
+ source_mask = self.cellpose_masks_nuc
6752
+ else:
6753
+ return
6754
+
6755
+ # Create working copy
6756
+ self.edit_working_mask, self.edit_original_2d, self.edit_source_is_tyx = \
6757
+ self._create_edit_working_copy(source_mask)
6758
+ self.edit_original_mask = source_mask.copy()
6759
+ self.edit_current_mask_key = mask_key
6760
+
6761
+ # Clear undo stack for new mask
6762
+ self.edit_undo_stack = []
6763
+
6764
+ # Update info label
6765
+ n_cells = len(np.unique(self.edit_working_mask)) - 1
6766
+ shape = self.edit_working_mask.shape
6767
+ self.edit_mask_info_label.setText(f"{n_cells} cells, {shape[1]}×{shape[0]} px")
6768
+ self.edit_mask_info_label.setStyleSheet("color: #88ff88; font-size: 10px;")
6769
+
6770
+ # Show TYX info if applicable
6771
+ self.edit_tyx_info_group.setVisible(self.edit_source_is_tyx)
6772
+ if self.edit_source_is_tyx:
6773
+ n_frames = source_mask.shape[0]
6774
+ self.edit_tyx_info_label.setText(
6775
+ f"ℹ️ Editing combined view of {n_frames} frames.\n"
6776
+ "Edits will apply to ALL frames when saved."
6777
+ )
6778
+
6779
+ # Show instructions panel
6780
+ self.edit_instructions_group.setVisible(True)
6781
+
6782
+ # Update buttons
6783
+ self._update_edit_buttons_state()
6784
+
6785
+ # Update status
6786
+ self.edit_status_label.setText(f"✓ Ready to edit {mask_key.replace('_', ' ').title()}")
6787
+ self.edit_status_label.setStyleSheet("color: #88ff88;")
6788
+
6789
+ # Update display
6790
+ self.plot_edit_mode()
6791
+
6792
+ def _create_edit_working_copy(self, source_mask):
6793
+ """Create a 2D working copy for editing.
6794
+
6795
+ Args:
6796
+ source_mask: The source mask array (YX or TYX)
6797
+
6798
+ Returns:
6799
+ tuple: (working_mask_2d, original_2d, is_tyx)
6800
+ """
6801
+ if source_mask.ndim == 3: # TYX
6802
+ # Max projection for editing
6803
+ working_mask = np.max(source_mask, axis=0).copy()
6804
+ original_2d = working_mask.copy()
6805
+ is_tyx = True
6806
+ else: # YX
6807
+ working_mask = source_mask.copy()
6808
+ original_2d = working_mask.copy()
6809
+ is_tyx = False
6810
+
6811
+ return working_mask, original_2d, is_tyx
6812
+
6813
+
6814
+
6815
+ def _update_edit_buttons_state(self):
6816
+ """Update enabled state of edit action buttons."""
6817
+ has_mask = self.edit_working_mask is not None
6818
+ has_edits = len(self.edit_undo_stack) > 0
6819
+
6820
+ self.btn_undo_edit.setEnabled(has_edits)
6821
+ self.btn_reset_edits.setEnabled(has_edits)
6822
+ self.btn_apply_edits.setEnabled(has_mask)
6823
+
6824
+ # =========================================================================
6825
+ # UNDO/RESET/APPLY
6826
+ # =========================================================================
6827
+
6828
+ def _push_undo(self, description="Edit"):
6829
+ """Push current mask state to undo stack."""
6830
+ if self.edit_working_mask is None:
6831
+ return
6832
+
6833
+ self.edit_undo_stack.append((self.edit_working_mask.copy(), description))
6834
+
6835
+ # Limit stack size
6836
+ if len(self.edit_undo_stack) > self.EDIT_MAX_UNDO_DEPTH:
6837
+ self.edit_undo_stack.pop(0)
6838
+
6839
+ def undo_edit(self):
6840
+ """Pop last state from undo stack and restore."""
6841
+ if not self.edit_undo_stack:
6842
+ self.edit_status_label.setText("Nothing to undo")
6843
+ self.edit_status_label.setStyleSheet("color: gray;")
6844
+ return
6845
+
6846
+ # Pop the last state
6847
+ mask, description = self.edit_undo_stack.pop()
6848
+ self.edit_working_mask = mask
6849
+
6850
+ self.edit_status_label.setText(f"↩ Undid: {description}")
6851
+ self.edit_status_label.setStyleSheet("color: #88ff88;")
6852
+
6853
+ # Refresh dropdown
6854
+ self._refresh_cell_label_dropdown()
6855
+
6856
+ # Update buttons and display
6857
+ self._update_edit_buttons_state()
6858
+ self.plot_edit_mode()
6859
+
6860
+ def reset_edits(self):
6861
+ """Restore mask to original state before editing began."""
6862
+ if self.edit_original_mask is None:
6863
+ return
6864
+
6865
+ # Confirm if many edits
6866
+ if len(self.edit_undo_stack) > 3:
6867
+ reply = QMessageBox.question(
6868
+ self, "Reset All Edits",
6869
+ f"This will discard {len(self.edit_undo_stack)} edits. Continue?",
6870
+ QMessageBox.Yes | QMessageBox.No,
6871
+ QMessageBox.No
6872
+ )
6873
+ if reply == QMessageBox.No:
6874
+ return
6875
+
6876
+ # Restore original
6877
+ self.edit_working_mask, self.edit_original_2d, _ = \
6878
+ self._create_edit_working_copy(self.edit_original_mask)
6879
+
6880
+ # Clear undo stack
6881
+ self.edit_undo_stack = []
6882
+
6883
+ self.edit_status_label.setText("↺ Reset to original mask")
6884
+ self.edit_status_label.setStyleSheet("color: #88ff88;")
6885
+
6886
+ # Refresh dropdown
6887
+ self._refresh_cell_label_dropdown()
6888
+
6889
+ # Update buttons and display
6890
+ self._update_edit_buttons_state()
6891
+ self.plot_edit_mode()
6892
+
6893
+ def apply_and_save_edits(self):
6894
+ """Apply edits back to the source mask and set as active for downstream workflow."""
6895
+ if self.edit_working_mask is None or self.edit_current_mask_key is None:
6896
+ return
6897
+
6898
+ source_key = self.edit_current_mask_key
6899
+
6900
+ if source_key == 'watershed':
6901
+ # Watershed is always YX
6902
+ self.segmentation_mask = self.edit_working_mask.copy()
6903
+ self._active_mask_source = 'segmentation'
6904
+
6905
+ elif source_key == 'cellpose_cyto':
6906
+ if self.edit_source_is_tyx:
6907
+ # Apply 2D edits to all frames
6908
+ self.cellpose_masks_cyto = self._apply_edits_to_tyx(
6909
+ self.edit_original_mask,
6910
+ self.edit_working_mask,
6911
+ self.edit_original_2d
6912
+ )
6913
+ else:
6914
+ self.cellpose_masks_cyto = self.edit_working_mask.copy()
6915
+ self._active_mask_source = 'cellpose'
6916
+
6917
+ elif source_key == 'cellpose_nuc':
6918
+ if self.edit_source_is_tyx:
6919
+ self.cellpose_masks_nuc = self._apply_edits_to_tyx(
6920
+ self.edit_original_mask,
6921
+ self.edit_working_mask,
6922
+ self.edit_original_2d
6923
+ )
6924
+ else:
6925
+ self.cellpose_masks_nuc = self.edit_working_mask.copy()
6926
+ self._active_mask_source = 'cellpose'
6927
+
6928
+ # Invalidate downstream calculations
6929
+ self.corrected_image = None
6930
+ self.photobleaching_calculated = False
6931
+ if hasattr(self, 'df_tracking'):
6932
+ self.df_tracking = pd.DataFrame()
6933
+ if hasattr(self, 'multi_channel_tracking_data'):
6934
+ self.multi_channel_tracking_data = {}
6935
+
6936
+ # Clear edit state
6937
+ self.edit_undo_stack = []
6938
+ self.edit_original_mask = self.edit_working_mask.copy()
6939
+ self.edit_original_2d = self.edit_working_mask.copy()
6940
+
6941
+ # Update status
6942
+ self.edit_status_label.setText(f"✓ Saved! Re-run Photobleaching/Tracking if needed.")
6943
+ self.edit_status_label.setStyleSheet("color: #88ff88; font-weight: bold;")
6944
+ self.statusBar().showMessage(f"✓ Edited mask saved ({source_key})")
6945
+
6946
+ # Update mask selector to reflect changes
6947
+ self._refresh_edit_mask_selector()
6948
+ # Re-select the mask we just edited
6949
+ for i in range(self.edit_mask_selector.count()):
6950
+ if self.edit_mask_selector.itemData(i) == source_key:
6951
+ self.edit_mask_selector.blockSignals(True)
6952
+ self.edit_mask_selector.setCurrentIndex(i)
6953
+ self.edit_mask_selector.blockSignals(False)
6954
+ break
6955
+
6956
+ self._update_edit_buttons_state()
6957
+ self.plot_edit_mode()
6958
+
6959
+ def _apply_edits_to_tyx(self, original_tyx, edited_2d, original_2d):
6960
+ """Apply 2D edits to all frames of a TYX mask.
6961
+
6962
+ Args:
6963
+ original_tyx: Original TYX mask (T, Y, X)
6964
+ edited_2d: Edited 2D mask (Y, X)
6965
+ original_2d: Original max projection (Y, X)
6966
+
6967
+ Returns:
6968
+ Modified TYX mask with edits applied to all frames
6969
+ """
6970
+ result = original_tyx.copy()
6971
+
6972
+ # Find erased pixels (were non-zero in original, now zero)
6973
+ erased = (original_2d > 0) & (edited_2d == 0)
6974
+
6975
+ # Find painted pixels (changed label)
6976
+ painted = (edited_2d > 0) & (edited_2d != original_2d)
6977
+
6978
+ # Apply to all frames
6979
+ for t in range(result.shape[0]):
6980
+ # Erase: set to 0 where erased in 2D
6981
+ result[t][erased] = 0
6982
+
6983
+ # Paint: set to new label where painted in 2D
6984
+ result[t][painted] = edited_2d[painted]
6985
+
6986
+ return result
6987
+
6988
+ # =========================================================================
6989
+ # EDIT MODE DISPLAY
6990
+ # =========================================================================
6991
+
6992
+ def plot_edit_mode(self):
6993
+ """Display the mask being edited overlaid on the selected channel.
6994
+
6995
+ Uses the same display logic as plot_segmentation() to ensure
6996
+ consistent appearance with channel colors and display parameters.
6997
+ """
6998
+ # Use the figure's subplot instead of clearing the whole figure
6999
+ # to avoid issues with the figure layout
7000
+ self.figure_segmentation.clear()
7001
+ self.ax_segmentation = self.figure_segmentation.add_subplot(111)
7002
+ self.ax_segmentation.set_facecolor('black')
7003
+
7004
+ if self.image_stack is None:
7005
+ self.ax_segmentation.text(
7006
+ 0.5, 0.5, 'No image loaded.',
7007
+ horizontalalignment='center', verticalalignment='center',
7008
+ fontsize=12, color='white', transform=self.ax_segmentation.transAxes
7009
+ )
7010
+ self.ax_segmentation.axis('off')
7011
+ self.canvas_segmentation.draw()
7012
+ return
7013
+
7014
+ # Get current channel and frame
7015
+ ch = self.segmentation_current_channel
7016
+
7017
+ # Use registered image if available (same as plot_segmentation)
7018
+ image_to_use = self.get_current_image_source()
7019
+
7020
+ # Choose image to display (temporal max projection vs current frame, then Z selection)
7021
+ if self.use_max_proj_for_segmentation and self.segmentation_maxproj is not None:
7022
+ # Temporal max projection (already max over T and Z)
7023
+ image_to_display = self.segmentation_maxproj[..., ch]
7024
+ else:
7025
+ # Get current time frame's Z-stack for this channel
7026
+ image_channel = image_to_use[self.segmentation_current_frame, :, :, :, ch]
7027
+ # Apply Z selection
7028
+ if getattr(self, 'segmentation_current_z', -1) == -1:
7029
+ # Max Z-projection (default)
7030
+ image_to_display = np.max(image_channel, axis=0)
7031
+ else:
7032
+ # Specific Z-slice
7033
+ z_idx = min(self.segmentation_current_z, image_channel.shape[0] - 1)
7034
+ image_to_display = image_channel[z_idx, :, :]
7035
+
7036
+ # Get display parameters for channel (same as plot_segmentation)
7037
+ params = self.channelDisplayParams.get(ch, {
7038
+ 'min_percentile': self.display_min_percentile,
7039
+ 'max_percentile': self.display_max_percentile,
7040
+ 'sigma': self.display_sigma,
7041
+ 'low_sigma': self.low_display_sigma
7042
+ })
7043
+
7044
+ # Convert using per-channel percentiles
7045
+ rescaled_image = mi.Utilities().convert_to_int8(
7046
+ image_to_display,
7047
+ rescale=True,
7048
+ min_percentile=params['min_percentile'],
7049
+ max_percentile=params['max_percentile']
7050
+ )
7051
+ if params['low_sigma'] > 0:
7052
+ rescaled_image = gaussian_filter(rescaled_image, sigma=params['low_sigma'])
7053
+ if params['sigma'] > 0:
7054
+ rescaled_image = gaussian_filter(rescaled_image, sigma=params['sigma'])
7055
+ rescaled_image = mi.Utilities().convert_to_int8(rescaled_image, rescale=False)
7056
+ normalized_image = rescaled_image.astype(np.float32) / 255.0
7057
+
7058
+ # Use channel colormap (same as plot_segmentation)
7059
+ cmap_used = cmap_list_imagej[ch % len(cmap_list_imagej)]
7060
+ self.ax_segmentation.imshow(normalized_image[..., 0], cmap=cmap_used, vmin=0, vmax=1)
7061
+
7062
+ # Overlay the working mask with contours
7063
+ if self.edit_working_mask is not None:
7064
+ mask = self.edit_working_mask
7065
+ unique_labels = np.unique(mask)
7066
+ unique_labels = unique_labels[unique_labels > 0]
7067
+
7068
+ if len(unique_labels) > 0:
7069
+ # Create semi-transparent colored overlay for cells
7070
+ overlay = np.zeros((*mask.shape, 4))
7071
+
7072
+ # Use tab10 colormap for different cell labels
7073
+ colors = plt.cm.tab10.colors
7074
+
7075
+ for i, label in enumerate(unique_labels):
7076
+ color = colors[i % len(colors)]
7077
+ cell_mask = mask == label
7078
+ overlay[cell_mask, :3] = color[:3]
7079
+ overlay[cell_mask, 3] = 0.3 # Alpha (slightly transparent)
7080
+
7081
+ self.ax_segmentation.imshow(overlay)
7082
+
7083
+ # Draw white contours around each cell
7084
+ self.ax_segmentation.contour(mask, levels=[0.5], colors='white', linewidths=1.5)
7085
+
7086
+ # Add axis labels in pixels (same as plot_segmentation)
7087
+ height, width = image_to_display.shape[:2]
7088
+ self.ax_segmentation.set_xlabel('X (pixels)', color='white', fontsize=10)
7089
+ self.ax_segmentation.set_ylabel('Y (pixels)', color='white', fontsize=10)
7090
+
7091
+ num_x_ticks = 5
7092
+ x_tick_positions = np.linspace(0, width - 1, num_x_ticks)
7093
+ self.ax_segmentation.set_xticks(x_tick_positions)
7094
+ self.ax_segmentation.set_xticklabels([f'{int(pos)}' for pos in x_tick_positions], color='white', fontsize=8)
7095
+
7096
+ num_y_ticks = 5
7097
+ y_tick_positions = np.linspace(0, height - 1, num_y_ticks)
7098
+ self.ax_segmentation.set_yticks(y_tick_positions)
7099
+ self.ax_segmentation.set_yticklabels([f'{int(pos)}' for pos in y_tick_positions], color='white', fontsize=8)
7100
+
7101
+ self.ax_segmentation.tick_params(axis='both', colors='white', direction='out', length=4)
7102
+ self.ax_segmentation.spines['bottom'].set_color('white')
7103
+ self.ax_segmentation.spines['left'].set_color('white')
7104
+ self.ax_segmentation.spines['top'].set_visible(False)
7105
+ self.ax_segmentation.spines['right'].set_visible(False)
7106
+
7107
+ # Add subtle grid lines
7108
+ self.ax_segmentation.grid(True, linewidth=0.3, alpha=0.3, color='white')
7109
+
7110
+ # Set title
7111
+ if self.edit_current_mask_key:
7112
+ title = f"Editing: {self.edit_current_mask_key.replace('_', ' ').title()}"
7113
+ if self.edit_source_is_tyx:
7114
+ title += " (Max Projection)"
7115
+ else:
7116
+ title = "Select a mask to edit"
7117
+ self.ax_segmentation.set_title(title, color='white', fontsize=10)
7118
+
7119
+ self.figure_segmentation.tight_layout()
7120
+ self.canvas_segmentation.draw()
7121
+
7122
+ def _connect_edit_mouse_events(self):
7123
+ """Connect mouse event handlers for edit mode."""
7124
+ # Disconnect any existing handlers
7125
+ self._disconnect_edit_mouse_events()
7126
+
7127
+ # Connect press, release, and motion events
7128
+ self._edit_cid_press = self.canvas_segmentation.mpl_connect(
7129
+ 'button_press_event', self._on_edit_mouse_press)
7130
+ self._edit_cid_release = self.canvas_segmentation.mpl_connect(
7131
+ 'button_release_event', self._on_edit_mouse_release)
7132
+ self._edit_cid_motion = self.canvas_segmentation.mpl_connect(
7133
+ 'motion_notify_event', self._on_edit_mouse_motion)
7134
+
7135
+ def _disconnect_edit_mouse_events(self):
7136
+ """Disconnect mouse event handlers for edit mode."""
7137
+ if hasattr(self, '_edit_cid_press') and self._edit_cid_press:
7138
+ self.canvas_segmentation.mpl_disconnect(self._edit_cid_press)
7139
+ self._edit_cid_press = None
7140
+ if hasattr(self, '_edit_cid_release') and self._edit_cid_release:
7141
+ self.canvas_segmentation.mpl_disconnect(self._edit_cid_release)
7142
+ self._edit_cid_release = None
7143
+ if hasattr(self, '_edit_cid_motion') and self._edit_cid_motion:
7144
+ self.canvas_segmentation.mpl_disconnect(self._edit_cid_motion)
7145
+ self._edit_cid_motion = None
7146
+
7147
+ def _on_edit_mouse_press(self, event):
7148
+ """Handle mouse button press in edit mode (eraser brush only)."""
7149
+ if event.inaxes != self.ax_segmentation:
7150
+ return
7151
+ if event.xdata is None or event.ydata is None:
7152
+ return
7153
+ if not self.edit_mode_active or self.edit_working_mask is None:
7154
+ return
7155
+
7156
+ x, y = int(event.xdata), int(event.ydata)
7157
+
7158
+ # Start erasing
7159
+ self.edit_is_drawing = True
7160
+ self.edit_last_mouse_pos = (x, y)
7161
+
7162
+ # Push undo state at start of stroke
7163
+ self._push_undo("Erase")
7164
+
7165
+ # Apply first point
7166
+ self._apply_eraser_at(x, y)
7167
+ self.plot_edit_mode()
7168
+
7169
+ def _on_edit_mouse_release(self, event):
7170
+ """Handle mouse button release in edit mode."""
7171
+ if self.edit_is_drawing:
7172
+ self.edit_is_drawing = False
7173
+ self.edit_last_mouse_pos = None
7174
+ self._update_edit_buttons_state()
7175
+
7176
+ def _on_edit_mouse_motion(self, event):
7177
+ """Handle mouse motion in edit mode (for eraser drawing)."""
7178
+ if not self.edit_is_drawing:
7179
+ return
7180
+ if event.inaxes != self.ax_segmentation:
7181
+ return
7182
+ if event.xdata is None or event.ydata is None:
7183
+ return
7184
+ if self.edit_working_mask is None:
7185
+ return
7186
+
7187
+ x, y = int(event.xdata), int(event.ydata)
7188
+
7189
+ # Apply eraser at new position
7190
+ self._apply_eraser_at(x, y)
7191
+
7192
+ # Draw line between last position and current for smooth strokes
7193
+ if self.edit_last_mouse_pos:
7194
+ self._apply_eraser_line(self.edit_last_mouse_pos[0], self.edit_last_mouse_pos[1], x, y)
7195
+
7196
+ self.edit_last_mouse_pos = (x, y)
7197
+ self.plot_edit_mode()
7198
+
7199
+ def _apply_eraser_at(self, x, y):
7200
+ """Apply eraser brush at given position (sets pixels to background 0).
7201
+
7202
+ Uses a fixed 10px circular brush.
7203
+ """
7204
+ if self.edit_working_mask is None:
7205
+ return
7206
+
7207
+ mask = self.edit_working_mask
7208
+ h, w = mask.shape
7209
+ size = 10 # Fixed brush size
7210
+
7211
+ # Create circular brush mask
7212
+ yy, xx = np.ogrid[:h, :w]
7213
+ brush_mask = ((xx - x) ** 2 + (yy - y) ** 2) <= (size / 2) ** 2
7214
+
7215
+ # Erase: set pixels to background (0)
7216
+ mask[brush_mask] = 0
7217
+
7218
+ def _apply_eraser_line(self, x0, y0, x1, y1):
7219
+ """Apply eraser along a line between two points for smooth strokes."""
7220
+ from skimage.draw import line
7221
+
7222
+ rr, cc = line(y0, x0, y1, x1)
7223
+
7224
+ for r, c in zip(rr, cc):
7225
+ self._apply_eraser_at(c, r)
7226
+
7227
+
6274
7228
  # =============================================================================
6275
7229
  # =============================================================================
6276
7230
  # PHOTOBLEACHING TAB
@@ -9054,7 +10008,7 @@ class GUI(QMainWindow):
9054
10008
  # Cluster radius
9055
10009
  self.cluster_radius_input = QSpinBox()
9056
10010
  self.cluster_radius_input.setMinimum(100)
9057
- self.cluster_radius_input.setMaximum(2000)
10011
+ self.cluster_radius_input.setMaximum(6000)
9058
10012
  self.cluster_radius_input.setValue(self.cluster_radius_nm)
9059
10013
  self.cluster_radius_input.valueChanged.connect(self.update_cluster_radius)
9060
10014
  params_layout.addRow("Cluster radius (nm):", self.cluster_radius_input)
@@ -10691,7 +11645,7 @@ class GUI(QMainWindow):
10691
11645
  }
10692
11646
 
10693
11647
  if display_crop is not None and display_flags is not None:
10694
- self.display_colocalization_results(display_crop, crop_size, display_flags, ch1, ch2)
11648
+ self.display_colocalization_results(display_crop, crop_size, display_flags, ch1, ch2, auto_columns=True)
10695
11649
  self.extract_colocalization_data(save_df=False)
10696
11650
 
10697
11651
  def _compute_coloc_flags(self, mean_crop, crop_size, ch2, method, threshold):
@@ -10776,20 +11730,34 @@ class GUI(QMainWindow):
10776
11730
 
10777
11731
 
10778
11732
 
10779
- def display_colocalization_results(self, mean_crop, crop_size, flag_vector, ch1, ch2):
10780
- """Display the colocalization result using provided crop data."""
11733
+ def display_colocalization_results(self, mean_crop, crop_size, flag_vector, ch1, ch2, auto_columns=False):
11734
+ """Display the colocalization result using provided crop data.
11735
+
11736
+ Args:
11737
+ mean_crop: Cropped spot images
11738
+ crop_size: Size of each crop
11739
+ flag_vector: Boolean vector indicating colocalization status
11740
+ ch1: Reference channel index
11741
+ ch2: Colocalize channel index
11742
+ auto_columns: If True, auto-calculate optimal columns and update spinbox.
11743
+ If False (default), use current spinbox value.
11744
+ """
10781
11745
  self.figure_colocalization.clear()
10782
11746
  title = f"Colocalization: {self.colocalization_results['colocalization_percentage']:.2f}%"
10783
11747
 
10784
- # Auto-calculate optimal number of columns based on spot count
10785
- n_spots = len(flag_vector) if flag_vector is not None else 0
10786
- optimal_cols = self._calculate_optimal_coloc_columns(n_spots)
10787
-
10788
- # Update the spinbox to reflect the auto-calculated value
10789
- if hasattr(self, 'columns_spinbox'):
10790
- self.columns_spinbox.blockSignals(True)
10791
- self.columns_spinbox.setValue(optimal_cols)
10792
- self.columns_spinbox.blockSignals(False)
11748
+ # Determine number of columns to use
11749
+ if auto_columns:
11750
+ # Auto-calculate optimal number of columns based on spot count
11751
+ n_spots = len(flag_vector) if flag_vector is not None else 0
11752
+ num_cols = self._calculate_optimal_coloc_columns(n_spots)
11753
+ # Update the spinbox to reflect the auto-calculated value
11754
+ if hasattr(self, 'columns_spinbox'):
11755
+ self.columns_spinbox.blockSignals(True)
11756
+ self.columns_spinbox.setValue(num_cols)
11757
+ self.columns_spinbox.blockSignals(False)
11758
+ else:
11759
+ # Use user's current spinbox value
11760
+ num_cols = self.columns_spinbox.value() if hasattr(self, 'columns_spinbox') else 20
10793
11761
 
10794
11762
  self.plots.plot_matrix_pair_crops(
10795
11763
  mean_crop=mean_crop,
@@ -10798,7 +11766,7 @@ class GUI(QMainWindow):
10798
11766
  selected_channels=(ch1, ch2),
10799
11767
  figure=self.figure_colocalization,
10800
11768
  crop_spacing=5,
10801
- number_columns=optimal_cols,
11769
+ number_columns=num_cols,
10802
11770
  plot_title=title
10803
11771
  )
10804
11772
  try:
@@ -10845,6 +11813,26 @@ class GUI(QMainWindow):
10845
11813
  optimal = min(200, 120 + int((n_spots - 5000) * 80 / 10000))
10846
11814
 
10847
11815
  return optimal
11816
+
11817
+ def _on_coloc_columns_changed(self, value):
11818
+ """Handle user change to the Crop Columns spinbox.
11819
+
11820
+ Redraws the colocalization display with the new column count.
11821
+ Only triggers if colocalization results exist.
11822
+ """
11823
+ if not self.colocalization_results:
11824
+ return
11825
+
11826
+ # Redraw with user's selected column count (auto_columns=False by default)
11827
+ self.display_colocalization_results(
11828
+ self.colocalization_results['mean_crop_filtered'],
11829
+ self.colocalization_results['crop_size'],
11830
+ self.colocalization_results['flag_vector'],
11831
+ self.colocalization_results['ch1_index'],
11832
+ self.colocalization_results['ch2_index'],
11833
+ auto_columns=False
11834
+ )
11835
+ self.canvas_colocalization.draw()
10848
11836
 
10849
11837
 
10850
11838
  # Note: display_colocalization_manual() removed - replaced by separate
@@ -11376,8 +12364,9 @@ class GUI(QMainWindow):
11376
12364
  columnsLayout.addWidget(QLabel("Columns:"))
11377
12365
  self.columns_spinbox = QSpinBox()
11378
12366
  self.columns_spinbox.setRange(4, 200)
11379
- self.columns_spinbox.setValue(20) # Auto-adjusted when running
11380
- self.columns_spinbox.setToolTip("Auto-adjusted based on spot count (larger = wider image)")
12367
+ self.columns_spinbox.setValue(20) # Auto-adjusted on first run, then user-controlled
12368
+ self.columns_spinbox.setToolTip("Adjust number of columns in crop display. Auto-set on first run.")
12369
+ self.columns_spinbox.valueChanged.connect(self._on_coloc_columns_changed)
11381
12370
  columnsLayout.addWidget(self.columns_spinbox)
11382
12371
  top_layout.addWidget(columnsGroup)
11383
12372
  actionsGroup = QGroupBox("Actions")
@@ -11701,9 +12690,13 @@ class GUI(QMainWindow):
11701
12690
  layout = QVBoxLayout(self.coloc_verify_distance_widget)
11702
12691
  layout.setContentsMargins(10, 5, 10, 5)
11703
12692
 
11704
- # Info label
11705
- info_label = QLabel("Review and correct Distance-based colocalization results:")
12693
+ # Info label explaining what is displayed
12694
+ info_label = QLabel(
12695
+ "Review unique particle tracks. Each row shows a time-averaged crop. "
12696
+ "A track is marked colocalized (✓) if ANY frame is within the distance threshold."
12697
+ )
11706
12698
  info_label.setStyleSheet("font-style: italic; color: #999;")
12699
+ info_label.setWordWrap(True)
11707
12700
  layout.addWidget(info_label)
11708
12701
 
11709
12702
  # Top bar with stats and buttons
@@ -12553,6 +13546,9 @@ class GUI(QMainWindow):
12553
13546
  channels=(ch1, ch2)
12554
13547
  )
12555
13548
 
13549
+ # Reset sorted flag so Sort button can be used
13550
+ self._verify_visual_sorted = False
13551
+
12556
13552
  # Update stats label
12557
13553
  self._update_verify_visual_stats()
12558
13554
 
@@ -12570,20 +13566,76 @@ class GUI(QMainWindow):
12570
13566
  )
12571
13567
 
12572
13568
  def sort_verify_visual(self):
12573
- """Sort Verify Visual results by prediction value (lowest to highest)."""
13569
+ """Sort Verify Visual results by prediction value (lowest to highest for review)."""
12574
13570
  if not hasattr(self, 'verify_visual_checkboxes') or len(self.verify_visual_checkboxes) == 0:
13571
+ QMessageBox.information(self, "No Data", "No spots to sort. Please click Populate first.")
12575
13572
  return
12576
13573
 
12577
- values = self.colocalization_results.get('prediction_values_vector') if hasattr(self, 'colocalization_results') else None
13574
+ if not hasattr(self, 'colocalization_results') or not self.colocalization_results:
13575
+ QMessageBox.warning(self, "No Results", "No colocalization results available.")
13576
+ return
13577
+
13578
+ results = self.colocalization_results
13579
+ values = results.get('prediction_values_vector')
13580
+ mean_crop = results.get('mean_crop_filtered')
13581
+ crop_size = results.get('crop_size', 15)
13582
+ flag_vector = results.get('flag_vector')
13583
+ ch1 = results.get('ch1_index', 0)
13584
+ ch2 = results.get('ch2_index', 1)
13585
+
12578
13586
  if values is None or len(values) == 0:
12579
13587
  QMessageBox.information(self, "Cannot Sort", "No prediction values available for sorting.")
12580
13588
  return
12581
13589
 
12582
- # Re-populate with sorted order would require rebuilding crops
12583
- # For now, show a message that sorting is based on visual arrangement
12584
- QMessageBox.information(self, "Sort",
12585
- "Spots are already displayed in their original detection order. "
12586
- "Lower prediction values indicate uncertain colocalization.")
13590
+ if mean_crop is None:
13591
+ QMessageBox.warning(self, "No Data", "Crop data not available for sorting.")
13592
+ return
13593
+
13594
+ # Check if already sorted (compare to original order)
13595
+ if hasattr(self, '_verify_visual_sorted') and self._verify_visual_sorted:
13596
+ QMessageBox.information(self, "Already Sorted", "Spots are already sorted by prediction value.")
13597
+ return
13598
+
13599
+ # Get current checkbox states before sorting
13600
+ current_states = [chk.isChecked() for chk in self.verify_visual_checkboxes]
13601
+
13602
+ # Create sorted indices (ascending by prediction value - uncertain first)
13603
+ num_spots = len(values)
13604
+ sorted_indices = np.argsort(values)
13605
+
13606
+ # Re-order checkbox states to match new sort order
13607
+ sorted_states = [current_states[i] if i < len(current_states) else False for i in sorted_indices]
13608
+
13609
+ # Re-order crops - each spot is crop_size rows in the mean_crop array
13610
+ num_crop_spots = mean_crop.shape[0] // crop_size
13611
+ if num_crop_spots < num_spots:
13612
+ num_spots = num_crop_spots
13613
+ sorted_indices = sorted_indices[:num_spots]
13614
+
13615
+ sorted_crop = np.zeros_like(mean_crop[:num_spots*crop_size])
13616
+ for new_idx, old_idx in enumerate(sorted_indices[:num_spots]):
13617
+ if old_idx < num_crop_spots:
13618
+ sorted_crop[new_idx*crop_size:(new_idx+1)*crop_size] = \
13619
+ mean_crop[old_idx*crop_size:(old_idx+1)*crop_size]
13620
+
13621
+ # Re-create verification crops with sorted data
13622
+ self._create_verification_crops(
13623
+ scroll_area=self.verify_visual_scroll_area,
13624
+ checkboxes_list_attr='verify_visual_checkboxes',
13625
+ mean_crop=sorted_crop,
13626
+ crop_size=crop_size,
13627
+ flag_vector=sorted_states, # Use previously checked states after reorder
13628
+ stats_label=self.verify_visual_stats_label,
13629
+ num_channels=2,
13630
+ channels=(ch1, ch2)
13631
+ )
13632
+
13633
+ # Mark as sorted
13634
+ self._verify_visual_sorted = True
13635
+ self._verify_visual_sort_indices = sorted_indices
13636
+
13637
+ # Update stats
13638
+ self._update_verify_visual_stats()
12587
13639
 
12588
13640
  def cleanup_verify_visual(self):
12589
13641
  """Clear all checkboxes in Verify Visual subtab."""
@@ -12630,7 +13682,11 @@ class GUI(QMainWindow):
12630
13682
  # === Verify Distance Subtab Methods ===
12631
13683
 
12632
13684
  def populate_verify_distance(self):
12633
- """Populate the Verify Distance subtab with Distance colocalization results."""
13685
+ """Populate the Verify Distance subtab with Distance colocalization results.
13686
+
13687
+ Calculates and stores the minimum distance from each reference channel spot
13688
+ to its nearest partner in the target channel for sorting purposes.
13689
+ """
12634
13690
  if not hasattr(self, 'distance_coloc_results') or not self.distance_coloc_results:
12635
13691
  QMessageBox.warning(self, "No Results",
12636
13692
  "Please run Distance colocalization first.")
@@ -12641,8 +13697,10 @@ class GUI(QMainWindow):
12641
13697
  ch0 = results.get('channel_0', 0)
12642
13698
  ch1 = results.get('channel_1', 1)
12643
13699
  df_coloc = results.get('df_colocalized', pd.DataFrame())
13700
+ df_ch1_all = results.get('df_ch1_all', pd.DataFrame())
12644
13701
  threshold_px = results.get('threshold_distance_px', 2.0)
12645
13702
  threshold_nm = results.get('threshold_distance_nm', 130.0)
13703
+ use_3d = results.get('use_3d', False)
12646
13704
 
12647
13705
  # We need to create crops from tracking data
12648
13706
  if not hasattr(self, 'df_tracking') or self.df_tracking.empty:
@@ -12684,28 +13742,129 @@ class GUI(QMainWindow):
12684
13742
 
12685
13743
  num_spots = mean_crop.shape[0] // crop_size
12686
13744
 
12687
- # Create flag vector based on distance colocalization
12688
- # Mark spots as colocalized if their coordinates match
12689
- coloc_coords = set()
13745
+ # Build set of colocalized coordinates for matching
13746
+ # Use a tolerance-based approach instead of exact coordinate matching
13747
+ coloc_coords_array = np.empty((0, 4)) # z, y, x, cell_id
12690
13748
  if not df_coloc.empty:
12691
- for _, row in df_coloc.iterrows():
12692
- coord = (round(row.get('x', 0), 1), round(row.get('y', 0), 1))
12693
- coloc_coords.add(coord)
12694
-
13749
+ if 'z' in df_coloc.columns and use_3d:
13750
+ coloc_coords_array = df_coloc[['z', 'y', 'x', 'cell_id']].values
13751
+ else:
13752
+ # Add dummy z=0 for 2D matching
13753
+ coloc_coords_array = np.column_stack([
13754
+ np.zeros(len(df_coloc)),
13755
+ df_coloc['y'].values,
13756
+ df_coloc['x'].values,
13757
+ df_coloc['cell_id'].values
13758
+ ])
13759
+
13760
+ # Calculate minimum distances for each spot in ch0 to nearest spot in ch1
13761
+ # This will be used for sorting (ascending = closest to threshold = most uncertain)
13762
+ distance_values = []
12695
13763
  flag_vector = []
12696
- for i, (_, row) in enumerate(df_ch0.drop_duplicates(subset=['particle']).iterrows()):
13764
+
13765
+ # Get ch1 coordinates for distance calculation
13766
+ ch1_coords = None
13767
+ if not df_ch1_all.empty and 'x' in df_ch1_all.columns and 'y' in df_ch1_all.columns:
13768
+ if use_3d and 'z' in df_ch1_all.columns:
13769
+ ch1_coords = df_ch1_all[['z', 'y', 'x']].values
13770
+ else:
13771
+ ch1_coords = df_ch1_all[['y', 'x']].values
13772
+
13773
+ # Get anisotropic scaling for 3D
13774
+ voxel_z_nm = results.get('voxel_z_nm', 300.0)
13775
+ voxel_xy_nm = results.get('voxel_xy_nm', 130.0)
13776
+ z_scale = voxel_z_nm / voxel_xy_nm if use_3d and voxel_xy_nm > 0 else 1.0
13777
+
13778
+ # Use the same particle column identification as CropArray
13779
+ # This ensures our iteration matches the crop order
13780
+ df_ch0_copy = df_ch0.copy()
13781
+ if 'unique_particle' in df_ch0_copy.columns:
13782
+ particle_col = 'unique_particle'
13783
+ elif 'cell_id' in df_ch0_copy.columns:
13784
+ if 'spot_type' in df_ch0_copy.columns:
13785
+ df_ch0_copy['unique_particle'] = (
13786
+ df_ch0_copy['cell_id'].astype(str) + '_' +
13787
+ df_ch0_copy['spot_type'].astype(str) + '_' +
13788
+ df_ch0_copy['particle'].astype(str)
13789
+ )
13790
+ else:
13791
+ df_ch0_copy['unique_particle'] = (
13792
+ df_ch0_copy['cell_id'].astype(str) + '_' +
13793
+ df_ch0_copy['particle'].astype(str)
13794
+ )
13795
+ particle_col = 'unique_particle'
13796
+ else:
13797
+ particle_col = 'particle'
13798
+
13799
+ # Helper function to check if a spot coordinate is in the colocalized set
13800
+ def is_coord_colocalized(z, y, x, cell_id, coloc_arr, tolerance=1.0):
13801
+ """Check if a spot is in the colocalized set using coordinate tolerance."""
13802
+ if len(coloc_arr) == 0:
13803
+ return False
13804
+ # Filter by cell_id first for efficiency
13805
+ cell_mask = coloc_arr[:, 3].astype(int) == int(cell_id)
13806
+ cell_coloc = coloc_arr[cell_mask]
13807
+ if len(cell_coloc) == 0:
13808
+ return False
13809
+ # Check distance to each colocalized spot
13810
+ for cz, cy, cx, _ in cell_coloc:
13811
+ dist_xy = np.sqrt((x - cx)**2 + (y - cy)**2)
13812
+ dist_z = abs(z - cz) if use_3d else 0
13813
+ if dist_xy <= tolerance and dist_z <= tolerance:
13814
+ return True
13815
+ return False
13816
+
13817
+ # Iterate unique particles in the same order as CropArray
13818
+ unique_particles = df_ch0_copy[particle_col].unique()
13819
+
13820
+ for i, particle_id in enumerate(unique_particles):
12697
13821
  if i >= num_spots:
12698
13822
  break
12699
- coord = (round(row.get('x', 0), 1), round(row.get('y', 0), 1))
12700
- flag_vector.append(coord in coloc_coords)
13823
+
13824
+ df_particle = df_ch0_copy[df_ch0_copy[particle_col] == particle_id]
13825
+
13826
+ # Check if ANY observation of this particle is colocalized
13827
+ is_coloc = False
13828
+ min_dist_all = threshold_px * 10.0 # Large default
13829
+
13830
+ for _, row in df_particle.iterrows():
13831
+ x_val, y_val = row['x'], row['y']
13832
+ z_val = row.get('z', 0)
13833
+ cell_id = row.get('cell_id', 0)
13834
+
13835
+ # Check if this observation is in the colocalized set
13836
+ if len(coloc_coords_array) > 0:
13837
+ if is_coord_colocalized(z_val, y_val, x_val, cell_id, coloc_coords_array, tolerance=1.0):
13838
+ is_coloc = True
13839
+
13840
+ # Calculate minimum distance to any ch1 spot for this observation
13841
+ if ch1_coords is not None and len(ch1_coords) > 0:
13842
+ if use_3d and ch1_coords.shape[1] == 3:
13843
+ spot_coord = np.array([[z_val * z_scale, y_val, x_val]])
13844
+ ch1_scaled = ch1_coords.copy().astype(float)
13845
+ ch1_scaled[:, 0] = ch1_scaled[:, 0] * z_scale # Scale Z
13846
+ else:
13847
+ spot_coord = np.array([[y_val, x_val]])
13848
+ ch1_scaled = ch1_coords
13849
+
13850
+ from scipy.spatial.distance import cdist
13851
+ distances = cdist(spot_coord, ch1_scaled, metric='euclidean')
13852
+ obs_min_dist = float(np.min(distances))
13853
+ if obs_min_dist < min_dist_all:
13854
+ min_dist_all = obs_min_dist
13855
+
13856
+ flag_vector.append(is_coloc)
13857
+ distance_values.append(min_dist_all)
12701
13858
 
12702
- # Pad flag_vector if needed
13859
+ # Pad vectors if needed (shouldn't happen, but just in case)
12703
13860
  while len(flag_vector) < num_spots:
12704
13861
  flag_vector.append(False)
13862
+ distance_values.append(threshold_px * 10.0)
12705
13863
 
12706
- # Store for later use
13864
+ # Store for later use (sorting, etc.)
12707
13865
  self.verify_distance_mean_crop = mean_crop
12708
13866
  self.verify_distance_crop_size = crop_size
13867
+ self.verify_distance_values = np.array(distance_values) # For sorting by distance
12709
13868
 
12710
13869
  # Create spot crops with checkboxes
12711
13870
  self._create_verification_crops(
@@ -12719,6 +13878,9 @@ class GUI(QMainWindow):
12719
13878
  channels=(ch0, ch1)
12720
13879
  )
12721
13880
 
13881
+ # Reset sorted flag so Sort button can be used
13882
+ self._verify_distance_sorted = False
13883
+
12722
13884
  # Update stats label
12723
13885
  self._update_verify_distance_stats()
12724
13886
 
@@ -12743,9 +13905,88 @@ class GUI(QMainWindow):
12743
13905
  )
12744
13906
 
12745
13907
  def sort_verify_distance(self):
12746
- """Sort Verify Distance results (by cell ID or coordinate)."""
12747
- QMessageBox.information(self, "Sort",
12748
- "Distance colocalization spots are displayed in detection order.")
13908
+ """Sort Verify Distance results by distance value (ascending - closest to threshold first).
13909
+
13910
+ Similar to Visual method's certainty-based sorting, but uses the measured
13911
+ distance to nearest partner. Spots with distances closest to the colocalization
13912
+ threshold are shown first as they represent the most uncertain classifications.
13913
+ """
13914
+ if not hasattr(self, 'verify_distance_checkboxes') or len(self.verify_distance_checkboxes) == 0:
13915
+ QMessageBox.information(self, "No Data", "No spots to sort. Please click Populate first.")
13916
+ return
13917
+
13918
+ if not hasattr(self, 'verify_distance_mean_crop') or self.verify_distance_mean_crop is None:
13919
+ QMessageBox.warning(self, "No Data", "Crop data not available for sorting.")
13920
+ return
13921
+
13922
+ # Check if distance values are available
13923
+ if not hasattr(self, 'verify_distance_values') or self.verify_distance_values is None:
13924
+ QMessageBox.warning(self, "No Distance Data",
13925
+ "Distance values not available. Please re-run Populate.")
13926
+ return
13927
+
13928
+ # Check if already sorted
13929
+ if hasattr(self, '_verify_distance_sorted') and self._verify_distance_sorted:
13930
+ QMessageBox.information(self, "Already Sorted", "Spots are already sorted by distance value.")
13931
+ return
13932
+
13933
+ mean_crop = self.verify_distance_mean_crop
13934
+ crop_size = self.verify_distance_crop_size
13935
+ distance_values = self.verify_distance_values
13936
+
13937
+ # Get current checkbox states before sorting
13938
+ current_states = [chk.isChecked() for chk in self.verify_distance_checkboxes]
13939
+ num_spots = len(current_states)
13940
+
13941
+ # Sort ascending by distance (closest to threshold = most uncertain first)
13942
+ # This matches the Visual method's approach of showing uncertain cases first
13943
+ sorted_indices = np.argsort(distance_values)
13944
+
13945
+ # Re-order states and distances
13946
+ sorted_states = [current_states[i] if i < len(current_states) else False for i in sorted_indices]
13947
+ sorted_distances = distance_values[sorted_indices]
13948
+
13949
+ # Re-order crops
13950
+ num_crop_spots = mean_crop.shape[0] // crop_size
13951
+ if num_crop_spots < num_spots:
13952
+ num_spots = num_crop_spots
13953
+ sorted_indices = sorted_indices[:num_spots]
13954
+
13955
+ sorted_crop = np.zeros_like(mean_crop[:num_spots*crop_size])
13956
+ for new_idx, old_idx in enumerate(sorted_indices[:num_spots]):
13957
+ if old_idx < num_crop_spots:
13958
+ sorted_crop[new_idx*crop_size:(new_idx+1)*crop_size] = \
13959
+ mean_crop[old_idx*crop_size:(old_idx+1)*crop_size]
13960
+
13961
+ # Get channels from distance results
13962
+ results = self.distance_coloc_results if hasattr(self, 'distance_coloc_results') else {}
13963
+ ch0 = results.get('channel_0', 0)
13964
+ ch1 = results.get('channel_1', 1)
13965
+ image = self.corrected_image if self.corrected_image is not None else self.image_stack
13966
+ num_channels = image.shape[-1] if image is not None and image.ndim == 5 else 1
13967
+
13968
+ # Re-create verification crops with sorted data
13969
+ self._create_verification_crops(
13970
+ scroll_area=self.verify_distance_scroll_area,
13971
+ checkboxes_list_attr='verify_distance_checkboxes',
13972
+ mean_crop=sorted_crop,
13973
+ crop_size=crop_size,
13974
+ flag_vector=sorted_states,
13975
+ stats_label=self.verify_distance_stats_label,
13976
+ num_channels=num_channels,
13977
+ channels=(ch0, ch1)
13978
+ )
13979
+
13980
+ # Update stored data after sorting for consistency
13981
+ self.verify_distance_mean_crop = sorted_crop
13982
+ self.verify_distance_values = sorted_distances
13983
+ self._verify_distance_sort_indices = sorted_indices # Store for reference
13984
+
13985
+ # Mark as sorted
13986
+ self._verify_distance_sorted = True
13987
+
13988
+ # Update stats
13989
+ self._update_verify_distance_stats()
12749
13990
 
12750
13991
  def cleanup_verify_distance(self):
12751
13992
  """Clear all checkboxes in Verify Distance subtab."""
@@ -12833,7 +14074,12 @@ class GUI(QMainWindow):
12833
14074
 
12834
14075
  # Checkbox
12835
14076
  chk = QCheckBox(f"Spot {i+1}")
12836
- chk.setChecked(bool(flag_vector[i]) if i < len(flag_vector) else False)
14077
+ # Safely get the flag value (handle numpy arrays, lists, etc.)
14078
+ try:
14079
+ flag_val = bool(flag_vector[i]) if i < len(flag_vector) else False
14080
+ except (TypeError, IndexError):
14081
+ flag_val = False
14082
+ chk.setChecked(flag_val)
12837
14083
  chk.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
12838
14084
 
12839
14085
  # Connect to stats update
@@ -12897,7 +14143,18 @@ class GUI(QMainWindow):
12897
14143
  spot_coord = (int(dfm.iloc[0]['y']), int(dfm.iloc[0]['x']))
12898
14144
  found_spot = True
12899
14145
  else:
12900
- spot_coord = (0, 0)
14146
+ # Particle not in current frame - jump to first valid frame for this particle
14147
+ df_particle = self.df_tracking[self.df_tracking[particle_col] == pid]
14148
+ if not df_particle.empty:
14149
+ first_frame = int(df_particle['frame'].min())
14150
+ self.current_frame = first_frame
14151
+ if hasattr(self, 'time_slider_tracking_vis'):
14152
+ self.time_slider_tracking_vis.setValue(first_frame)
14153
+ dfm = df_particle[df_particle['frame'] == first_frame]
14154
+ spot_coord = (int(dfm.iloc[0]['y']), int(dfm.iloc[0]['x']))
14155
+ found_spot = True
14156
+ else:
14157
+ spot_coord = (0, 0)
12901
14158
  else:
12902
14159
  spot_coord = (0, 0)
12903
14160
  else:
@@ -12956,7 +14213,12 @@ class GUI(QMainWindow):
12956
14213
  else:
12957
14214
  main_img = norm_stack[selected_channelIndex]
12958
14215
  main_cmap = cmap_list_imagej[selected_channelIndex % len(cmap_list_imagej)]
12959
- gs = fig.add_gridspec(1, 2, width_ratios=[3, 2], hspace=0.1, wspace=0.1)
14216
+
14217
+ # Always show 3D intensity profile for spot quality assessment
14218
+ show_3d_profile = True
14219
+
14220
+ # 3-column layout: main image + 2D crops + 3D surfaces
14221
+ gs = fig.add_gridspec(1, 3, width_ratios=[3, 1, 1.5], hspace=0.1, wspace=0.15)
12960
14222
  ax_main = fig.add_subplot(gs[0, 0])
12961
14223
 
12962
14224
  # Store main axes reference and recreate RectangleSelector
@@ -12972,8 +14234,15 @@ class GUI(QMainWindow):
12972
14234
  props=dict(facecolor='cyan', edgecolor='white', alpha=0.3, linewidth=2)
12973
14235
  )
12974
14236
 
14237
+ # 2D crop subgrid
12975
14238
  gs2 = gs[0, 1].subgridspec(C, 1, hspace=0.1)
12976
- axes_zoom = [fig.add_subplot(gs2[i, 0]) for i in range(C)]
14239
+ axes_zoom = [fig.add_subplot(gs2[i, 0]) for i in range(C)]
14240
+
14241
+ # 3D profile subgrid (only create if enabled)
14242
+ axes_3d = []
14243
+ if show_3d_profile:
14244
+ gs3 = gs[0, 2].subgridspec(C, 1, hspace=0.15)
14245
+ axes_3d = [fig.add_subplot(gs3[i, 0], projection='3d') for i in range(C)]
12977
14246
  # remove background if requested
12978
14247
  if hasattr(self, 'checkbox_remove_bg') and self.checkbox_remove_bg.isChecked():
12979
14248
  seg_mask = getattr(self, 'segmentation_mask', None)
@@ -13027,6 +14296,15 @@ class GUI(QMainWindow):
13027
14296
  rect = patches.Rectangle((x0, y0), crop_sz, crop_sz, edgecolor='white', facecolor='none', linewidth=2)
13028
14297
  ax_main.add_patch(rect)
13029
14298
  ax_main.axis('off')
14299
+
14300
+ # Add thin border to show image boundaries (matching Tracking tab style)
14301
+ # Use -0.5 origin and full size to place border outside the image
14302
+ if self.image_stack is not None:
14303
+ img_H, img_W = main_img.shape[:2]
14304
+ img_border = patches.Rectangle((-0.5, -0.5), img_W, img_H, linewidth=0.8,
14305
+ edgecolor='#555555', facecolor='none', linestyle='-')
14306
+ ax_main.add_patch(img_border)
14307
+
13030
14308
  for ci, ax in enumerate(axes_zoom):
13031
14309
  if found_spot:
13032
14310
  crop = norm_stack[ci, y0:y1, x0:x1]
@@ -13034,6 +14312,59 @@ class GUI(QMainWindow):
13034
14312
  crop = np.zeros((crop_sz, crop_sz))
13035
14313
  ax.imshow(crop, cmap=cmap_list_imagej[ci % len(cmap_list_imagej)], interpolation='nearest', vmin=0, vmax=1)
13036
14314
  ax.axis('off')
14315
+ # Add gray frame border to 2D crops using Rectangle (spines hidden by axis('off'))
14316
+ # Use -0.5 origin and full size to place border outside the image (matplotlib centers pixels at integers)
14317
+ crop_h, crop_w = crop.shape[:2]
14318
+ crop_border = patches.Rectangle((-0.5, -0.5), crop_w, crop_h, linewidth=0.8,
14319
+ edgecolor='#555555', facecolor='none', linestyle='-')
14320
+ ax.add_patch(crop_border)
14321
+
14322
+ # Render 3D intensity profiles if enabled
14323
+ if show_3d_profile and axes_3d:
14324
+ # Create meshgrid for surface plot (only once, using crop dimensions)
14325
+ crop_example = norm_stack[0, y0:y1, x0:x1] if found_spot else np.zeros((crop_sz, crop_sz))
14326
+ Y_grid, X_grid = np.meshgrid(np.arange(crop_example.shape[0]),
14327
+ np.arange(crop_example.shape[1]), indexing='ij')
14328
+
14329
+ for ci, ax3d in enumerate(axes_3d):
14330
+ if found_spot:
14331
+ crop = norm_stack[ci, y0:y1, x0:x1]
14332
+ else:
14333
+ crop = np.zeros((crop_sz, crop_sz))
14334
+
14335
+ # Get channel colormap
14336
+ cmap = cmap_list_imagej[ci % len(cmap_list_imagej)]
14337
+
14338
+ # Plot surface with matching colormap
14339
+ ax3d.plot_surface(X_grid, Y_grid, crop, cmap=cmap,
14340
+ edgecolor='none', alpha=0.9, antialiased=True)
14341
+
14342
+ # Style the 3D axes for dark theme
14343
+ ax3d.set_facecolor('black')
14344
+ ax3d.set_xlabel('X', fontsize=7, color='white', labelpad=-2)
14345
+ ax3d.set_ylabel('Y', fontsize=7, color='white', labelpad=-2)
14346
+ ax3d.set_zlabel('I', fontsize=7, color='white', labelpad=-2)
14347
+ ax3d.tick_params(axis='both', which='major', labelsize=5, colors='white', pad=0)
14348
+ ax3d.tick_params(axis='z', which='major', labelsize=5, colors='white', pad=0)
14349
+
14350
+ # Set consistent Z limits for comparison across channels
14351
+ ax3d.set_zlim(0, 1)
14352
+
14353
+ # Make pane and grid styling match dark theme
14354
+ ax3d.xaxis.pane.fill = False
14355
+ ax3d.yaxis.pane.fill = False
14356
+ ax3d.zaxis.pane.fill = False
14357
+ ax3d.xaxis.pane.set_edgecolor('gray')
14358
+ ax3d.yaxis.pane.set_edgecolor('gray')
14359
+ ax3d.zaxis.pane.set_edgecolor('gray')
14360
+ ax3d.grid(True, alpha=0.3, color='gray')
14361
+
14362
+ # Add channel label
14363
+ ax3d.set_title(f'Ch{ci}', fontsize=8, color='white', pad=-5)
14364
+
14365
+ # Set viewing angle for nice perspective
14366
+ ax3d.view_init(elev=25, azim=-45)
14367
+
13037
14368
  fig.tight_layout()
13038
14369
 
13039
14370
  # Add thin white frame border to main image
@@ -14771,6 +16102,21 @@ class GUI(QMainWindow):
14771
16102
  self.watershed_size_slider.blockSignals(False)
14772
16103
  if hasattr(self, 'watershed_size_label'):
14773
16104
  self.watershed_size_label.setText("0")
16105
+
16106
+ # Reset Edit tab state
16107
+ self.edit_mode_active = False
16108
+ self.edit_working_mask = None
16109
+ self.edit_original_mask = None
16110
+ self.edit_current_mask_key = None
16111
+ self.edit_undo_stack = []
16112
+ if hasattr(self, '_disconnect_edit_mouse_events'):
16113
+ self._disconnect_edit_mouse_events()
16114
+ if hasattr(self, 'edit_mask_selector'):
16115
+ self.edit_mask_selector.blockSignals(True)
16116
+ self.edit_mask_selector.setCurrentIndex(0)
16117
+ self.edit_mask_selector.blockSignals(False)
16118
+ if hasattr(self, 'edit_instructions_group'):
16119
+ self.edit_instructions_group.setVisible(False)
14774
16120
 
14775
16121
  def reset_photobleaching_tab(self):
14776
16122
  self.figure_photobleaching.clear()
@@ -15475,6 +16821,7 @@ class GUI(QMainWindow):
15475
16821
  self.verify_visual_checkboxes = []
15476
16822
  if hasattr(self, 'verify_visual_stats_label'):
15477
16823
  self.verify_visual_stats_label.setText("Run Visual colocalization first, then click Populate")
16824
+ self._verify_visual_sorted = False
15478
16825
 
15479
16826
  # Reset Verify Distance
15480
16827
  if hasattr(self, 'verify_distance_scroll_area'):
@@ -15483,6 +16830,11 @@ class GUI(QMainWindow):
15483
16830
  self.verify_distance_checkboxes = []
15484
16831
  if hasattr(self, 'verify_distance_stats_label'):
15485
16832
  self.verify_distance_stats_label.setText("Run Distance colocalization first, then click Populate")
16833
+ # Reset stored distance data for sorting
16834
+ self.verify_distance_mean_crop = None
16835
+ self.verify_distance_crop_size = None
16836
+ self.verify_distance_values = None
16837
+ self._verify_distance_sorted = False
15486
16838
 
15487
16839
  def reset_cellpose_tab(self):
15488
16840
  """Reset Cellpose tab state, masks, and UI controls to defaults."""
@@ -15544,6 +16896,8 @@ class GUI(QMainWindow):
15544
16896
  self.chk_remove_border_cells.setChecked(False)
15545
16897
  if hasattr(self, 'chk_remove_unpaired_cells'):
15546
16898
  self.chk_remove_unpaired_cells.setChecked(False)
16899
+ if hasattr(self, 'chk_keep_center_cell'):
16900
+ self.chk_keep_center_cell.setChecked(False)
15547
16901
 
15548
16902
  # Refresh the shared segmentation display
15549
16903
  # (Cellpose now shares the segmentation canvas, so just refresh it)