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/__init__.py +1 -1
- microlive/gui/app.py +969 -14
- microlive/imports.py +5 -1
- microlive/pipelines/pipeline_FRAP.py +19 -1
- microlive/pipelines/pipeline_folding_efficiency.py +29 -25
- microlive/pipelines/pipeline_particle_tracking.py +616 -176
- {microlive-1.0.18.dist-info → microlive-1.0.20.dist-info}/METADATA +1 -1
- {microlive-1.0.18.dist-info → microlive-1.0.20.dist-info}/RECORD +11 -12
- microlive/pipelines/pipeline_spot_detection_no_tracking.py +0 -368
- {microlive-1.0.18.dist-info → microlive-1.0.20.dist-info}/WHEEL +0 -0
- {microlive-1.0.18.dist-info → microlive-1.0.20.dist-info}/entry_points.txt +0 -0
- {microlive-1.0.18.dist-info → microlive-1.0.20.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
4731
|
-
if
|
|
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
|
-
|
|
4758
|
-
if
|
|
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=
|
|
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=
|
|
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
|
|
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
|
|
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 ==
|
|
6287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|