microlive 1.0.18__py3-none-any.whl → 1.0.20__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
@@ -4183,6 +4183,19 @@ class GUI(QMainWindow):
4183
4183
  if hasattr(self, 'label_nuc_mask_status'):
4184
4184
  self.label_nuc_mask_status.setText("No nucleus mask loaded")
4185
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)
4186
4199
  self.plot_cellpose_results()
4187
4200
 
4188
4201
  def clear_imported_masks(self):
@@ -4600,6 +4613,142 @@ class GUI(QMainWindow):
4600
4613
  new_masks[masks == old_id] = new_id
4601
4614
  return new_masks
4602
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
+
4603
4752
  def plot_cellpose_results(self):
4604
4753
  """Plot Cellpose segmentation results on the shared segmentation canvas."""
4605
4754
  if self.image_stack is None:
@@ -4721,21 +4870,43 @@ class GUI(QMainWindow):
4721
4870
  self.segmentation_channel_buttons.append(btn)
4722
4871
 
4723
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
+
4724
4881
  # Clear old mask when changing channel
4725
4882
  self.segmentation_mask = None
4726
4883
  self.segmentation_current_channel = channel_index
4727
4884
 
4728
4885
  # Refresh display based on active sub-tab
4729
4886
  if hasattr(self, 'segmentation_method_tabs'):
4730
- current_subtab = self.segmentation_method_tabs.currentIndex()
4731
- 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
4732
4889
  self.plot_cellpose_results()
4890
+ elif current_index == 4: # Edit sub-tab
4891
+ self.plot_edit_mode()
4733
4892
  else:
4734
4893
  self.plot_segmentation()
4735
4894
  else:
4736
4895
  self.plot_segmentation()
4737
4896
 
4738
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
+
4739
4910
  # Clear old manual/watershed mask when changing frame
4740
4911
  self.segmentation_mask = None
4741
4912
  self.segmentation_current_frame = value
@@ -4754,9 +4925,11 @@ class GUI(QMainWindow):
4754
4925
 
4755
4926
  # Refresh display based on active sub-tab
4756
4927
  if hasattr(self, 'segmentation_method_tabs'):
4757
- current_subtab = self.segmentation_method_tabs.currentIndex()
4758
- 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
4759
4930
  self.plot_cellpose_results()
4931
+ elif current_index == 4: # Edit sub-tab
4932
+ self.plot_edit_mode()
4760
4933
  else:
4761
4934
  self.plot_segmentation()
4762
4935
  else:
@@ -5702,7 +5875,7 @@ class GUI(QMainWindow):
5702
5875
  cellpose_cyto_* and cellpose_nuc_* inputs (model, channel, diameter)
5703
5876
  btn_run_cyto, btn_run_nuc (QPushButton)
5704
5877
  num_masks_slider, min_frames_slider, cell_expansion_slider, cell_shrink_slider
5705
- chk_remove_border_cells, chk_remove_unpaired_cells (QCheckBox)
5878
+ chk_remove_border_cells, chk_remove_unpaired_cells, chk_keep_center_cell (QCheckBox)
5706
5879
  btn_clear_cellpose (QPushButton)
5707
5880
  cellpose_masks_cyto, cellpose_masks_nuc (Optional[np.ndarray])
5708
5881
  cellpose_masks_cyto_tyx, cellpose_masks_nuc_tyx (Optional[np.ndarray])
@@ -5722,7 +5895,7 @@ class GUI(QMainWindow):
5722
5895
  run_cellpose_cyto, run_cellpose_nuc, clear_cellpose_masks
5723
5896
  _on_num_masks_slider_changed, _on_min_frames_slider_changed
5724
5897
  _on_expansion_slider_changed, _on_shrink_slider_changed
5725
- 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
5726
5899
  """
5727
5900
 
5728
5901
  self.segmentation_current_frame = 0
@@ -5738,7 +5911,7 @@ class GUI(QMainWindow):
5738
5911
  main_layout = QHBoxLayout(self.segmentation_tab)
5739
5912
  # LEFT PANEL: Segmentation Figure & Controls
5740
5913
  left_layout = QVBoxLayout()
5741
- main_layout.addLayout(left_layout, stretch=3)
5914
+ main_layout.addLayout(left_layout, stretch=5)
5742
5915
 
5743
5916
  # Create canvas + Z-slider layout (horizontal: canvas on left, Z-slider on right)
5744
5917
  canvas_z_layout = QHBoxLayout()
@@ -5821,12 +5994,15 @@ class GUI(QMainWindow):
5821
5994
 
5822
5995
  # RIGHT PANEL: Segmentation Methods & Source Toggle
5823
5996
  right_layout = QVBoxLayout()
5824
- main_layout.addLayout(right_layout, stretch=1)
5997
+ main_layout.addLayout(right_layout, stretch=2)
5825
5998
 
5826
5999
  # =====================================================================
5827
6000
  # SEGMENTATION METHOD SUB-TABS
5828
6001
  # =====================================================================
5829
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)
5830
6006
 
5831
6007
  # --- Manual Segmentation Tab ---
5832
6008
  manual_tab = QWidget()
@@ -6164,6 +6340,16 @@ class GUI(QMainWindow):
6164
6340
  self.chk_remove_unpaired_cells.stateChanged.connect(self.on_remove_unpaired_cells_changed)
6165
6341
  improve_layout.addRow(self.chk_remove_unpaired_cells)
6166
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
+
6167
6353
  improve_group.setLayout(improve_layout)
6168
6354
  cellpose_layout.addWidget(improve_group)
6169
6355
 
@@ -6234,9 +6420,154 @@ class GUI(QMainWindow):
6234
6420
  # Add Manual tab (Index 2) before Import
6235
6421
  self.segmentation_method_tabs.addTab(manual_tab, "Manual")
6236
6422
 
6237
- # Add Import tab last (Index 3)
6423
+ # Add Import tab (Index 3)
6238
6424
  self.segmentation_method_tabs.addTab(import_tab, "Import")
6239
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
+
6240
6571
  # Initialize Cellpose/imported mask state variables
6241
6572
  # NOTE: Uses segmentation_current_frame and segmentation_current_channel (shared)
6242
6573
  self.cellpose_masks_cyto = None
@@ -6275,21 +6606,625 @@ class GUI(QMainWindow):
6275
6606
  self.plot_segmentation()
6276
6607
 
6277
6608
  def _on_segmentation_subtab_changed(self, index):
6278
- """Handle switching between segmentation method sub-tabs (Watershed, Cellpose, Manual, Import).
6609
+ """Handle switching between segmentation method sub-tabs.
6279
6610
 
6280
6611
  Tab indices:
6281
6612
  0 = Watershed
6282
6613
  1 = Cellpose
6283
6614
  2 = Manual
6284
6615
  3 = Import (uses Cellpose-style display)
6616
+ 4 = Edit (edit existing masks)
6285
6617
  """
6286
- if index == 1 or index == 3: # Cellpose or Import sub-tab
6287
- # 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()
6288
6622
  self.plot_cellpose_results()
6289
6623
  else:
6290
- # For Watershed and Manual, use the standard segmentation display
6624
+ self.exit_edit_mode()
6291
6625
  self.plot_segmentation()
6292
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
+
6293
7228
  # =============================================================================
6294
7229
  # =============================================================================
6295
7230
  # PHOTOBLEACHING TAB
@@ -6924,9 +7859,12 @@ class GUI(QMainWindow):
6924
7859
  z_spot_size_in_px=z_spot_size,
6925
7860
  use_3d=use_3d
6926
7861
  )
6927
- threshold = auto_thresh.calculate()
7862
+ threshold_raw = auto_thresh.calculate()
6928
7863
  method_used = auto_thresh.method_used
6929
7864
 
7865
+ # Reduce threshold by 10% to improve spot coverage (auto-threshold tends to overestimate)
7866
+ threshold = threshold_raw * 0.9
7867
+
6930
7868
  # Store per-channel
6931
7869
  self.auto_threshold_per_channel[channel] = threshold
6932
7870
 
@@ -15167,6 +16105,21 @@ class GUI(QMainWindow):
15167
16105
  self.watershed_size_slider.blockSignals(False)
15168
16106
  if hasattr(self, 'watershed_size_label'):
15169
16107
  self.watershed_size_label.setText("0")
16108
+
16109
+ # Reset Edit tab state
16110
+ self.edit_mode_active = False
16111
+ self.edit_working_mask = None
16112
+ self.edit_original_mask = None
16113
+ self.edit_current_mask_key = None
16114
+ self.edit_undo_stack = []
16115
+ if hasattr(self, '_disconnect_edit_mouse_events'):
16116
+ self._disconnect_edit_mouse_events()
16117
+ if hasattr(self, 'edit_mask_selector'):
16118
+ self.edit_mask_selector.blockSignals(True)
16119
+ self.edit_mask_selector.setCurrentIndex(0)
16120
+ self.edit_mask_selector.blockSignals(False)
16121
+ if hasattr(self, 'edit_instructions_group'):
16122
+ self.edit_instructions_group.setVisible(False)
15170
16123
 
15171
16124
  def reset_photobleaching_tab(self):
15172
16125
  self.figure_photobleaching.clear()
@@ -15946,6 +16899,8 @@ class GUI(QMainWindow):
15946
16899
  self.chk_remove_border_cells.setChecked(False)
15947
16900
  if hasattr(self, 'chk_remove_unpaired_cells'):
15948
16901
  self.chk_remove_unpaired_cells.setChecked(False)
16902
+ if hasattr(self, 'chk_keep_center_cell'):
16903
+ self.chk_keep_center_cell.setChecked(False)
15949
16904
 
15950
16905
  # Refresh the shared segmentation display
15951
16906
  # (Cellpose now shares the segmentation canvas, so just refresh it)